Day 2 - 数据层准备
建议用时:180-210 分钟
你将学会什么
- 怎么把需求里的字段变成 C# 模型
- 数据层负责什么、不负责什么
- 为什么第一版先用 JSON 文件
- 怎么写
ProductRepository - 怎么实现新增、查询、修改、删除
- 怎么用控制台程序验证数据层
今天先不写界面。先把数据层打牢:商品长什么样、保存到哪里、怎么新增、怎么读取、怎么修改、怎么删除。数据层稳定了,后面服务层和页面才好接。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“项目范围与需求”,今天继续学习“数据层准备”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“数据层准备”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。数据层是完整项目的地基,地基不稳,后面页面和业务都会反复改。
数据层负责什么
数据层负责这些事:
- 商品数据结构是什么。
- 商品保存在哪里。
- 怎么读取全部商品。
- 怎么按 Id 查询商品。
- 怎么新增商品。
- 怎么更新商品。
- 怎么删除商品。
数据层不负责这些事:
- 输入框怎么显示。
- 按钮怎么点击。
- 名称为空时提示什么文案。
- 搜索框放在哪里。
- 页面怎么排版。
这些属于界面层或服务层。
从需求字段到 C# 模型
Day 1 的需求写了商品字段:
| 字段 | 类型 | 规则 |
|---|---|---|
| Id | Guid | 新增时自动生成 |
| Name | string | 不能为空,最多 50 个字符 |
| Price | decimal | 必须大于 0 |
| Stock | int | 必须大于或等于 0 |
| CreatedAt | DateTime | 新增时自动记录 |
| UpdatedAt | DateTime | 新增和编辑时更新 |
所以 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 是四个基础数据动作:
| 字母 | 单词 | 含义 | 本页方法 |
|---|---|---|---|
| C | Create | 新增 | AddAsync |
| R | Read | 读取 | GetAllAsync / FindByIdAsync |
| U | Update | 修改 | UpdateAsync |
| D | Delete | 删除 | DeleteAsync |
大多数业务项目都离不开 CRUD。
为什么用 Id 查询
商品名称可能重复,价格也可能重复。
例如两个商品都叫:
Keyboard所以不能靠名称判断唯一商品。
Id 的作用是唯一标识一条数据。
本页使用:
Guid.NewGuid()生成一个全局唯一标识。
为什么修改时要先找 index
更新商品时,不是直接 Add 一条新数据。
正确流程是:
- 读取全部商品。
- 找到 Id 相同的那一条。
- 替换那一条。
- 保存全部商品。
对应代码:
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) | 删除记录 |
| 保存 JSON | JsonSerializer.Serialize | 对象转文本 |
| 读取 JSON | JsonSerializer.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按 Id 查询:Keyboard修改后价格:209修改后库存:8删除后数量:0- 控制台输出 JSON 文件路径。
- 打开 JSON 文件能看到数组结构。
为什么这个答案是对的
这个答案已经覆盖数据层的基本职责:
| 职责 | 代码 |
|---|---|
| 定义数据 | Product |
| 保存文件路径 | filePath |
| 读取全部 | GetAllAsync |
| 保存全部 | SaveAllAsync |
| 新增 | AddAsync |
| 查询 | FindByIdAsync |
| 修改 | UpdateAsync |
| 删除 | DeleteAsync |
今天先把数据层做成可验证的最小闭环。明天再把“名称不能为空、价格必须大于 0、库存不能小于 0”这些规则放进服务层。