Skip to Content
Week 05Day 7 - 复盘与巩固

Day 7 - 第五周复盘与综合练习

建议用时:220-280 分钟

你将学会什么

  • 如何判断一段代码应该放在入口、Core、Infrastructure 还是 Tests
  • 如何把 DI、配置、日志、测试串成工程化主线
  • 如何用一个商品小应用检查第五周是否掌握
  • 如何发现“能跑但结构不好”的代码

第五周的重点不是多背几个名词,而是知道代码应该放哪里、依赖应该从哪里来、配置和日志怎么接入、业务规则怎么测试。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“小项目:工程化小应用”,今天继续学习“第五周复盘与综合练习”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“第五周复盘与综合练习”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

1. Week 5 主线

第五周解决的是:

项目变大后,代码如何放得清楚、换得动、测得住、查得出问题。

2. 判断表

你遇到的问题应该想到什么
多个项目怎么组织Solution / Project
商品规则放哪里Core
文件保存放哪里Infrastructure
业务类需要 Repository构造函数注入
文件路径可能变化配置
程序运行后怎么排查日志
改代码后怎么确认没坏单元测试
测 Service 不想写文件Fake Repository

3. 工程化不是为了复杂

工程化的目的不是把简单代码变复杂,而是让它以后能扩展。

一个好结构应该满足:

  1. 换文件保存为数据库保存,Service 不需要改。
  2. 关闭日志,只改配置,不改业务代码。
  3. 测试 ProductService,不需要真实文件。
  4. 业务规则失败时,能从日志看到线索。

4. 本周最常见错误

错误后果
Service 里直接 new FileRepository难替换、难测试
配置写死在很多地方换环境困难
所有日志都写 Error真错误被淹没
测试直接依赖文件慢、不稳定
Core 依赖 UI 或 Infrastructure结构倒置

第五周常用 API 总表

主题必须会的操作
解决方案dotnet new slndotnet sln adddotnet add reference
分层Core、Infrastructure、Cli、Tests
依赖注入ServiceCollectionAddSingletonAddTransientGetRequiredService
配置ConfigurationBuilderAddJsonFileGetValue<T>GetSection
日志ILogger<T>LogInformationLogWarningLogError
测试[Fact][Theory]Assert.EqualAssert.Throws
构建验证dotnet builddotnet 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)
RepositoryIProductRepository
ServiceProductService
业务规则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; } }

综合验收

完成后检查:

  1. Service 不直接 new Repository。
  2. 文件路径在配置对象里。
  3. 日志通过接口注入。
  4. 商品规则能拦住空名称和非法价格。
  5. 可以把 FileProductRepository 换成 FakeProductRepository

本页最后要记住

  1. 工程化的核心是职责清楚。
  2. Core 放业务规则和接口。
  3. Infrastructure 放外部实现。
  4. 配置管理变化值。
  5. 日志记录运行线索。
  6. 测试保护业务规则。