Day 7 - 第五周复盘与综合练习
建议用时:220-280 分钟
你将学会什么
- 如何判断一段代码应该放在入口、Core、Infrastructure 还是 Tests
- 如何把 DI、配置、日志、测试串成工程化主线
- 如何用一个商品小应用检查第五周是否掌握
- 如何发现“能跑但结构不好”的代码
第五周的重点不是多背几个名词,而是知道代码应该放哪里、依赖应该从哪里来、配置和日志怎么接入、业务规则怎么测试。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“小项目:工程化小应用”,今天继续学习“第五周复盘与综合练习”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“第五周复盘与综合练习”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
1. Week 5 主线
第五周解决的是:
项目变大后,代码如何放得清楚、换得动、测得住、查得出问题。2. 判断表
| 你遇到的问题 | 应该想到什么 |
|---|---|
| 多个项目怎么组织 | Solution / Project |
| 商品规则放哪里 | Core |
| 文件保存放哪里 | Infrastructure |
| 业务类需要 Repository | 构造函数注入 |
| 文件路径可能变化 | 配置 |
| 程序运行后怎么排查 | 日志 |
| 改代码后怎么确认没坏 | 单元测试 |
| 测 Service 不想写文件 | Fake Repository |
3. 工程化不是为了复杂
工程化的目的不是把简单代码变复杂,而是让它以后能扩展。
一个好结构应该满足:
- 换文件保存为数据库保存,Service 不需要改。
- 关闭日志,只改配置,不改业务代码。
- 测试 ProductService,不需要真实文件。
- 业务规则失败时,能从日志看到线索。
4. 本周最常见错误
| 错误 | 后果 |
|---|---|
Service 里直接 new FileRepository | 难替换、难测试 |
| 配置写死在很多地方 | 换环境困难 |
| 所有日志都写 Error | 真错误被淹没 |
| 测试直接依赖文件 | 慢、不稳定 |
| Core 依赖 UI 或 Infrastructure | 结构倒置 |
第五周常用 API 总表
| 主题 | 必须会的操作 |
|---|---|
| 解决方案 | dotnet new sln、dotnet sln add、dotnet add reference |
| 分层 | Core、Infrastructure、Cli、Tests |
| 依赖注入 | ServiceCollection、AddSingleton、AddTransient、GetRequiredService |
| 配置 | ConfigurationBuilder、AddJsonFile、GetValue<T>、GetSection |
| 日志 | ILogger<T>、LogInformation、LogWarning、LogError |
| 测试 | [Fact]、[Theory]、Assert.Equal、Assert.Throws |
| 构建验证 | dotnet build、dotnet test |
本周所有 API 都在解决一个问题:让项目从单文件练习变成结构清楚、可配置、可测试、可排错的小应用。
第二部分:把知识组合成完整例子
先看效果:商品小应用工程化主线
AppOptions options = new AppOptions
{
DataFile = "products.txt",
EnableLog = true
};
IAppLogger logger = new ConsoleAppLogger(options.EnableLog);
IProductRepository repository = new FileProductRepository(options.DataFile);
ProductService service = new ProductService(repository, logger);
Product product = service.Create("键盘", 199m);
Console.WriteLine($"创建成功: {product.Name}");
class AppOptions
{
public string DataFile { get; set; } = "products.txt";
public bool EnableLog { get; set; } = true;
}
interface IAppLogger
{
void Information(string message);
}
class ConsoleAppLogger : IAppLogger
{
private readonly bool _enabled;
public ConsoleAppLogger(bool enabled)
{
_enabled = enabled;
}
public void Information(string message)
{
if (_enabled)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss} [INFO] {message}");
}
}
}
interface IProductRepository
{
void Save(Product product);
}
class FileProductRepository : IProductRepository
{
private readonly string _path;
public FileProductRepository(string path)
{
_path = path;
}
public void Save(Product product)
{
File.AppendAllText(_path, $"{product.Name},{product.Price}" + Environment.NewLine);
}
}
class ProductService
{
private readonly IProductRepository _repository;
private readonly IAppLogger _logger;
public ProductService(IProductRepository repository, IAppLogger logger)
{
_repository = repository;
_logger = logger;
}
public Product Create(string name, decimal price)
{
Product product = new Product(name, price);
_repository.Save(product);
_logger.Information($"商品创建成功: {product.Name}");
return product;
}
}
class Product
{
public Product(string name, decimal price)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("商品名称不能为空");
}
if (price <= 0)
{
throw new ArgumentException("商品价格必须大于 0");
}
Name = name.Trim();
Price = price;
}
public string Name { get; }
public decimal Price { get; }
}这个例子里:
| 知识点 | 位置 |
|---|---|
| 配置 | AppOptions |
| 日志 | IAppLogger |
| 依赖注入 | new ProductService(repository, logger) |
| Repository | IProductRepository |
| Service | ProductService |
| 业务规则 | Product |
第三部分:跟着敲代码
每个例子都是完整代码,可以直接放进 Program.cs 运行。
例子 1:接口注入复盘
IMessageWriter writer = new ConsoleMessageWriter();
ProductService service = new ProductService(writer);
service.Save();
interface IMessageWriter
{
void Write(string message);
}
class ConsoleMessageWriter : IMessageWriter
{
public void Write(string message)
{
Console.WriteLine(message);
}
}
class ProductService
{
private readonly IMessageWriter _writer;
public ProductService(IMessageWriter writer)
{
_writer = writer;
}
public void Save()
{
_writer.Write("保存成功");
}
}业务类依赖接口,不依赖具体输出方式。
例子 2:配置复盘
using System.Text.Json;
await File.WriteAllTextAsync("appsettings.json", """
{
"DataFile": "products.txt",
"EnableLog": true
}
""");
string json = await File.ReadAllTextAsync("appsettings.json");
AppOptions options = JsonSerializer.Deserialize<AppOptions>(json) ?? new AppOptions();
Console.WriteLine(options.DataFile);
Console.WriteLine(options.EnableLog);
class AppOptions
{
public string DataFile { get; set; } = "products.txt";
public bool EnableLog { get; set; } = true;
}配置值从文件来,不散落在业务代码里。
例子 3:日志复盘
IAppLogger logger = new ConsoleAppLogger();
logger.Information("开始保存商品");
logger.Error("保存失败", new InvalidOperationException("磁盘空间不足"));
interface IAppLogger
{
void Information(string message);
void Error(string message, Exception? exception = null);
}
class ConsoleAppLogger : IAppLogger
{
public void Information(string message)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss} [INFO] {message}");
}
public void Error(string message, Exception? exception = null)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss} [ERROR] {message}");
if (exception is not null)
{
Console.WriteLine($"{exception.GetType().Name}: {exception.Message}");
}
}
}日志要有时间、级别、动作和错误原因。
例子 4:测试替身复盘
FakeProductRepository repository = new FakeProductRepository();
ProductService service = new ProductService(repository);
service.Create("键盘", 199m);
Console.WriteLine(repository.Saved.Count == 1 ? "测试通过" : "测试失败");
interface IProductRepository
{
void Save(Product product);
}
class FakeProductRepository : IProductRepository
{
public List<Product> Saved { get; } = new List<Product>();
public void Save(Product product)
{
Saved.Add(product);
}
}
class ProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public Product Create(string name, decimal price)
{
Product product = new Product(name, price);
_repository.Save(product);
return product;
}
}
class Product
{
public Product(string name, decimal price)
{
Name = name;
Price = price;
}
public string Name { get; }
public decimal Price { get; }
}测试 Service 时,用假的 Repository 就能避免真实文件。
小白重复敲写训练
复盘日从零搭一个最小三层程序,不追求功能多。
训练 1:核心层计算
public static class PriceCalculator
{
public static decimal Total(decimal price, int count) => price * count;
}训练 2:应用层调用接口
public interface INotifier
{
void Send(string message);
}
public class OrderService(INotifier notifier)
{
public void Create() => notifier.Send("订单已创建");
}训练 3:测试核心规则
[Fact]
public void Total_TwoItems_ReturnsCorrectAmount()
{
Assert.Equal(40m, PriceCalculator.Total(20m, 2));
}从空白文件独立写一遍,然后运行 dotnet test。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:第五周复盘与综合练习。今天至少要掌握的 3 个点是:
1. 如何判断一段代码应该放在入口、Core、Infrastructure 还是 Tests
2. 如何把 DI、配置、日志、测试串成工程化主线
3. 如何用一个商品小应用检查第五周是否掌握答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:如何判断一段代码应该放在入口、Core、Infrastructure 还是 Tests。
上位机专项练习
本周过关标准不是背目录,而是能说明每层职责,并能替换读取器、修改配置、查看日志和运行测试。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:复习职责边界
Console.WriteLine("Core: 设备模型和业务规则");
Console.WriteLine("Infrastructure: 串口、TCP、文件、数据库");
Console.WriteLine("App: 启动和界面");运行结果或界面效果:
三层职责清楚改动任务: 增加 Tests 层的职责。
专项例子 2:复习依赖倒置
IReader reader = new FakeReader();
var monitor = new Monitor(reader);
monitor.Show();运行结果或界面效果:
业务依赖 IReader 接口改动任务: 画出 Monitor -> IReader <- FakeReader。
专项例子 3:复习工程检查
string[] checks = ["配置可修改", "日志可定位", "规则有测试"];
foreach (string check in checks)
{
Console.WriteLine($"[完成] {check}");
}运行结果或界面效果:
[完成] 配置可修改
[完成] 日志可定位
[完成] 规则有测试改动任务: 增加“通信可替换”。
第四部分:作业完整答案
作业 1:概念判断
| 场景 | 答案 |
|---|---|
| 多项目放在一起管理 | Solution |
| 商品规则和接口 | Core |
| 文件保存实现 | Infrastructure |
| 从外面传入 Repository | 依赖注入 |
| 文件路径从外部读取 | 配置 |
| 记录保存成功和失败 | 日志 |
| 用代码验证规则 | 单元测试 |
作业 2:综合完整答案
AppOptions options = new AppOptions
{
DataFile = "products.txt",
EnableLog = true
};
IAppLogger logger = new ConsoleAppLogger(options.EnableLog);
IProductRepository repository = new FileProductRepository(options.DataFile);
ProductService service = new ProductService(repository, logger);
try
{
Product product = service.Create("鼠标", 88m);
Console.WriteLine($"创建成功: {product.Name}");
}
catch (ArgumentException ex)
{
logger.Error("创建失败", ex);
}
class AppOptions
{
public string DataFile { get; set; } = "products.txt";
public bool EnableLog { get; set; } = true;
}
interface IAppLogger
{
void Information(string message);
void Error(string message, Exception? exception = null);
}
class ConsoleAppLogger : IAppLogger
{
private readonly bool _enabled;
public ConsoleAppLogger(bool enabled)
{
_enabled = enabled;
}
public void Information(string message)
{
if (_enabled)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss} [INFO] {message}");
}
}
public void Error(string message, Exception? exception = null)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss} [ERROR] {message}");
if (exception is not null)
{
Console.WriteLine($"{exception.GetType().Name}: {exception.Message}");
}
}
}
interface IProductRepository
{
void Save(Product product);
}
class FileProductRepository : IProductRepository
{
private readonly string _path;
public FileProductRepository(string path)
{
_path = path;
}
public void Save(Product product)
{
File.AppendAllText(_path, $"{product.Name},{product.Price}" + Environment.NewLine);
}
}
class ProductService
{
private readonly IProductRepository _repository;
private readonly IAppLogger _logger;
public ProductService(IProductRepository repository, IAppLogger logger)
{
_repository = repository;
_logger = logger;
}
public Product Create(string name, decimal price)
{
_logger.Information($"开始创建商品: {name}");
Product product = new Product(name, price);
_repository.Save(product);
_logger.Information($"商品创建成功: {product.Name}");
return product;
}
}
class Product
{
public Product(string name, decimal price)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("商品名称不能为空");
}
if (price <= 0)
{
throw new ArgumentException("商品价格必须大于 0");
}
Name = name.Trim();
Price = price;
}
public string Name { get; }
public decimal Price { get; }
}综合验收
完成后检查:
- Service 不直接 new Repository。
- 文件路径在配置对象里。
- 日志通过接口注入。
- 商品规则能拦住空名称和非法价格。
- 可以把
FileProductRepository换成FakeProductRepository。
本页最后要记住
- 工程化的核心是职责清楚。
- Core 放业务规则和接口。
- Infrastructure 放外部实现。
- 配置管理变化值。
- 日志记录运行线索。
- 测试保护业务规则。