Skip to Content
Week 10Day 2 - 数据层准备

Day 2 - 数据层准备

建议用时:180-210 分钟

你将学会什么

  • 怎么把需求里的字段变成 C# 模型
  • 数据层负责什么、不负责什么
  • 为什么第一版先用 JSON 文件
  • 怎么写 ProductRepository
  • 怎么实现新增、查询、修改、删除
  • 怎么用控制台程序验证数据层

今天先不写界面。先把数据层打牢:商品长什么样、保存到哪里、怎么新增、怎么读取、怎么修改、怎么删除。数据层稳定了,后面服务层和页面才好接。

本页固定顺序

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

学习衔接

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

今天的最低通过线

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

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

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

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。数据层是完整项目的地基,地基不稳,后面页面和业务都会反复改。

数据层负责什么

数据层负责这些事:

  • 商品数据结构是什么。
  • 商品保存在哪里。
  • 怎么读取全部商品。
  • 怎么按 Id 查询商品。
  • 怎么新增商品。
  • 怎么更新商品。
  • 怎么删除商品。

数据层不负责这些事:

  • 输入框怎么显示。
  • 按钮怎么点击。
  • 名称为空时提示什么文案。
  • 搜索框放在哪里。
  • 页面怎么排版。

这些属于界面层或服务层。

从需求字段到 C# 模型

Day 1 的需求写了商品字段:

字段类型规则
IdGuid新增时自动生成
Namestring不能为空,最多 50 个字符
Pricedecimal必须大于 0
Stockint必须大于或等于 0
CreatedAtDateTime新增时自动记录
UpdatedAtDateTime新增和编辑时更新

所以 C# 模型写成:

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

这里用 get; set;,是因为 JSON 读取时需要给属性赋值。

为什么第一版先用 JSON

第一版做商品管理工具,数据量不大,目标是先跑通本地保存。

JSON 的好处:

  • 文件能直接打开查看。
  • 不需要安装数据库。
  • C# 自带 System.Text.Json
  • 适合保存小型本地列表。

JSON 的限制:

  • 数据很多时读写会变慢。
  • 并发写入不方便。
  • 复杂查询不如数据库。

所以第一版用 JSON 很合适。后面数据变多,再考虑 SQLite。

什么是 Repository

Repository 可以理解成“数据仓库入口”。

页面和服务层不应该到处写:

File.ReadAllTextAsync(...) JsonSerializer.Deserialize(...) File.WriteAllTextAsync(...)

这些细节统一放进:

ProductRepository

后面只调用:

await repository.GetAllAsync(); await repository.AddAsync(product); await repository.UpdateAsync(product); await repository.DeleteAsync(id);

这样以后把 JSON 换成 SQLite,也主要改 Repository。

什么是 CRUD

CRUD 是四个基础数据动作:

字母单词含义本页方法
CCreate新增AddAsync
RRead读取GetAllAsync / FindByIdAsync
UUpdate修改UpdateAsync
DDelete删除DeleteAsync

大多数业务项目都离不开 CRUD。

为什么用 Id 查询

商品名称可能重复,价格也可能重复。

例如两个商品都叫:

Keyboard

所以不能靠名称判断唯一商品。

Id 的作用是唯一标识一条数据。

本页使用:

Guid.NewGuid()

生成一个全局唯一标识。

为什么修改时要先找 index

更新商品时,不是直接 Add 一条新数据。

正确流程是:

  1. 读取全部商品。
  2. 找到 Id 相同的那一条。
  3. 替换那一条。
  4. 保存全部商品。

对应代码:

int index = products.FindIndex(product => product.Id == updatedProduct.Id); if (index < 0) { return; } products[index] = updatedProduct; await SaveAllAsync(products);

index < 0 表示没找到。

数据层为什么先不做业务校验

例如名称为空、价格小于 0,这些规则当然要处理。

但本周项目会分层:

  • 数据层负责存取。
  • 服务层负责业务规则。
  • 页面负责输入和显示。

所以今天数据层先保持简单。明天 Day 3 会专门写 ProductService 做校验。

数据层常用操作速查

需求常用写法说明
定义实体class Product保存业务数据
定义仓储接口IProductRepository规定数据操作
新增Add(product)保存一条
查询全部GetAll()列表显示
按 Id 查询FindById(id)编辑和删除
更新Update(product)保存修改
删除Delete(id)删除记录
保存 JSONJsonSerializer.Serialize对象转文本
读取 JSONJsonSerializer.Deserialize文本转对象
文件存在File.Exists(path)读取前判断

仓储接口常用形状:

interface IProductRepository { List<Product> GetAll(); Product? FindById(int id); void Add(Product product); bool Update(Product product); bool Delete(int id); }

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

今天会做一个最小数据层。

它包含:

  • Product:商品数据模型。
  • ProductRepository:商品文件仓库。
  • 一段控制台验证代码:新增、查询、修改、删除商品。

完整 Program.cs

新建控制台项目后,把下面代码放进 Program.cs

using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; string dataFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProductMvp"); string filePath = Path.Combine(dataFolder, "products.json"); var repository = new ProductRepository(filePath); DateTime now = DateTime.Now; var keyboard = new Product { Id = Guid.NewGuid(), Name = "Keyboard", Price = 199m, Stock = 10, CreatedAt = now, UpdatedAt = now }; await repository.SaveAllAsync(new List<Product>()); await repository.AddAsync(keyboard); List<Product> productsAfterAdd = await repository.GetAllAsync(); Console.WriteLine($"新增后数量:{productsAfterAdd.Count}"); Product? found = await repository.FindByIdAsync(keyboard.Id); Console.WriteLine($"按 Id 查询:{found?.Name}"); if (found is not null) { found.Price = 209m; found.Stock = 8; found.UpdatedAt = DateTime.Now; await repository.UpdateAsync(found); } Product? updated = await repository.FindByIdAsync(keyboard.Id); Console.WriteLine($"修改后价格:{updated?.Price}"); Console.WriteLine($"修改后库存:{updated?.Stock}"); await repository.DeleteAsync(keyboard.Id); List<Product> productsAfterDelete = await repository.GetAllAsync(); Console.WriteLine($"删除后数量:{productsAfterDelete.Count}"); Console.WriteLine($"JSON 文件:{repository.FilePath}"); 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 sealed class ProductRepository { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private readonly string filePath; public ProductRepository(string filePath) { this.filePath = filePath; string? folder = Path.GetDirectoryName(filePath); if (!string.IsNullOrWhiteSpace(folder)) { Directory.CreateDirectory(folder); } } public string FilePath => filePath; public async Task<List<Product>> GetAllAsync() { if (!File.Exists(filePath)) { return new List<Product>(); } string json = await File.ReadAllTextAsync(filePath); if (string.IsNullOrWhiteSpace(json)) { return new List<Product>(); } return JsonSerializer.Deserialize<List<Product>>(json) ?? new List<Product>(); } public async Task<Product?> FindByIdAsync(Guid id) { List<Product> products = await GetAllAsync(); return products.FirstOrDefault(product => product.Id == id); } public async Task AddAsync(Product product) { List<Product> products = await GetAllAsync(); products.Add(product); await SaveAllAsync(products); } public async Task UpdateAsync(Product updatedProduct) { List<Product> products = await GetAllAsync(); int index = products.FindIndex(product => product.Id == updatedProduct.Id); if (index < 0) { return; } products[index] = updatedProduct; await SaveAllAsync(products); } public async Task DeleteAsync(Guid id) { List<Product> products = await GetAllAsync(); products.RemoveAll(product => product.Id == id); await SaveAllAsync(products); } public async Task SaveAllAsync(List<Product> products) { string json = JsonSerializer.Serialize(products, JsonOptions); await File.WriteAllTextAsync(filePath, json); } }

正常运行结果

新增后数量:1 按 Id 查询:Keyboard 修改后价格:209 修改后库存:8 删除后数量:0 JSON 文件:...

这一页的重点是:数据层只负责保存和读取数据,不负责界面,也不负责复杂业务校验。

第三部分:跟着敲代码

从这里开始动手。先用控制台项目验证数据层。

第 1 步:创建控制台项目

dotnet new console -n ProductDataLayerDemo cd ProductDataLayerDemo

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

第 2 步:写 using

using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json;

解释:

  • IO 用来读写文件。
  • Linq 用来查询列表。
  • Text.Json 用来保存和读取 JSON。

第 3 步:写 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; } }

解释:

  • Id 用来唯一定位商品。
  • Name 是商品名。
  • Price 是价格。
  • Stock 是库存。
  • CreatedAt 是创建时间。
  • UpdatedAt 是最后更新时间。

第 4 步:写 Repository 构造函数

public sealed class ProductRepository { private readonly string filePath; public ProductRepository(string filePath) { this.filePath = filePath; string? folder = Path.GetDirectoryName(filePath); if (!string.IsNullOrWhiteSpace(folder)) { Directory.CreateDirectory(folder); } } public string FilePath => filePath; }

解释:

  • filePath 是 JSON 文件完整路径。
  • Directory.CreateDirectory 确保目录存在。
  • FilePath 暴露出来,方便后面提示文件保存位置。

第 5 步:写读取全部商品

public async Task<List<Product>> GetAllAsync() { if (!File.Exists(filePath)) { return new List<Product>(); } string json = await File.ReadAllTextAsync(filePath); if (string.IsNullOrWhiteSpace(json)) { return new List<Product>(); } return JsonSerializer.Deserialize<List<Product>>(json) ?? new List<Product>(); }

解释:

  • 文件不存在时返回空列表。
  • 文件为空时返回空列表。
  • JSON 读取结果为空时也返回空列表。

第 6 步:写保存全部商品

private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; public async Task SaveAllAsync(List<Product> products) { string json = JsonSerializer.Serialize(products, JsonOptions); await File.WriteAllTextAsync(filePath, json); }

解释:

  • WriteIndented = true 让 JSON 更容易阅读。
  • SaveAllAsync 每次把整个列表保存到文件。

第 7 步:写查询和新增

public async Task<Product?> FindByIdAsync(Guid id) { List<Product> products = await GetAllAsync(); return products.FirstOrDefault(product => product.Id == id); } public async Task AddAsync(Product product) { List<Product> products = await GetAllAsync(); products.Add(product); await SaveAllAsync(products); }

解释:

  • FindByIdAsync 按唯一 Id 查询。
  • AddAsync 先读全部,再加入新商品,再保存。

第 8 步:写修改

public async Task UpdateAsync(Product updatedProduct) { List<Product> products = await GetAllAsync(); int index = products.FindIndex(product => product.Id == updatedProduct.Id); if (index < 0) { return; } products[index] = updatedProduct; await SaveAllAsync(products); }

解释:

  • FindIndex 找到要修改的商品位置。
  • 找不到就直接结束。
  • 找到后替换并保存。

第 9 步:写删除

public async Task DeleteAsync(Guid id) { List<Product> products = await GetAllAsync(); products.RemoveAll(product => product.Id == id); await SaveAllAsync(products); }

解释:

  • RemoveAll 删除所有 Id 匹配的商品。
  • 正常情况下 Id 唯一,所以只会删一条。

第 10 步:写验证流程

string dataFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProductMvp"); string filePath = Path.Combine(dataFolder, "products.json"); var repository = new ProductRepository(filePath); DateTime now = DateTime.Now; var keyboard = new Product { Id = Guid.NewGuid(), Name = "Keyboard", Price = 199m, Stock = 10, CreatedAt = now, UpdatedAt = now }; await repository.SaveAllAsync(new List<Product>()); await repository.AddAsync(keyboard);

解释:

  • 先清空文件,保证每次运行结果一致。
  • 再新增一个商品。

第 11 步:继续验证查询、修改、删除

List<Product> productsAfterAdd = await repository.GetAllAsync(); Console.WriteLine($"新增后数量:{productsAfterAdd.Count}"); Product? found = await repository.FindByIdAsync(keyboard.Id); Console.WriteLine($"按 Id 查询:{found?.Name}"); if (found is not null) { found.Price = 209m; found.Stock = 8; found.UpdatedAt = DateTime.Now; await repository.UpdateAsync(found); } Product? updated = await repository.FindByIdAsync(keyboard.Id); Console.WriteLine($"修改后价格:{updated?.Price}"); Console.WriteLine($"修改后库存:{updated?.Stock}"); await repository.DeleteAsync(keyboard.Id); List<Product> productsAfterDelete = await repository.GetAllAsync(); Console.WriteLine($"删除后数量:{productsAfterDelete.Count}"); Console.WriteLine($"JSON 文件:{repository.FilePath}");

解释:

  • 新增后数量应该是 1。
  • 修改后价格应该是 209。
  • 删除后数量应该是 0。

第 12 步:运行

dotnet run

检查输出:

新增后数量:1 按 Id 查询:Keyboard 修改后价格:209 修改后库存:8 删除后数量:0 JSON 文件:...

常见错误和修法

错误为什么错修法
数据层直接写界面提示数据层和界面层混在一起数据层只负责读写,提示交给服务层或 ViewModel
每次保存只追加文本JSON 结构会越来越乱读取列表、修改列表、整体序列化保存
文件不存在就报错第一次运行通常没有数据文件File.Exists 为 false 时返回空列表
JSON 为空时反序列化失败空文件不是合法列表空白内容返回 new List<Product>()
更新时不按 Id 查找可能改错商品FindIndex(product => product.Id == id)

小白重复敲写训练

数据层先完成内存版,再接 JSON 文件。

训练 1:内存仓储新增和读取

var repository = new MemoryProductRepository(); repository.Add(new Product { Name = "Keyboard" }); Console.WriteLine(repository.GetAll().Count);

训练 2:按编号查找

public Product? FindById(Guid id) { return _items.FirstOrDefault(product => product.Id == id); }

改动任务:查存在和不存在的两个编号。

训练 3:保存到 JSON

public async Task SaveAsync(IReadOnlyList<Product> products) { string json = JsonSerializer.Serialize(products, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync("products.json", json); }

第三遍补 LoadAsync,文件不存在时返回空列表。

每日小测

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

1. 判断题

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

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

2. 填空题

本页主题是:数据层准备。今天至少要掌握的 3 个点是:

1. 怎么把需求里的字段变成 C# 模型 2. 数据层负责什么、不负责什么 3. 为什么第一版先用 JSON 文件

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

3. 流程题

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

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

4. 找错误题

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

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

5. 改需求题

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

可选改法:

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

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:怎么把需求里的字段变成 C# 模型。

上位机专项练习

领域模型是项目底座。Device 表示设备,Tag 表示点位,Reading 表示采样,Alarm 表示异常事件。

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

专项例子 1:设备与点位模型

public record Device(string Id, string Name, string IpAddress); public record Tag(string Name, string Unit, double AlarmLimit); var device = new Device("D001", "PLC-01", "192.168.1.10"); var tag = new Tag("Temperature", "C", 80); Console.WriteLine($"{device.Name}/{tag.Name}");

运行结果或界面效果:

PLC-01/Temperature

改动任务: 增加压力点位。

专项例子 2:采集记录模型

public record Reading(string DeviceId, string TagName, double Value, DateTime Time); var reading = new Reading("D001", "Temperature", 26.8, DateTime.Now); Console.WriteLine($"{reading.TagName}: {reading.Value}");

运行结果或界面效果:

Temperature: 26.8

改动任务: 增加 Quality 字段。

专项例子 3:报警模型

public class Alarm { public required string Message { get; init; } public DateTime OccurredAt { get; init; } public bool IsAcknowledged { get; private set; } public void Acknowledge() => IsAcknowledged = true; }

运行结果或界面效果:

报警对象可以被确认

改动任务: 增加 AcknowledgedBy。

第四部分:作业完整答案

作业要求:写一个 Product 模型和 ProductRepository,支持 JSON 保存、读取、新增、按 Id 查询、修改、删除。

答案文件:Program.cs

using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; string dataFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProductMvp"); string filePath = Path.Combine(dataFolder, "products.json"); var repository = new ProductRepository(filePath); DateTime now = DateTime.Now; var keyboard = new Product { Id = Guid.NewGuid(), Name = "Keyboard", Price = 199m, Stock = 10, CreatedAt = now, UpdatedAt = now }; await repository.SaveAllAsync(new List<Product>()); await repository.AddAsync(keyboard); List<Product> productsAfterAdd = await repository.GetAllAsync(); Console.WriteLine($"新增后数量:{productsAfterAdd.Count}"); Product? found = await repository.FindByIdAsync(keyboard.Id); Console.WriteLine($"按 Id 查询:{found?.Name}"); if (found is not null) { found.Price = 209m; found.Stock = 8; found.UpdatedAt = DateTime.Now; await repository.UpdateAsync(found); } Product? updated = await repository.FindByIdAsync(keyboard.Id); Console.WriteLine($"修改后价格:{updated?.Price}"); Console.WriteLine($"修改后库存:{updated?.Stock}"); await repository.DeleteAsync(keyboard.Id); List<Product> productsAfterDelete = await repository.GetAllAsync(); Console.WriteLine($"删除后数量:{productsAfterDelete.Count}"); Console.WriteLine($"JSON 文件:{repository.FilePath}"); 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 sealed class ProductRepository { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private readonly string filePath; public ProductRepository(string filePath) { this.filePath = filePath; string? folder = Path.GetDirectoryName(filePath); if (!string.IsNullOrWhiteSpace(folder)) { Directory.CreateDirectory(folder); } } public string FilePath => filePath; public async Task<List<Product>> GetAllAsync() { if (!File.Exists(filePath)) { return new List<Product>(); } string json = await File.ReadAllTextAsync(filePath); if (string.IsNullOrWhiteSpace(json)) { return new List<Product>(); } return JsonSerializer.Deserialize<List<Product>>(json) ?? new List<Product>(); } public async Task<Product?> FindByIdAsync(Guid id) { List<Product> products = await GetAllAsync(); return products.FirstOrDefault(product => product.Id == id); } public async Task AddAsync(Product product) { List<Product> products = await GetAllAsync(); products.Add(product); await SaveAllAsync(products); } public async Task UpdateAsync(Product updatedProduct) { List<Product> products = await GetAllAsync(); int index = products.FindIndex(product => product.Id == updatedProduct.Id); if (index < 0) { return; } products[index] = updatedProduct; await SaveAllAsync(products); } public async Task DeleteAsync(Guid id) { List<Product> products = await GetAllAsync(); products.RemoveAll(product => product.Id == id); await SaveAllAsync(products); } public async Task SaveAllAsync(List<Product> products) { string json = JsonSerializer.Serialize(products, JsonOptions); await File.WriteAllTextAsync(filePath, json); } }

验收结果

运行后必须看到:

  1. 新增后数量:1
  2. 按 Id 查询:Keyboard
  3. 修改后价格:209
  4. 修改后库存:8
  5. 删除后数量:0
  6. 控制台输出 JSON 文件路径。
  7. 打开 JSON 文件能看到数组结构。

为什么这个答案是对的

这个答案已经覆盖数据层的基本职责:

职责代码
定义数据Product
保存文件路径filePath
读取全部GetAllAsync
保存全部SaveAllAsync
新增AddAsync
查询FindByIdAsync
修改UpdateAsync
删除DeleteAsync

今天先把数据层做成可验证的最小闭环。明天再把“名称不能为空、价格必须大于 0、库存不能小于 0”这些规则放进服务层。