Day 1 - 解决方案结构
建议用时:240-300 分钟
你将学会什么
- Solution、Project、Folder 分别是什么
- 为什么项目变大后不能把所有代码放进一个
Program.cs - Core、Infrastructure、Entry 层分别放什么
- 项目引用方向为什么不能乱
- 如何用
dotnet命令创建一个多项目解决方案 - 如何把业务规则从入口代码里拆出去
本页先用控制台项目当入口层。以后换成桌面界面或 Web 接口时,分层思想不变:入口负责接收操作,Core 负责业务规则,Infrastructure 负责文件、数据库、网络等外部细节。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“第四周复盘与综合练习”,今天继续学习“解决方案结构”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“解决方案结构”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。解决方案结构不是为了好看,而是为了项目变大以后仍然能找得到、改得动、测得住。
1. Solution 是什么
Solution 是解决方案。
它本质上是一个管理多个项目的清单文件,后缀是:
.sln一个解决方案可以包含:
- 控制台项目。
- 桌面项目。
- Web 项目。
- 类库项目。
- 测试项目。
2. Project 是什么
Project 是项目。
一个项目有自己的 .csproj 文件。
项目负责告诉 .NET:
- 这是控制台程序还是类库。
- 用什么 .NET 版本。
- 引用了哪些包。
- 引用了哪些其他项目。
- 编译哪些
.cs文件。
3. Folder 是什么
Folder 只是文件夹。
文件夹可以帮助整理文件,但它不是一个独立编译单元。
区别:
| 名称 | 作用 |
|---|---|
| Solution | 管理多个项目 |
| Project | 一个可编译单元 |
| Folder | 整理文件 |
4. 为什么要分层
如果所有代码都在入口里,会出现这些问题:
- 输入、业务规则、文件保存混在一起。
- 业务规则不好测试。
- 换成桌面或 Web 时要重写大量代码。
- 文件保存改成数据库保存时容易影响业务逻辑。
- 代码越写越长,定位问题越来越慢。
分层的目标是:
不同职责放到不同位置。5. Core 层放什么
Core 放最核心、最不应该依赖外部框架的东西。
常见内容:
| 内容 | 示例 |
|---|---|
| 实体 | Product、Order、Customer |
| 接口 | IProductRepository |
| 服务 | ProductService |
| 业务规则 | 名称不能为空、价格必须大于 0 |
| 枚举 | OrderStatus |
Core 不应该关心:
- 控制台怎么输入。
- 桌面按钮怎么点击。
- Web 路由怎么写。
- 文件怎么保存。
- 数据库连接字符串是什么。
6. Infrastructure 层放什么
Infrastructure 放外部细节。
常见内容:
| 内容 | 示例 |
|---|---|
| 文件保存 | FileProductRepository |
| 数据库保存 | SqlProductRepository |
| 网络请求 | HttpProductClient |
| 日志实现 | 文件日志、控制台日志 |
| 第三方 SDK | 支付、短信、邮件 |
Infrastructure 可以引用 Core,因为它要实现 Core 里定义的接口。
7. Entry 层放什么
Entry 是程序入口层。
本页用控制台项目表示入口层。以后它也可以换成桌面项目或 Web 项目。
入口层负责:
- 接收用户操作。
- 准备依赖对象。
- 调用 Core 服务。
- 把结果显示出来。
入口层不应该塞满业务规则。
8. Tests 层放什么
Tests 放测试。
测试优先测 Core,因为 Core 里是业务规则。
例如:
- 商品名为空时应该失败。
- 价格为 0 时应该失败。
- 正常商品能保存。
- 订单未提交不能支付。
测试项目通常引用 Core。
9. 项目引用方向
引用方向一定要清楚:
入口层可以引用 Core。
Infrastructure 可以引用 Core。
入口层可以引用 Infrastructure。
Core 不要引用入口层。
Core 不要引用 Infrastructure。原因是:Core 要保持干净,才能复用和测试。
如果 Core 依赖了界面或数据库实现,后面换界面、换数据库都会很难。
本页常用命令速查
| 需求 | 命令 | 作用 |
|---|---|---|
| 创建解决方案 | dotnet new sln -n MyApp | 创建 .sln |
| 创建类库项目 | dotnet new classlib -n MyApp.Core | 放核心业务代码 |
| 创建控制台项目 | dotnet new console -n MyApp.Cli | 放启动入口 |
| 创建测试项目 | dotnet new xunit -n MyApp.Tests | 放自动测试 |
| 项目加入解决方案 | dotnet sln add 项目.csproj | 让解决方案管理项目 |
| 添加项目引用 | dotnet add A.csproj reference B.csproj | A 可以使用 B 的代码 |
| 还原依赖 | dotnet restore | 下载 NuGet 包 |
| 构建全部项目 | dotnet build | 检查编译 |
| 运行指定项目 | dotnet run --project 项目.csproj | 启动入口项目 |
| 测试全部项目 | dotnet test | 执行测试 |
常见结构:
MyApp.sln
src/MyApp.Core
src/MyApp.Cli
tests/MyApp.Tests判断项目引用方向:
Cli 引用 Core
Tests 引用 Core
Core 不引用 Cli
Core 不引用 Tests第二部分:把知识组合成完整例子
这一部分先看结果。今天最终要能看懂这种结构,并知道每一层负责什么。
先看效果:ProductApp 解决方案
ProductApp/
ProductApp.sln
src/
ProductApp.Console/ 程序入口,接收输入并调用服务
ProductApp.Core/ 业务模型、接口、业务规则
ProductApp.Infrastructure/ 文件、数据库、网络等实现
tests/
ProductApp.Tests/ 测试业务规则和服务逻辑引用方向应该是:
ProductApp.Console -> ProductApp.Core
ProductApp.Console -> ProductApp.Infrastructure
ProductApp.Infrastructure -> ProductApp.Core
ProductApp.Core -> 不引用入口层,也不引用基础设施层最重要的是:
Core 不应该依赖 Console、桌面界面、Web 接口、数据库实现。Core 是业务中心。入口和外部实现都围着它转。
第三部分:跟着敲代码
从这里开始动手。先创建结构,再用单文件完整例子理解各层职责。
动手前先做这 3 件事
- 找一个空文件夹练习,不要直接在重要项目里乱建。
- 每执行一组命令就用
dotnet build检查一次。 - 先理解引用方向,再写代码。
例子 1:创建解决方案和项目
dotnet new sln -n ProductApp --format sln
mkdir -p src tests
dotnet new console -n ProductApp.Console -o src/ProductApp.Console
dotnet new classlib -n ProductApp.Core -o src/ProductApp.Core
dotnet new classlib -n ProductApp.Infrastructure -o src/ProductApp.Infrastructure这会创建一个解决方案和三个项目。
这里明确写 --format sln,是为了生成传统的 ProductApp.sln 文件。新版本 .NET SDK 默认可能生成 .slnx,文件名不同会导致后面的 dotnet sln ProductApp.sln ... 找不到文件。
例子 2:把项目加入解决方案
dotnet sln ProductApp.sln add src/ProductApp.Console/ProductApp.Console.csproj
dotnet sln ProductApp.sln add src/ProductApp.Core/ProductApp.Core.csproj
dotnet sln ProductApp.sln add src/ProductApp.Infrastructure/ProductApp.Infrastructure.csproj加入解决方案后,ProductApp.sln 才知道这些项目属于同一个解决方案。
例子 3:添加项目引用
dotnet add src/ProductApp.Infrastructure/ProductApp.Infrastructure.csproj reference src/ProductApp.Core/ProductApp.Core.csproj
dotnet add src/ProductApp.Console/ProductApp.Console.csproj reference src/ProductApp.Core/ProductApp.Core.csproj
dotnet add src/ProductApp.Console/ProductApp.Console.csproj reference src/ProductApp.Infrastructure/ProductApp.Infrastructure.csproj引用方向是:
Infrastructure -> Core
Console -> Core
Console -> Infrastructure例子 4:构建整个解决方案
dotnet build ProductApp.sln只要引用方向没错,项目应该能正常构建。
例子 5:用一个完整程序模拟三层结构
下面这个例子可以直接放进一个控制台项目的 Program.cs 运行。它把 Core、Infrastructure、Entry 都写在一个文件里,是为了先理解职责分离。
IProductRepository repository = new FileProductRepository("products.txt");
ProductService service = new ProductService(repository);
try
{
Product product = service.Create(" 键盘 ", 199m);
Console.WriteLine($"创建成功: {product.Name} | {product.Price}");
}
catch (ArgumentException ex)
{
Console.WriteLine($"创建失败: {ex.Message}");
}
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; }
}
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 FileProductRepository : IProductRepository
{
private readonly string _path;
public FileProductRepository(string path)
{
_path = path;
}
public void Save(Product product)
{
string line = $"{product.Name},{product.Price}";
File.AppendAllText(_path, line + Environment.NewLine);
}
}这个单文件例子里:
| 代码 | 实际应该放哪层 |
|---|---|
Product | Core |
IProductRepository | Core |
ProductService | Core |
FileProductRepository | Infrastructure |
| 最上面的创建和调用代码 | Console / Desktop / Web 入口层 |
例子 6:Core 层代码应该长什么样
ProductService service = new ProductService(new MemoryProductRepository());
Product product = service.Create("鼠标", 88m);
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; }
}
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);
}
}这里用 MemoryProductRepository 是为了演示:Core 服务依赖接口,不依赖具体文件保存。
例子 7:错误结构是什么样
下面是错误方向,不要这样设计:
ProductApp.Core -> ProductApp.Console
ProductApp.Core -> ProductApp.Infrastructure
ProductService 直接 new FileProductRepository()
ProductService 直接 Console.WriteLine()问题是:业务层被入口和文件实现绑死,后面不好换,也不好测试。
例子 8:正确调用关系
入口层创建具体实现:
FileProductRepository repository = new FileProductRepository("products.txt")
入口层把实现交给 Core 服务:
ProductService service = new ProductService(repository)
Core 服务只知道接口:
IProductRepository这就是依赖从外面传入的基础。下一页会专门讲依赖注入。
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
| 所有代码放一个项目 | 层次不清,测试困难 | 拆成 App、Core、Tests 等项目 |
| 项目引用方向反了 | Core 依赖 UI 会导致复用困难 | UI 引用 Core,测试引用 Core |
| 解决方案里没加项目 | IDE 或命令不好统一管理 | 用 dotnet sln add 加入 .sln |
| 命名空间混乱 | 类型难找,引用容易错 | 按项目和文件夹统一命名 |
| 不跑整体构建 | 单项目能过不代表整体能过 | 在解决方案根目录跑 dotnet build |
小白重复敲写训练
工程结构先用命令创建,再亲手看目录变化。
训练 1:创建解决方案和两个项目
dotnet new sln -n Shop
dotnet new console -n Shop.App
dotnet new classlib -n Shop.Core
dotnet sln Shop.sln add Shop.App/Shop.App.csproj
dotnet sln Shop.sln add Shop.Core/Shop.Core.csproj每执行一行就查看目录,不要一次全贴进去。
训练 2:添加项目引用
dotnet add Shop.App/Shop.App.csproj reference Shop.Core/Shop.Core.csproj
dotnet list Shop.App/Shop.App.csproj reference改动任务:再创建 Shop.Infrastructure 并加入解决方案。
训练 3:跨项目调用
// Shop.Core/PriceCalculator.cs
namespace Shop.Core;
public static class PriceCalculator
{
public static decimal Total(decimal price, int count) => price * count;
}// Shop.App/Program.cs
using Shop.Core;
Console.WriteLine(PriceCalculator.Total(20m, 3));运行 dotnet run --project Shop.App 验证引用生效。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:解决方案结构。今天至少要掌握的 3 个点是:
1. Solution、Project、Folder 分别是什么
2. 为什么项目变大后不能把所有代码放进一个 `Program.cs`
3. Core、Infrastructure、Entry 层分别放什么答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:Solution、Project、Folder 分别是什么。
上位机专项练习
真实上位机不能把界面、通信、数据和配置全写在一个文件。先练习分层和项目引用。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:Core 层定义设备模型
namespace Hmi.Core;
public class Device
{
public string Name { get; set; } = "";
public bool IsOnline { get; set; }
}运行结果或界面效果:
Core 层只描述设备,不关心界面改动任务: 增加 IpAddress 属性。
专项例子 2:Infrastructure 层实现读取
using Hmi.Core;
namespace Hmi.Infrastructure;
public class FakeDeviceReader
{
public Device Read() => new() { Name = "PLC-01", IsOnline = true };
}运行结果或界面效果:
Infrastructure 层负责外部设备或文件改动任务: 把模拟设备名改成 PLC-02。
专项例子 3:App 层组合并显示
var reader = new Hmi.Infrastructure.FakeDeviceReader();
Hmi.Core.Device device = reader.Read();
Console.WriteLine($"{device.Name}: {(device.IsOnline ? "在线" : "离线")}");运行结果或界面效果:
PLC-01: 在线改动任务: 把 IsOnline 模拟为 false。
第四部分:作业完整答案
这一部分给出当天作业的完整答案。建议先照着目录和命令做一遍,再用单文件代码理解职责。
作业 1:画出项目结构
要求:
- 有入口项目。
- 有 Core 项目。
- 有 Infrastructure 项目。
- 有 Tests 项目。
- 写清每层职责。
完整答案
ProductApp/
ProductApp.sln
src/
ProductApp.Console/ 入口层:接收输入、调用服务、显示结果
ProductApp.Core/ 核心层:实体、接口、业务规则、服务
ProductApp.Infrastructure/ 基础设施层:文件、数据库、网络实现
tests/
ProductApp.Tests/ 测试层:验证 Core 的业务规则作业 2:写出引用方向
完整答案
ProductApp.Console -> ProductApp.Core
ProductApp.Console -> ProductApp.Infrastructure
ProductApp.Infrastructure -> ProductApp.Core
ProductApp.Tests -> ProductApp.Core
ProductApp.Core 不引用 ProductApp.Console
ProductApp.Core 不引用 ProductApp.Infrastructure作业 3:创建解决方案命令
完整答案
dotnet new sln -n ProductApp --format sln
mkdir -p src tests
dotnet new console -n ProductApp.Console -o src/ProductApp.Console
dotnet new classlib -n ProductApp.Core -o src/ProductApp.Core
dotnet new classlib -n ProductApp.Infrastructure -o src/ProductApp.Infrastructure
dotnet sln ProductApp.sln add src/ProductApp.Console/ProductApp.Console.csproj
dotnet sln ProductApp.sln add src/ProductApp.Core/ProductApp.Core.csproj
dotnet sln ProductApp.sln add src/ProductApp.Infrastructure/ProductApp.Infrastructure.csproj
dotnet add src/ProductApp.Infrastructure/ProductApp.Infrastructure.csproj reference src/ProductApp.Core/ProductApp.Core.csproj
dotnet add src/ProductApp.Console/ProductApp.Console.csproj reference src/ProductApp.Core/ProductApp.Core.csproj
dotnet add src/ProductApp.Console/ProductApp.Console.csproj reference src/ProductApp.Infrastructure/ProductApp.Infrastructure.csproj
dotnet build ProductApp.sln作业 4:分层代码完整答案
IProductRepository repository = new FileProductRepository("products.txt");
ProductService service = new ProductService(repository);
Product product = service.Create("显示器", 1299m);
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; }
}
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 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);
}
}作业验收
完成后检查:
- 能说清 Solution、Project、Folder 的区别。
- 能说清 Core、Infrastructure、Entry 分别放什么。
- 能说清为什么 Core 不依赖入口层。
- 能用命令创建基础解决方案结构。
- 能把商品创建规则放进 Core,而不是入口代码。
本页最后要记住
- Solution 管多个 Project。
- Project 是可编译单元,Folder 只是文件夹。
- Core 放业务规则和接口。
- Infrastructure 放文件、数据库、网络等实现。
- Entry 层负责接收操作并调用服务。
- Core 要保持干净,不依赖入口层和基础设施实现。
- 引用方向错了,项目后面会越来越难改。