小窍门
开发软件的新手? 首先开始 学习入门 教程。 随着项目的增长,你将自然地了解程序组织。
是否在其他语言中有经验? 如果你熟悉 Visual Studio 中的解决方案和项目,或者生成 Maven 或 Cargo 等系统,本文将这些概念映射到 .NET。
随着 C# 应用程序的发展,你需要组织代码。 .NET 提供组织工具(解决方案、项目、程序集、命名空间和类型)的层次结构,可协同工作,使大型代码库可管理。 此处所述的约定代表了 .NET 社区的广泛共识。 可能会因特定原因而偏离,但遵循这些约定可使代码更易于其他 .NET 开发人员理解和浏览。
组织层次结构
在层中组织典型的 .NET 应用程序,从最广泛到最具体:
| 级别 | 说明 | 示例 |
|---|---|---|
| 解决方案 | 对相关项目进行分组的容器。 | MyApp.slnx |
| 项目 | 用于产生一个程序集的构建单元。 | MyApp.Web.csproj |
| Assembly | 项目生成的编译 .dll 或 .exe。 |
MyApp.Web.dll |
| 命名空间 | 类型的逻辑分组。 | MyApp.Web.Controllers |
| 类型 | 类、结构、接口、枚举或委托。 | OrderController |
每个级别都有不同的用途。 解决方案组织开发工作流。 项目定义一起编译的内容,每个项目生成一个程序集。 程序集是部署和版本控制单元。 命名空间可防止命名冲突,并使类型易于查找。 单个程序集可以包含多个命名空间,单个命名空间可以跨越多个程序集。 类型定义实际行为和数据。
项目和程序集
每个项目编译为单个程序集:类库或可执行文件。 从单个项目开始,适用于小型应用程序 - 不要过早拆分。 创建单独的项目的主要原因是在多个应用程序中重复使用该代码。 除了重复使用之外,在有具体原因时添加项目:
- 跨应用程序共享代码 — 将共享逻辑提取到多个应用引用的类库中。
- 关注点分离 - 使数据访问、业务逻辑和呈现层保持独立。
- 控制依赖项 - 项目只能使用它显式引用的项目的类型。
单个项目适用于许多应用程序。 抵制“以防万一”创建单独的项目的冲动。以后,当第二个应用程序需要相同的代码时,始终可以提取库。
将命名空间与文件夹结构匹配
命名空间名称应遵循项目的文件夹结构。 当你看到命名空间 MyApp.Services.Payments时,你就知道要在 Services/Payments 文件夹中查找该命名空间下定义的类型的源代码。 .NET SDK 支持此约定,因此被广泛遵循,违反此约定会主动混淆其他开发人员:
// File: Services/OrderService.cs
// Namespace mirrors the folder path
using MyApp.Core;
namespace MyApp.Services;
public class OrderService
{
public Order CreateOrder(string product, int quantity, decimal price) =>
new() { ProductName = product, Quantity = quantity, UnitPrice = price };
public string FormatSummary(Order order) =>
$"{order.Quantity}x {order.ProductName} = {order.Total:C}";
}
根命名空间自动设置为项目文件的名称。 子文件夹中的类型不会自动获取子命名空间(在每个文件中显式声明命名空间),但始终将它们保持同步。
小窍门
可以通过在项目文件中设置 <RootNamespace> 来更改根命名空间。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>MyCompany.MyApp</RootNamespace>
</PropertyGroup>
</Project>
按功能(而不是按类型类型)组织命名空间
按功能或责任将相关类型分组到命名空间中。 将接口、其实现和支持类型放在一起:
// Good: group by feature
namespace MyApp.Payments;
public interface IPaymentProcessor
{
bool ProcessPayment(decimal amount);
}
public class CreditCardProcessor : IPaymentProcessor
{
public bool ProcessPayment(decimal amount)
{
Console.WriteLine($"Processing credit card payment of {amount:C}");
return true;
}
}
public record PaymentResult(bool Success, string? TransactionId);
基于功能的组织将所需的一切放在一个位置,使代码更易于导航和推理。
访问修饰符和程序集
访问修饰符与项目和程序集结构配合使用,以控制访问权限:
-
public- 可从引用此程序集的任何程序集访问。 -
internal— 只能在同一程序集中访问(顶级类型的默认值)。 -
private、protected、private protected、protected internal—可根据包含类型、程序集或派生类型进行访问。
对于其他项目不需要的类型,默认为 internal。 这种做法会隐藏实现详细信息,让你自由重构,而不会破坏使用者。 对于共享库尤其重要:
namespace MyApp.Inventory;
// Public — other projects can use this type
public class InventoryService
{
public int GetStockLevel(string productName) =>
StockDatabase.Lookup(productName);
}
// Internal — only visible within this assembly
internal static class StockDatabase
{
private static readonly Dictionary<string, int> _stock = new()
{
["Widget"] = 42,
["Gadget"] = 17
};
internal static int Lookup(string productName) =>
_stock.GetValueOrDefault(productName);
}
推荐做法
- 一致地命名命名空间。 请使用
CompanyName.ProductName.Feature作为命名模式。 例如,使用Contoso.Inventory.Shipping。 一致的命名有助于开发人员在不搜索的情况下查找类型。 - 保持项目的专注度。 每个项目都应有一个明确的责任。 当项目处理太多不相关的问题时,请将其拆分。
- 使用文件范围的命名空间。
namespace MyApp.Services;语法减少了缩进,是推荐的样式。 在所有新代码中使用它。 - 默认为
internal。 仅当其他程序集真正需要它们时标记类型public。 以后可以随时扩大访问权限;缩小访问权限是一项破坏性的变更。