Skip to Content
Week 05Day 6 - 小项目:工程化小应用

Day 6 - 小项目:工程化小应用

建议用时:280-340 分钟

你将学会什么

  • 如何把解决方案结构、依赖注入、配置、日志、测试思想组合起来
  • 如何让入口层只负责组装和调用
  • 如何让 Service 负责业务规则
  • 如何让 Repository 负责数据保存
  • 如何用配置控制文件路径和日志开关
  • 如何用假的 Repository 验证服务逻辑

今天的小项目重点不是功能多,而是结构清楚。一个商品新增功能,也可以写出入口、配置、日志、Service、Repository、测试替身这些工程化骨架。

本页固定顺序

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

学习衔接

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

今天的最低通过线

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

  • 能用自己的话说明“小项目:工程化小应用”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

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

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。工程化不是把代码拆很多文件,而是让每块代码都有明确职责。

1. 小项目也要有结构

哪怕只有一个“新增商品”功能,也能拆成:

职责对应代码
入口读取配置、创建对象、调用服务
配置AppOptions
日志IAppLogger
业务规则ProductProductService
数据保存IProductRepository
文件实现FileProductRepository
测试替身FakeProductRepository

结构不是为了复杂,而是为了以后能改。

2. 入口层负责组装

入口层做这些事:

  1. 读取配置。
  2. 创建日志对象。
  3. 创建 Repository。
  4. 创建 Service。
  5. 调用 Service。
  6. 处理最终错误提示。

入口层不应该写商品价格校验规则。

3. Service 负责业务流程

ProductService 负责:

  1. 记录开始日志。
  2. 创建商品。
  3. 调用 Repository 保存。
  4. 记录成功日志。

它不负责:

  1. 配置文件怎么读取。
  2. 商品保存到哪个文件。
  3. 日志最终输出到哪里。

4. 实体负责自己的基本规则

Product 负责:

  1. 名称不能为空。
  2. 价格必须大于 0。
  3. 名称需要 Trim。

这些规则属于商品自己,不应该散落在入口层。

5. Repository 负责数据保存

Repository 只负责保存和读取数据。

例如:

FileProductRepository 保存到文件 MemoryProductRepository 保存到内存 SqlProductRepository 保存到数据库

Service 依赖接口,所以可以替换实现。

6. 配置负责变化项

本项目中变化项是:

  1. 商品文件路径。
  2. 是否启用日志。

以后还可以加:

  1. 日志级别。
  2. 接口地址。
  3. 超时时间。
  4. 数据库连接字符串。

7. 测试为什么需要假的 Repository

测试 ProductService 时,不需要真的写文件。

可以用:

MemoryProductRepository

它把商品保存到列表里,测试就能检查列表数量。

工程化小应用常用操作速查

需求常用写法
Core 放业务规则ProductProductService
Infrastructure 放实现JsonProductRepository
Cli 放入口Program.cs
Tests 放测试ProductServiceTests
服务依赖接口ProductService(IProductRepository repo)
程序注册服务services.AddSingleton<IProductRepository, ...>()
读取配置IConfiguration
记录日志ILogger<T>
验证规则xUnit Assert

工程化最小闭环:

CLI 读取输入 -> 调用 Service -> Service 校验业务规则 -> Repository 保存数据 -> Logger 记录关键过程 -> Tests 验证 Service

第二部分:把知识组合成完整例子

前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要能写出一个结构清楚的小应用。

先看效果:配置 + 日志 + Service + Repository

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(); options.Validate(); IAppLogger logger = new ConsoleAppLogger(options.EnableLog); IProductRepository repository = new FileProductRepository(options.DataFile); ProductService service = new ProductService(repository, logger); try { Product product = service.Create(" 键盘 ", 199m); Console.WriteLine($"创建成功: {product.Name} | {product.Price}"); } catch (ArgumentException ex) { logger.Error("创建商品失败", ex); } Console.WriteLine("文件内容:"); Console.WriteLine(await File.ReadAllTextAsync(options.DataFile)); class AppOptions { public string DataFile { get; set; } = "products.txt"; public bool EnableLog { get; set; } = true; public void Validate() { if (string.IsNullOrWhiteSpace(DataFile)) { throw new InvalidOperationException("DataFile 不能为空"); } } } 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={name}"); Product product = new Product(name, price); _repository.Save(product); _logger.Information($"商品创建成功,Name={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; } }

这个小项目已经包含 Week 5 的主线:

能力代码位置
配置AppOptions
日志IAppLoggerConsoleAppLogger
依赖注入new ProductService(repository, logger)
RepositoryIProductRepositoryFileProductRepository
ServiceProductService
业务规则Product 构造函数
错误处理try/catch

第三部分:跟着敲代码

从这里开始动手。每个例子都是完整代码,可以直接放进 Program.cs 运行。

动手前先做这 3 件事

  1. 先跑通最小新增商品。
  2. 每次只加一个结构:配置、日志、Repository、测试替身。
  3. 每一步都运行一次。

例子 1:最小商品创建

Product product = new Product(" 键盘 ", 199m); Console.WriteLine($"{product.Name} | {product.Price}"); 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; } }

先把业务规则放进实体。

例子 2:加入 Repository 接口

IProductRepository repository = new MemoryProductRepository(); Product product = new Product("键盘", 199m); repository.Save(product); Console.WriteLine($"保存数量: {((MemoryProductRepository)repository).Products.Count}"); interface IProductRepository { void Save(Product product); } class MemoryProductRepository : IProductRepository { public List<Product> Products { get; } = new List<Product>(); public void Save(Product product) { Products.Add(product); } } class Product { public Product(string name, decimal price) { Name = name; Price = price; } public string Name { get; } public decimal Price { get; } }

接口先出来,保存实现就可以替换。

例子 3:加入 Service

MemoryProductRepository repository = new MemoryProductRepository(); ProductService service = new ProductService(repository); service.Create("键盘", 199m); Console.WriteLine($"保存数量: {repository.Products.Count}"); interface IProductRepository { void Save(Product 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 MemoryProductRepository : IProductRepository { public List<Product> Products { get; } = new List<Product>(); public void Save(Product product) { Products.Add(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 组织业务流程,Repository 负责保存。

例子 4:加入配置

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; }

配置先能读取,再传给后面的对象使用。

例子 5:加入日志接口

IAppLogger logger = new ConsoleAppLogger(enabled: true); logger.Information("程序启动"); logger.Error("模拟错误", new InvalidOperationException("测试错误")); 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}"); } } }

日志也用接口,未来可以换成文件日志或框架日志。

例子 6:完整工程化小应用

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(); options.Validate(); IAppLogger logger = new ConsoleAppLogger(options.EnableLog); IProductRepository repository = new FileProductRepository(options.DataFile); ProductService service = new ProductService(repository, logger); try { Product product = service.Create("显示器", 1299m); 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; public void Validate() { if (string.IsNullOrWhiteSpace(DataFile)) { throw new InvalidOperationException("DataFile 不能为空"); } } } 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; } }

例子 7:用假的 Repository 验证 Service

FakeProductRepository repository = new FakeProductRepository(); NullLogger logger = new NullLogger(); ProductService service = new ProductService(repository, logger); Product product = service.Create("键盘", 199m); Console.WriteLine(product.Name == "键盘" ? "名称测试通过" : "名称测试失败"); 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); } } interface IAppLogger { void Information(string message); void Error(string message, Exception? exception = null); } class NullLogger : IAppLogger { public void Information(string message) { } public void Error(string message, Exception? exception = null) { } } 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("开始创建商品"); Product product = new Product(name, price); _repository.Save(product); 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; } }

这个例子就是测试思路:不写文件,只验证服务规则和保存调用。

常见错误和修法

错误为什么错修法
小项目只堆功能后面难维护按 Core、Services、UI 或 Console 分层
没有日志出错后无法定位关键流程记录输入、结果和异常
没有配置路径、开关、参数都写死把可变内容放配置文件
没有测试核心规则改动后容易破坏旧逻辑给服务层和校验规则补测试
README 不写运行命令接手者不知道怎么启动写清 restorebuildrun

小白重复敲写训练

工程化小项目先分别验证服务、配置和日志,再组合。

训练 1:仓储接口和内存实现

IProductRepository repository = new MemoryProductRepository(); repository.Add("Keyboard"); Console.WriteLine(string.Join(", ", repository.GetAll())); interface IProductRepository { void Add(string name); IReadOnlyList<string> GetAll(); } class MemoryProductRepository : IProductRepository { private readonly List<string> _items = new(); public void Add(string name) => _items.Add(name); public IReadOnlyList<string> GetAll() => _items; }

训练 2:服务依赖仓储

class ProductService(IProductRepository repository) { public void Create(string name) { if (string.IsNullOrWhiteSpace(name)) return; repository.Add(name.Trim()); } }

改动任务:增加重复名称检查。

训练 3:给服务写测试

[Fact] public void Create_EmptyName_DoesNotAdd() { var repository = new MemoryProductRepository(); var service = new ProductService(repository); service.Create(" "); Assert.Empty(repository.GetAll()); }

第三遍再写一个成功添加的测试。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:小项目:工程化小应用。今天至少要掌握的 3 个点是:

1. 如何把解决方案结构、依赖注入、配置、日志、测试思想组合起来 2. 如何让入口层只负责组装和调用 3. 如何让 Service 负责业务规则

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:如何把解决方案结构、依赖注入、配置、日志、测试思想组合起来。

上位机专项练习

把分层、配置、依赖注入、日志和测试组合成可维护的设备监控控制台。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:业务服务只依赖接口

class DeviceService { private readonly IReader reader; public DeviceService(IReader reader) => this.reader = reader; public double Collect() => reader.Read(); }

运行结果或界面效果:

DeviceService 不知道底层是串口还是模拟器

改动任务: 增加 IsAlarm 方法。

专项例子 2:启动时组合对象

IReader reader = new FakeReader(); var service = new DeviceService(reader); double value = service.Collect(); Console.WriteLine($"采集值: {value}");

运行结果或界面效果:

采集值: 模拟读取值

改动任务: 把 FakeReader 换成第二个实现。

专项例子 3:输出运行摘要

var summary = new { DeviceCount = 3, OnlineCount = 2, AlarmCount = 1 }; Console.WriteLine($"设备 {summary.DeviceCount} / 在线 {summary.OnlineCount} / 报警 {summary.AlarmCount}");

运行结果或界面效果:

设备 3 / 在线 2 / 报警 1

改动任务: 增加 OfflineCount。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。建议先照着敲一遍,再改配置值和错误输入验证。

作业 1:完整工程化小应用

要求:

  1. appsettings.json 读取 DataFileEnableLog
  2. ProductService 通过接口依赖 Repository 和 Logger。
  3. 商品名不能为空,价格必须大于 0。
  4. 保存到配置指定的文件。
  5. 正常流程有日志,异常流程有错误日志。

完整答案

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(); options.Validate(); IAppLogger logger = new ConsoleAppLogger(options.EnableLog); IProductRepository repository = new FileProductRepository(options.DataFile); ProductService service = new ProductService(repository, logger); try { Product product = service.Create("键盘", 199m); 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; public void Validate() { if (string.IsNullOrWhiteSpace(DataFile)) { throw new InvalidOperationException("DataFile 不能为空"); } } } 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; } }

作业 2:测试替身完整答案

FakeProductRepository repository = new FakeProductRepository(); NullLogger logger = new NullLogger(); ProductService service = new ProductService(repository, logger); service.Create("鼠标", 88m); 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); } } interface IAppLogger { void Information(string message); void Error(string message, Exception? exception = null); } class NullLogger : IAppLogger { public void Information(string message) { } public void Error(string message, Exception? exception = null) { } } 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("开始创建商品"); Product product = new Product(name, price); _repository.Save(product); 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 里。
  2. Service 不直接 new Repository。
  3. 日志通过接口注入。
  4. 文件保存只在 Repository 里。
  5. 商品规则在 Product 或 ProductService 里。
  6. 可以用 Fake Repository 验证 Service。

本页最后要记住

  1. 工程化不是把代码拆散,而是让职责清楚。
  2. 入口层负责组装和调用。
  3. 配置负责变化参数。
  4. 日志负责运行线索。
  5. Service 负责业务流程。
  6. Repository 负责数据读写。
  7. 接口让真实实现和测试替身都能替换。