Skip to Content
Week 10Day 3 - 服务层

Day 3 - 服务层

建议用时:180-210 分钟

你将学会什么

  • 服务层负责什么
  • 为什么业务规则不写在界面里
  • 什么是请求对象
  • 什么是返回结果对象
  • 怎么校验名称、价格、库存
  • 怎么让服务层调用 Repository

数据层负责存取,服务层负责规则。今天重点是把“能不能新增、为什么不能新增、删除前有没有选中”这些判断放进 ProductService,不要散落在按钮事件或 ViewModel 里。

本页固定顺序

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

学习衔接

上一页学习的是“数据层准备”,今天继续学习“服务层”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

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

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

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

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。服务层的核心问题是:业务规则到底放在哪里。

项目分层再看一遍

负责什么不负责什么
界面层显示、输入、按钮不负责核心业务规则
服务层校验、业务动作、结果返回不负责控件排版
数据层保存、读取、查询不负责名称是否为空

例如“价格必须大于 0”不是界面规则,也不是文件规则,它是业务规则,所以放在服务层。

为什么不把规则写在按钮里

如果把规则写在按钮里,会出现这些问题:

  • 新增页面写一遍校验。
  • 编辑页面又写一遍校验。
  • 批量导入也要写一遍校验。
  • 以后规则改了,要到处找代码。

服务层集中规则后,界面只需要调用:

ServiceResult result = await service.CreateAsync(request);

然后根据 result.Successresult.Message 显示结果。

什么是请求对象

请求对象是把一次操作需要的输入打包起来。

本页的新增请求是:

public sealed class CreateProductRequest { public string Name { get; set; } = ""; public string PriceText { get; set; } = ""; public string StockText { get; set; } = ""; }

为什么价格和库存先用字符串?

因为界面输入框拿到的通常就是文本。服务层要判断:

  • 文本能不能转成数字。
  • 数字是否符合规则。

什么是返回结果对象

服务层可以抛异常,也可以返回结果对象。

本页对“名称为空、价格不是数字、库存小于 0”这类可预期问题,使用返回结果对象:

public sealed class ServiceResult { public bool Success { get; init; } public string Message { get; init; } = ""; public Guid? ProductId { get; init; } }

好处是:

  • 成功失败都能统一处理。
  • UI 不需要用异常控制正常校验流程。
  • 可以带回提示文字。
  • 新增成功时还能带回商品 Id。

什么时候用异常

可预期的业务失败,用结果对象。

例如:

  • 名称为空。
  • 价格不是数字。
  • 没有选中商品。

真正意外的问题,才适合异常。

例如:

  • 文件无法写入。
  • JSON 文件损坏。
  • 磁盘权限失败。

这类问题可以在更外层捕获并写日志。

为什么用接口 IProductRepository

服务层不直接依赖具体存储。

本页用了:

public interface IProductRepository

然后服务层依赖接口:

private readonly IProductRepository repository;

这样好处是:

  • 今天可以用 InMemoryProductRepository 验证服务规则。
  • 明天可以换成 JSON Repository。
  • 后面也可以换成 SQLite Repository。
  • ProductService 的业务规则不用改。

服务层方法名要表达业务动作

差的名字:

Do() Save() Handle()

更清楚的名字:

CreateAsync() DeleteAsync() GetAllAsync()

方法名清楚,后面 ViewModel 调用时就不容易乱。

服务层常用写法速查

需求常用写法说明
注入仓储ProductService(IProductRepository repo)服务不直接 new 数据层
请求对象CreateProductRequest保存输入参数
返回结果ServiceResult<T>成功/失败/错误
校验名称IsNullOrWhiteSpace必填
校验价格price <= 0业务规则
查重Any(product => product.Name == name)防重复
成功返回ServiceResult.Ok(data)带结果
失败返回ServiceResult.Fail("原因")不抛给界面

服务层固定顺序:

接收请求 -> 校验输入 -> 查询必要数据 -> 执行业务规则 -> 调用仓储 -> 返回结果

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

今天做一个能跑的 ProductService

它会处理:

  • 新增商品。
  • 名称不能为空。
  • 名称最多 50 个字符。
  • 价格必须是数字,并且大于 0。
  • 库存必须是整数,并且不能小于 0。
  • 删除商品前必须传入商品 Id。
  • 每次操作返回 ServiceResult,告诉调用者成功还是失败。

完整 Program.cs

using System; using System.Collections.Generic; using System.Linq; var repository = new InMemoryProductRepository(); var service = new ProductService(repository); ServiceResult ok = await service.CreateAsync(new CreateProductRequest { Name = "Keyboard", PriceText = "199", StockText = "10" }); Console.WriteLine($"{ok.Success} - {ok.Message}"); ServiceResult emptyName = await service.CreateAsync(new CreateProductRequest { Name = "", PriceText = "199", StockText = "10" }); Console.WriteLine($"{emptyName.Success} - {emptyName.Message}"); ServiceResult badPrice = await service.CreateAsync(new CreateProductRequest { Name = "Mouse", PriceText = "abc", StockText = "10" }); Console.WriteLine($"{badPrice.Success} - {badPrice.Message}"); ServiceResult badStock = await service.CreateAsync(new CreateProductRequest { Name = "Monitor", PriceText = "899", StockText = "-1" }); Console.WriteLine($"{badStock.Success} - {badStock.Message}"); List<Product> products = await service.GetAllAsync(); Console.WriteLine($"当前商品数量:{products.Count}"); ServiceResult deleteWithoutId = await service.DeleteAsync(null); Console.WriteLine($"{deleteWithoutId.Success} - {deleteWithoutId.Message}"); if (ok.ProductId is Guid productId) { ServiceResult deleted = await service.DeleteAsync(productId); Console.WriteLine($"{deleted.Success} - {deleted.Message}"); } List<Product> productsAfterDelete = await service.GetAllAsync(); Console.WriteLine($"删除后商品数量:{productsAfterDelete.Count}"); public sealed class CreateProductRequest { public string Name { get; set; } = ""; public string PriceText { get; set; } = ""; public string StockText { get; set; } = ""; } public sealed class ServiceResult { public bool Success { get; init; } public string Message { get; init; } = ""; public Guid? ProductId { get; init; } public static ServiceResult Ok(string message, Guid? productId = null) { return new ServiceResult { Success = true, Message = message, ProductId = productId }; } public static ServiceResult Fail(string message) { return new ServiceResult { Success = false, Message = message }; } } public sealed class Product { public Guid Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } public interface IProductRepository { Task<List<Product>> GetAllAsync(); Task AddAsync(Product product); Task<Product?> FindByIdAsync(Guid id); Task DeleteAsync(Guid id); } public sealed class InMemoryProductRepository : IProductRepository { private readonly List<Product> products = new(); public Task<List<Product>> GetAllAsync() { return Task.FromResult(products.ToList()); } public Task AddAsync(Product product) { products.Add(product); return Task.CompletedTask; } public Task<Product?> FindByIdAsync(Guid id) { Product? product = products.FirstOrDefault(item => item.Id == id); return Task.FromResult(product); } public Task DeleteAsync(Guid id) { products.RemoveAll(item => item.Id == id); return Task.CompletedTask; } } public sealed class ProductService { private readonly IProductRepository repository; public ProductService(IProductRepository repository) { this.repository = repository; } public Task<List<Product>> GetAllAsync() { return repository.GetAllAsync(); } public async Task<ServiceResult> CreateAsync(CreateProductRequest request) { string name = request.Name.Trim(); if (string.IsNullOrWhiteSpace(name)) { return ServiceResult.Fail("商品名称不能为空。"); } if (name.Length > 50) { return ServiceResult.Fail("商品名称不能超过 50 个字符。"); } if (!decimal.TryParse(request.PriceText, out decimal price)) { return ServiceResult.Fail("价格必须是数字。"); } if (price <= 0) { return ServiceResult.Fail("价格必须大于 0。"); } if (!int.TryParse(request.StockText, out int stock)) { return ServiceResult.Fail("库存必须是整数。"); } if (stock < 0) { return ServiceResult.Fail("库存不能小于 0。"); } DateTime now = DateTime.Now; var product = new Product { Id = Guid.NewGuid(), Name = name, Price = price, Stock = stock, CreatedAt = now, UpdatedAt = now }; await repository.AddAsync(product); return ServiceResult.Ok("商品新增成功。", product.Id); } public async Task<ServiceResult> DeleteAsync(Guid? productId) { if (productId is null) { return ServiceResult.Fail("请先选择要删除的商品。"); } Product? product = await repository.FindByIdAsync(productId.Value); if (product is null) { return ServiceResult.Fail("要删除的商品不存在。"); } await repository.DeleteAsync(productId.Value); return ServiceResult.Ok("商品删除成功。"); } }

正常运行结果

True - 商品新增成功。 False - 商品名称不能为空。 False - 价格必须是数字。 False - 库存不能小于 0。 当前商品数量:1 False - 请先选择要删除的商品。 True - 商品删除成功。 删除后商品数量:0

这个例子展示了服务层的价值:它不是简单转发 Repository,而是集中处理业务规则,并把结果返回给调用者。

第三部分:跟着敲代码

从这里开始动手。今天仍然先用控制台项目验证服务层。

第 1 步:创建控制台项目

dotnet new console -n ProductServiceDemo cd ProductServiceDemo

打开 Program.cs,清空原内容。

第 2 步:写请求对象

public sealed class CreateProductRequest { public string Name { get; set; } = ""; public string PriceText { get; set; } = ""; public string StockText { get; set; } = ""; }

解释:

  • Name 来自商品名输入。
  • PriceText 来自价格输入框。
  • StockText 来自库存输入框。

第 3 步:写返回结果对象

public sealed class ServiceResult { public bool Success { get; init; } public string Message { get; init; } = ""; public Guid? ProductId { get; init; } public static ServiceResult Ok(string message, Guid? productId = null) { return new ServiceResult { Success = true, Message = message, ProductId = productId }; } public static ServiceResult Fail(string message) { return new ServiceResult { Success = false, Message = message }; } }

解释:

  • Success 表示成功还是失败。
  • Message 是给界面显示的提示。
  • ProductId 用来在新增成功时带回 Id。

第 4 步:写 Product

public sealed class Product { public Guid Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } }

解释:

  • 这和 Day 2 的数据模型一致。

第 5 步:写 Repository 接口

public interface IProductRepository { Task<List<Product>> GetAllAsync(); Task AddAsync(Product product); Task<Product?> FindByIdAsync(Guid id); Task DeleteAsync(Guid id); }

解释:

  • 服务层只需要这些数据动作。
  • 具体怎么保存,接口不关心。

第 6 步:写内存 Repository

public sealed class InMemoryProductRepository : IProductRepository { private readonly List<Product> products = new(); public Task<List<Product>> GetAllAsync() { return Task.FromResult(products.ToList()); } public Task AddAsync(Product product) { products.Add(product); return Task.CompletedTask; } public Task<Product?> FindByIdAsync(Guid id) { Product? product = products.FirstOrDefault(item => item.Id == id); return Task.FromResult(product); } public Task DeleteAsync(Guid id) { products.RemoveAll(item => item.Id == id); return Task.CompletedTask; } }

解释:

  • 这里用内存列表,是为了专心验证服务层。
  • 后面可以换成 Day 2 的 JSON Repository。

第 7 步:写 ProductService 构造函数

public sealed class ProductService { private readonly IProductRepository repository; public ProductService(IProductRepository repository) { this.repository = repository; } }

解释:

  • 通过构造函数把 Repository 传进来。
  • 这叫依赖注入的基础形式。

第 8 步:写 GetAllAsync

public Task<List<Product>> GetAllAsync() { return repository.GetAllAsync(); }

解释:

  • 获取全部商品暂时不需要业务规则。
  • 所以直接调用 Repository。

第 9 步:写新增校验

public async Task<ServiceResult> CreateAsync(CreateProductRequest request) { string name = request.Name.Trim(); if (string.IsNullOrWhiteSpace(name)) { return ServiceResult.Fail("商品名称不能为空。"); } if (name.Length > 50) { return ServiceResult.Fail("商品名称不能超过 50 个字符。"); } if (!decimal.TryParse(request.PriceText, out decimal price)) { return ServiceResult.Fail("价格必须是数字。"); } if (price <= 0) { return ServiceResult.Fail("价格必须大于 0。"); } if (!int.TryParse(request.StockText, out int stock)) { return ServiceResult.Fail("库存必须是整数。"); } if (stock < 0) { return ServiceResult.Fail("库存不能小于 0。"); }

解释:

  • 先处理名称。
  • 再处理价格。
  • 最后处理库存。
  • 每个失败都返回明确原因。

第 10 步:新增成功时保存商品

继续接在 CreateAsync 里:

DateTime now = DateTime.Now; var product = new Product { Id = Guid.NewGuid(), Name = name, Price = price, Stock = stock, CreatedAt = now, UpdatedAt = now }; await repository.AddAsync(product); return ServiceResult.Ok("商品新增成功。", product.Id); }

解释:

  • 校验全部通过,才创建商品。
  • 新增成功后返回成功结果和商品 Id。

第 11 步:写删除规则

public async Task<ServiceResult> DeleteAsync(Guid? productId) { if (productId is null) { return ServiceResult.Fail("请先选择要删除的商品。"); } Product? product = await repository.FindByIdAsync(productId.Value); if (product is null) { return ServiceResult.Fail("要删除的商品不存在。"); } await repository.DeleteAsync(productId.Value); return ServiceResult.Ok("商品删除成功。"); }

解释:

  • 没选中商品不能删除。
  • 商品不存在也不能删除。
  • 删除成功后返回成功消息。

第 12 步:写验证代码

var repository = new InMemoryProductRepository(); var service = new ProductService(repository); ServiceResult ok = await service.CreateAsync(new CreateProductRequest { Name = "Keyboard", PriceText = "199", StockText = "10" }); Console.WriteLine($"{ok.Success} - {ok.Message}");

再依次验证空名称、错误价格、错误库存、删除未选择、正常删除。

常见错误和修法

错误为什么错修法
服务层只转发 Repository没有承担业务规则把名称、价格、库存校验放进服务层
校验失败还继续保存错误数据会进入文件返回失败结果或直接停止保存流程
ViewModel 直接操作文件界面和数据保存耦合ViewModel 调用 Service,Service 调用 Repository
错误结果只有 false不知道失败原因返回 SuccessMessageErrors
方法名太模糊调用时看不出意图CreateProductAsyncUpdateProductAsync 这类动词

小白重复敲写训练

服务层每次只增加一条业务规则。

训练 1:名称清洗后保存

public void Create(string name, decimal price) { string cleanName = name.Trim(); var product = new Product { Name = cleanName, Price = price }; _repository.Add(product); }

训练 2:拒绝空名称

if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("商品名称不能为空", nameof(name)); }

分别传入正常名称和空格。

训练 3:拒绝重复名称

bool exists = _repository.GetAll() .Any(product => product.Name.Equals(cleanName, StringComparison.OrdinalIgnoreCase)); if (exists) { throw new InvalidOperationException("商品名称已存在"); }

第三遍为这条规则写成功和失败两个测试。

每日小测

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

1. 判断题

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

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

2. 填空题

本页主题是:服务层。今天至少要掌握的 3 个点是:

1. 服务层负责什么 2. 为什么业务规则不写在界面里 3. 什么是请求对象

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

3. 流程题

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

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

4. 找错误题

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

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

5. 改需求题

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

可选改法:

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

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:服务层负责什么。

上位机专项练习

轮询服务定时读取设备,把结果交给缓存、报警和界面,不能让 ViewModel 直接处理底层通信。

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

专项例子 1:轮询一台设备

public async Task PollAsync(Device device, CancellationToken token) { Reading reading = await reader.ReadAsync(device, token); cache.Update(reading); alarmService.Check(reading); }

运行结果或界面效果:

读取、缓存、报警职责依次执行

改动任务: 增加读取失败日志。

专项例子 2:持续轮询循环

while (!token.IsCancellationRequested) { await PollAllDevicesAsync(token); await Task.Delay(options.PollInterval, token); }

运行结果或界面效果:

按配置周期重复采集

改动任务: 测量每轮耗时,避免间隔越来越不准。

专项例子 3:启动和停止服务

public Task StartAsync() { cts = new CancellationTokenSource(); pollingTask = RunAsync(cts.Token); return Task.CompletedTask; } public async Task StopAsync() { cts?.Cancel(); if (pollingTask is not null) await pollingTask; }

运行结果或界面效果:

服务可以安全启动和停止

改动任务: 防止重复点击启动。

第四部分:作业完整答案

作业要求:写一个 ProductService,用请求对象接收输入,用返回结果对象表示成功失败,并完成新增和删除的业务规则。

答案文件:Program.cs

using System; using System.Collections.Generic; using System.Linq; var repository = new InMemoryProductRepository(); var service = new ProductService(repository); ServiceResult ok = await service.CreateAsync(new CreateProductRequest { Name = "Keyboard", PriceText = "199", StockText = "10" }); Console.WriteLine($"{ok.Success} - {ok.Message}"); ServiceResult emptyName = await service.CreateAsync(new CreateProductRequest { Name = "", PriceText = "199", StockText = "10" }); Console.WriteLine($"{emptyName.Success} - {emptyName.Message}"); ServiceResult badPrice = await service.CreateAsync(new CreateProductRequest { Name = "Mouse", PriceText = "abc", StockText = "10" }); Console.WriteLine($"{badPrice.Success} - {badPrice.Message}"); ServiceResult badStock = await service.CreateAsync(new CreateProductRequest { Name = "Monitor", PriceText = "899", StockText = "-1" }); Console.WriteLine($"{badStock.Success} - {badStock.Message}"); List<Product> products = await service.GetAllAsync(); Console.WriteLine($"当前商品数量:{products.Count}"); ServiceResult deleteWithoutId = await service.DeleteAsync(null); Console.WriteLine($"{deleteWithoutId.Success} - {deleteWithoutId.Message}"); if (ok.ProductId is Guid productId) { ServiceResult deleted = await service.DeleteAsync(productId); Console.WriteLine($"{deleted.Success} - {deleted.Message}"); } List<Product> productsAfterDelete = await service.GetAllAsync(); Console.WriteLine($"删除后商品数量:{productsAfterDelete.Count}"); public sealed class CreateProductRequest { public string Name { get; set; } = ""; public string PriceText { get; set; } = ""; public string StockText { get; set; } = ""; } public sealed class ServiceResult { public bool Success { get; init; } public string Message { get; init; } = ""; public Guid? ProductId { get; init; } public static ServiceResult Ok(string message, Guid? productId = null) { return new ServiceResult { Success = true, Message = message, ProductId = productId }; } public static ServiceResult Fail(string message) { return new ServiceResult { Success = false, Message = message }; } } public sealed class Product { public Guid Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } public interface IProductRepository { Task<List<Product>> GetAllAsync(); Task AddAsync(Product product); Task<Product?> FindByIdAsync(Guid id); Task DeleteAsync(Guid id); } public sealed class InMemoryProductRepository : IProductRepository { private readonly List<Product> products = new(); public Task<List<Product>> GetAllAsync() { return Task.FromResult(products.ToList()); } public Task AddAsync(Product product) { products.Add(product); return Task.CompletedTask; } public Task<Product?> FindByIdAsync(Guid id) { Product? product = products.FirstOrDefault(item => item.Id == id); return Task.FromResult(product); } public Task DeleteAsync(Guid id) { products.RemoveAll(item => item.Id == id); return Task.CompletedTask; } } public sealed class ProductService { private readonly IProductRepository repository; public ProductService(IProductRepository repository) { this.repository = repository; } public Task<List<Product>> GetAllAsync() { return repository.GetAllAsync(); } public async Task<ServiceResult> CreateAsync(CreateProductRequest request) { string name = request.Name.Trim(); if (string.IsNullOrWhiteSpace(name)) { return ServiceResult.Fail("商品名称不能为空。"); } if (name.Length > 50) { return ServiceResult.Fail("商品名称不能超过 50 个字符。"); } if (!decimal.TryParse(request.PriceText, out decimal price)) { return ServiceResult.Fail("价格必须是数字。"); } if (price <= 0) { return ServiceResult.Fail("价格必须大于 0。"); } if (!int.TryParse(request.StockText, out int stock)) { return ServiceResult.Fail("库存必须是整数。"); } if (stock < 0) { return ServiceResult.Fail("库存不能小于 0。"); } DateTime now = DateTime.Now; var product = new Product { Id = Guid.NewGuid(), Name = name, Price = price, Stock = stock, CreatedAt = now, UpdatedAt = now }; await repository.AddAsync(product); return ServiceResult.Ok("商品新增成功。", product.Id); } public async Task<ServiceResult> DeleteAsync(Guid? productId) { if (productId is null) { return ServiceResult.Fail("请先选择要删除的商品。"); } Product? product = await repository.FindByIdAsync(productId.Value); if (product is null) { return ServiceResult.Fail("要删除的商品不存在。"); } await repository.DeleteAsync(productId.Value); return ServiceResult.Ok("商品删除成功。"); } }

验收结果

运行后必须看到:

  1. 合法商品新增成功。
  2. 空名称返回失败。
  3. 错误价格返回失败。
  4. 小于 0 的库存返回失败。
  5. 新增成功后商品数量是 1。
  6. 未选择商品时删除失败。
  7. 删除已存在商品成功。
  8. 删除后商品数量是 0。

为什么这个答案是对的

这个答案把服务层职责拆清楚了:

职责代码
接收输入CreateProductRequest
返回结果ServiceResult
校验规则CreateAsync
调用数据层repository.AddAsync
删除规则DeleteAsync
隔离存储实现IProductRepository

服务层不是多余的一层。它让规则集中,后面主列表、详情编辑、搜索页面都能复用同一套业务判断。