文章
努力加载图片中...
ASP.NET 学习记录
  • 11033 字

  • 14 分钟

  • 18 次

  • 2026-04-13
标签:

1 前言

前几天我在 b 站,看到了一个关于 Web 后端框架对比的视频,发现了一个集开发效率、性能、低资源占用的一个后端技术,也就是 ASP.NET,是由 C# 编写的。

然后我去官网稍微看了下教程,没想到 C# 的很多语法和 Java 完全一样,不一样的地方也很容易理解。

为了减轻我的博客的资源占用负担,我打算将博客从 SpringBoot 迁移到 ASP.NET 中。所以来记录一下我初次学习和使用 ASP.NET 的过程。

2 安装

C # 环境安装很简单,在官网下载安装包: 下载 .NET (Linux、macOS 和 Windows) | .NET 然后双击安装即可

然后打开终端,输入命令

shell
dotnet --version

如果出现版本信息,那就说明安装好了。

接下来就是在 VS Code 中配置开发环境。

首先在VS Code 扩展中,搜索 C#,找到 C# Dev Kit,点击安装,安装完成后,开发环境就配置好了,非常方便。只能说 C# 和 VS Code 不愧都是微软的产品,相互集成真方便。 image.png

3 项目框架搭建

3.1 项目结构介绍

我在 skill.sh 中找到了一个 dotNET Web 后端框架的项目模板 Skill: dotnet-backend-patterns by wshobson/agents

我在 codex 让 gpt 按照这个 skill 搭建出项目,然后编写一个简单的关于用户表的 CURD 代码,并且让它成功跑起来。

搭建完成后,我就按照这个项目来学习。PS:有 AI 学习起来是真方便啊。

项目结构大概是这样的:

txt
src/ ├── Domain/ # 领域层:核心业务逻辑,零外部依赖 │ ├── Entities/ # 领域实体(如User、Order),包含业务规则和行为 │ ├── Interfaces/ # 领域层接口定义(如IRepository),由外层实现 │ ├── Exceptions/ # 领域特定异常类 │ └── ValueObjects/ # 值对象(如Money、Address),不可变,属性相等判断 ├── Application/ # 应用层:编排业务用例,协调领域对象 │ ├── Services/ # 应用服务,实现具体业务用例流程 │ ├── DTOs/ # 数据传输对象,定义API输入/输出结构 │ ├── Validators/ # 输入验证逻辑(如FluentValidation规则) │ └── Interfaces/ # 应用层接口定义(如IEmailService) ├── Infrastructure/ # 基础设施层:技术实现细节 │ ├── Data/ # 数据访问实现(EF Core、Repository具体实现) │ ├── Caching/ # 缓存实现(Redis、内存缓存等) │ ├── External/ # 外部服务集成(支付、邮件、第三方API) │ └── DependencyInjection/ # DI容器配置、服务注册扩展方法 ├── Api/ # 表现层:HTTP接口适配,最外层技术细节 │ ├── Controllers/ # API控制器,接收HTTP请求,调用应用层 │ ├── Middleware/ # HTTP管道中间件(认证、日志、异常处理) │ ├── Filters/ # MVC过滤器(模型验证、异常转换) │ └── Program.cs # 应用入口,配置中间件和服务 └── Project.slnx # 解决方案文件
  • 用 SpringBoot 的思路理解,Domain 类比 Model 层、Application 类比 Service 层、Infrastructure 类比 Repository / Mapper 层、Api 类比 Controller 层。
  • ValueObjects 是一个新概念,我具体不太理解是什么意思。类似于 MongoDB 的那种嵌入文档。它是复杂类型,但不是真正的实体类,可以与其他类组合,可以有自己的逻辑方法。

上述的注解是 AI 给的,AI 还说,这是一个叫 洋葱架构(Onion Architecture) / 整洁架构(Clean Architecture) 的项目结构,介绍文章:Clean Architecture in ASP.NET Core - NDepend Blog Clean Architecture 的核心思想可以用一张同心圆图来理解:

txt
┌─────────────────┐ │ Api/ │ ← 最外层:框架、UI、外部接口 │ Controllers │ │ Middleware │ └────────┬────────┘ │ 依赖向内 ┌────────▼────────┐ │ Application/ │ ← 应用层:用例、流程编排 │ Services │ │ DTOs │ └────────┬────────┘ │ 依赖向内 ┌────────▼────────┐ │ Domain/ │ ← 核心层:业务规则、领域模型 │ Entities │ 不依赖任何外部框架! │ ValueObjects │ └─────────────────┘

关键原则:依赖方向只能向内,Domain 层不依赖任何外部框架、数据库或 UI 技术。它通过严格的向内依赖规则,将技术细节(数据库、框架、UI)与业务核心解耦。这种架构特别适合长期维护的企业级应用

这种项目搭建思维有点像 SpringBoot 的分层思维,理解起来比较容易。

3.2 项目搭建

搭建的话,只需要按照如下的命令流程,执行相关的命令就行

shell
# 进入工作目录 cd "./Project" # 1 创建解决方案 dotnet new sln -n AspWebApiDemo # 2 创建 src 目录 mkdir src # 3 创建 4 个项目 dotnet new webapi -n Api -o src/Api --use-controllers --no-openapi # webapi 项目 dotnet new classlib -n Domain -o src/Domain # 类库 dotnet new classlib -n Application -o src/Application # 类库 dotnet new classlib -n Infrastructure -o src/Infrastructure # 类库 # 4 将项目加入解决方案 dotnet sln AspWebApiDemo.slnx add src/Api/Api.csproj src/Domain/Domain.csproj src/Application/Application.csproj src/Infrastructure/Infrastructure.csproj # 5 配置项目之间的引用关系 # Application -> Domain dotnet add src/Application/Application.csproj reference src/Domain/Domain.csproj # Infrastructure -> Domain dotnet add src/Infrastructure/Infrastructure.csproj reference src/Domain/Domain.csproj # Api -> Application + Infrastructure dotnet add src/Api/Api.csproj reference src/Application/Application.csproj src/Infrastructure/Infrastructure.csproj # 6 还原、构建 dotnet restore src/Api/Api.csproj dotnet build src/Api/Api.csproj # 7 运行 dotnet run --project src/Api/Api.csproj

解决方案,是指 Solution ,是 VS 用来组织和管理一个或者多个项目的容器,基于 slnx 文件 :

xml
<Solution> <Folder Name="/src/"> <Project Path="src/Api/Api.csproj" /> <Project Path="src/Application/Application.csproj" /> <Project Path="src/Domain/Domain.csproj" /> <Project Path="src/Infrastructure/Infrastructure.csproj" /> </Folder> </Solution>

csproj,是指 Project,是具体的项目的配置文件。

xml
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>

总结来说,解决方案类比 Maven,slnx 文件类比 Maven 的父级 pom.xml 文件,负责组织子模块(dotNET 是项目),csproj 类比具体模块的 pom.xml 文件,定义具体模块(dotNET 是项目)的配置。

restore 还原,指的是从远程仓库下载项目定义的 Nuget 包。例如:

xml
<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> <ProjectReference Include="..\Domain\Domain.csproj" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> </ItemGroup> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>

这里定义了 Microsoft.EntityFrameworkCoreNpgsql.EntityFrameworkCore.PostgreSQL 两个包,那执行 dotnet restore 时,就会从远程仓库(Nuget 仓库)下载这些包到指定位置。很像 Maven 的库下载。

Build 构建,就不用说了,就是将项目编译并打包成可执行文件。 执行 Build 后,项目文件夹(指的是具体的子项目),会有 bin 、 obj 这两个文件夹,这两个文件夹类比的是 Maven 项目的 target 文件夹。

bin 文件夹存放的是编译后的最终产物,可以直接用于运行和部署;obj 文件夹存放的是编译时的中间产物、临时文件,用于支持代码提示等等。

从上面可以看出,虽然很想 Maven 但是总感觉缺了什么。如果你实际运行了 dotnet restoredotnet build ,你就会明白,对于项目管理来说,现在少了两个关键的问题:依赖库下载在哪?依赖版本怎么统一?

很巧的是,slnx 文件不像 Maven 的 pom.xml 文件,slnx 更加注重于项目的组织,所以不能在这统一定义依赖的版本。

我们需要在项目的根目录,或者说是 slnx 同级目录下,创建两个文件: 第一个,nuget.config 文件,用于定义依赖库的下载位置。

xml
<?xml version="1.0" encoding="utf-8"?> <configuration> <config> <add key="globalPackagesFolder" value=".nuget/packages" /> </config> </configuration>

我定义在了本项目的 .nuget/packages 文件夹中。其实应该定义在统一的位置,不然每个项目都要下一遍(我被 Python 荼毒了,悲)。

第二个,Directory.Packages.props 文件夹,用于统一依赖版本:

xml
<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <ItemGroup> <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.5" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.5" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5" /> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> </ItemGroup> </Project>

Microsoft.EntityFrameworkCore 系列,是 .NET 的 ORM 框架库。 Npgsql.EntityFrameworkCore.PostgreSQL 是 .NET 中 EntityFrameworkCore (EF Core) 用于 PostgreSQL 数据库提供程序(包含 Npgsql 提供的驱动和 ORM 适配)。

当前项目结构: image.png

4 业务逻辑编写

4.1 前置处理

我们写一个简单的用户管理模块,用户表包含:ID、用户名、密码、角色、状态、创建时间、更新时间,业务逻辑就是简单的 CURD。

由于 Skill 要求项目必须使用异步来编写实际业务逻辑,我们入乡随俗,也统一用异步的方式编写。

首先,当然是安装库了,在 Api 模块的 Api.csproj 文件和 Infrastructure 模块的 Infrastructure.csproj 文件中,定义需要使用库。 Api.csproj

xml
<Project Sdk="Microsoft.NET.Sdk.Web"> <ItemGroup> <ProjectReference Include="..\Application\Application.csproj" /> <ProjectReference Include="..\Infrastructure\Infrastructure.csproj" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> </ItemGroup> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> </Project>

Infrastructure.csproj

xml
<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> <ProjectReference Include="..\Domain\Domain.csproj" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> </ItemGroup> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>

因为需要连接数据库,所以需要在 Api 模块中的 appsettings.Development.json 中,定义一下连接字符串:

json
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "ConnectionStrings": { "Default": "Host=127.0.0.1;Port=5432;Database=postgres;Username=webapi_admin;Password=12345678..Postgres%" } }

接下,就是定义实体类和基础设施了。

4.2 基础设施和实体类

在 Domain 项目中,Enums 包,定义如下枚举类:

cs
using Domain.Entities; namespace Domain.Enums; public enum UserRole { User = 1, Admin = 2 }
cs
namespace Domain.Enums; public enum UserStatus { Enabled = 1, Disabled = 2, Pending = 3 }

创建 Entites 包,定义如下实体类:

cs
namespace Domain.Entities; public class BaseEntity { public Guid Id { get; set;} = Guid.NewGuid(); public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } }
cs
using Domain.Enums; namespace Domain.Entities; public sealed class User : BaseEntity { public string Username { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public UserRole Role { get; set; } public UserStatus Status { get; set; } public User() { } public User(string username, string password, UserRole role, UserStatus status) { Username = username; Password = password; Role = role; Status = status; } }
  • { get; set; } 是 C# 的自动实现属性,在编译时会自动生成相关的 get、set 依法。
  • get、set 前面还可以加权限:public、private 等。
  • 构造方法还要自己写,如果能简化就好了。

然后到 Infrastructure 项目,创建 Data 文件夹,定义数据库连接上下文。

cs
using Domain.Entities; using Microsoft.EntityFrameworkCore; namespace Infrastructure.Data; public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options){ } public DbSet<User> Users => Set<User>(); }
  • 定义这个类的目的是,用于读取前面定义的连接字符串,用于连接数据库,并且提供 SQL 翻译、事务、并发控制等功能,就是数据库连接配置类。
  • public DbSet<User> Users => Set<User>(); 定义这个成员变量的目的是提供数据访问的入口,用于操作这个数据表。

4.3 Repository 层和 Service 层

然后在 Infrastructure 项目中,创建 Interfaces 目录,创建 IUserRepository

cs
using Domain.Entities; namespace Domain.Interfaces.Repository; public interface IUserRepository { Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default); Task<IReadOnlyList<User>> GetAllAsync(CancellationToken ct = default); Task AddAsync(User user, CancellationToken ct = default); Task UpdateAsync(User user, CancellationToken ct = default); Task DeleteAsync(User user, CancellationToken ct = default); }
  • Task 类似 Future。在实际实现方法时,需要使用 async 、 await
  • CancellationToken 取消令牌,用于支持请求取消,如果用户中断请求,会立即释放资源,停止执行。
  • IReadOnlyList 只读集合,只读保护,确保数据安全。
  • 这三个均是 .NET 异步编程的实践。

继续创建 Repository 目录,创建 UserRepository

cs
using Domain.Entities; using Domain.Interfaces.Repository; using Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace Infrastructure.Repository; public class UserRepository : IUserRepository { private readonly AppDbContext _db; public UserRepository(AppDbContext db) { _db = db; } public async Task AddAsync(User user, CancellationToken ct = default) { await _db.Users.AddAsync(user, ct); await _db.SaveChangesAsync(ct); } public async Task UpdateAsync(User user, CancellationToken ct = default) { _db.Users.Update(user); await _db.SaveChangesAsync(ct); } public async Task DeleteAsync(User user, CancellationToken ct = default) { _db.Users.Remove(user); await _db.SaveChangesAsync(ct); } public async Task<IReadOnlyList<User>> GetAllAsync(CancellationToken ct = default) { return await _db.Users.AsNoTracking().OrderByDescending(x => x.CreatedAt).ToListAsync(ct); } public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) { return _db.Users.FirstOrDefaultAsync(x => x.Id == id, ct); } }

SaveChangesAsync 方法,是实际执行数据库操作的方法,只有 Insert/Update/Delete 操作需要,是事务控制的核心操作。在执行 SaveChangesAsync 前,前面可以写很多 Insert/Update/Delete 操作,这些操作会在最后的 SaveChangesAsync 一并执行,并且是在同一个事务中执行,确保原子性。

接下来,实现 Service 层,在 Application 项目中,创建 DTOs 目录,添加 UserDtos 相关类:

cs
using System.ComponentModel.DataAnnotations; using Domain.Enums; namespace Application.DTOs; public sealed record CreateUserRequest { [Required] [StringLength(32, MinimumLength = 3)] public string Username { get; init; } = string.Empty; [Required] [StringLength(256, MinimumLength = 3)] public string Password { get; init; } = string.Empty; public UserRole Role { get; init; } = UserRole.User; public UserStatus Status { get; init; } = UserStatus.Pending; } public sealed record UpdateUserRequest { [Required] public Guid Id { get; init; } [Required] [StringLength(32, MinimumLength = 3)] public string Username { get; init; } = string.Empty; [Required] [StringLength(256, MinimumLength = 3)] public string Password { get; init; } = string.Empty; public UserRole Role { get; init; } public UserStatus Status { get; init; } }

继续创建 Interfaces 目录,添加 IUserService 接口:

cs
using Application.DTOs; using Domain.Entities; namespace Application.Interfaces; public interface IUserService { Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default); Task<IReadOnlyList<User>> GetAllAsync(CancellationToken ct = default); Task AddAsync(CreateUserRequest request, CancellationToken ct = default); Task UpdateAsync(UpdateUserRequest request, CancellationToken ct = default); Task DeleteAsync(Guid id, CancellationToken ct = default); }

继续创建 Services 包,添加 UserService 类:

cs
using Application.DTOs; using Application.Interfaces; using Domain.Entities; using Domain.Interfaces.Repository; namespace Application.Services; public class UserService : IUserService { private readonly IUserRepository _userRepository; public UserService(IUserRepository userRepository) { _userRepository = userRepository; } public async Task AddAsync(CreateUserRequest request, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(request.Username)) throw new ArgumentException("Username is required"); if (string.IsNullOrWhiteSpace(request.Password)) throw new ArgumentException("Password is required"); var entity = new User { Id = Guid.NewGuid(), Username = request.Username, Password = request.Password, Role = request.Role, Status = request.Status, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await _userRepository.AddAsync(entity, ct); } public async Task UpdateAsync(UpdateUserRequest request, CancellationToken ct = default) { var entity = await _userRepository.GetByIdAsync(request.Id, ct); if (entity is null) throw new ArgumentException("User not found"); if (string.IsNullOrWhiteSpace(request.Username)) throw new ArgumentException("Username is required"); if (string.IsNullOrWhiteSpace(request.Password)) throw new ArgumentException("Password is required"); entity.Username = request.Username; entity.Password = request.Password; entity.Role = request.Role; entity.Status = request.Status; entity.UpdatedAt = DateTime.UtcNow; await _userRepository.UpdateAsync(entity, ct); } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var entity = await _userRepository.GetByIdAsync(id, ct); if (entity is null) throw new ArgumentException("User not found"); await _userRepository.DeleteAsync(entity, ct); } public async Task<IReadOnlyList<User>> GetAllAsync(CancellationToken ct = default) { var entities = await _userRepository.GetAllAsync(ct); return entities.Select(x => new User { Id = x.Id, Username = x.Username, Password = x.Password, Role = x.Role, Status = x.Status, CreatedAt = x.CreatedAt, UpdatedAt = x.UpdatedAt }).ToList(); } public async Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) { var entity = await _userRepository.GetByIdAsync(id, ct); return entity is null ? null : new User { Id = entity.Id, Username = entity.Username, Password = entity.Password, Role = entity.Role, Status = entity.Status, CreatedAt = entity.CreatedAt, UpdatedAt = entity.UpdatedAt }; } }
  • 可以看到,new 一个对象时,后面跟着大括号加上各个属性的赋值语句,这不是 C# 的创建的对象的方式, C# 创建对象的方式和 Java 一样,使用构造函数。这个花括号的语句叫做:对象初始化器语法。类似于 Java 的 Builder 写法,只不过这个对象初始化器语法是 C# 特性。
  • 还有一点,就是 .NET 原生只有构造器注入,没有字段注入和 Setter 注入。

4.3 Controller 层与服务注册

然后,就是编写控制器了,在 Api 项目的 Controllers 目录中,添加 UserController 类:

cs
using Application.DTOs; using Application.Interfaces; using Domain.Entities; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; [ApiController] [Route("api/[controller]")] public class UserController : ControllerBase { private readonly IUserService _userService; public UserController(IUserService userService) { _userService = userService; } [HttpGet("{id:guid}")] public async Task<ActionResult<User>> GetById(Guid id, CancellationToken ct) { var result = await _userService.GetByIdAsync(id, ct); return result is null ? NotFound() : Ok(result); } [HttpGet] public async Task<ActionResult<IReadOnlyList<User>>> GetAll(CancellationToken ct) { var result = await _userService.GetAllAsync(ct); return Ok(result); } [HttpPost] public async Task<ActionResult> Create([FromBody] CreateUserRequest request, CancellationToken ct) { await _userService.AddAsync(request, ct); return Ok(); } [HttpPut] public async Task<ActionResult> Update([FromBody] UpdateUserRequest request, CancellationToken ct) { await _userService.UpdateAsync(request, ct); return Ok(); } [HttpDelete("{id:guid}")] public async Task<ActionResult> Delete(Guid id, CancellationToken ct) { await _userService.DeleteAsync(id, ct); return Ok(); } }
  • ApiController、Route、HttpGet,类比 SpringBoot 的 RestController、RequestMapping 和 GetMapping 这些。
  • 这种 [xxxxx] 的语法是 C# 的特性(Attribute)语法,用于元数据标记,本质其实是调用括号内部的类的构造函数:[HttpGet(xxx)] 等价于 new HttpGetAttribute(xxx)
  • Ok()NotFound() 是快捷方法,用于生成标准 HTTP 响应。

这样,各个分层的代码就定义完了。但是,还没完,从各个分层的代码中,能看出漏了什么吗?熟悉 SpringBoot 的可能一开始发现不了,因为这个问题其实在 SpringBoot 是理所当然的。

其实问题就是,.NET 没有 Bean 这个概念。所以,之前的代码中只有 DI (依赖注入),而没有注册的过程。

问题也就是这个,没有注册,框架自然无法帮你管理生命周期,以及进行 IoC。

那怎么注册呢?由于当前的项目很简单, 所以我们只需要在主入口中进行注册即可。

在 Api 项目中,找到 Program.cs 文件,这是程序主入口,类似 SpringBoot 的 Application 类,修改这个类,在注释 // Add services to the container. 下方添加代码:

cs
using Application.Interfaces; using Application.Services; using Domain.Interfaces.Repository; using Infrastructure.Data; using Infrastructure.Repository; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(); builder.Services.AddDbContext<AppDbContext>(options => { options.UseNpgsql(builder.Configuration.GetConnectionString("Default")); }); builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IUserService, UserService>(); var app = builder.Build(); // Configure the HTTP request pipeline. app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();

这样就完成了类的注册,而 .NET 称这个为 Service 服务注册。

和 Spring Bean 的区别其实很明显,熟悉的 Spring 基础的应该明白,Spring 的 Bean 是运行时扫描,通过配置、注解来进行,是动态扫描注册的;而 .NET 是纯代码注册,或者说叫做显式代码注册,编译时直接确定需要注册什么类,不会通过反射扫描注册,而是运行时执行注册代码来注册。

5 运行

接下来,就可以开始运行了,开始之前,需要进行一些准备操作,在这些执行操作时,可以发现 dotnet 的 EF Core 这个框架一个非常厉害的地方。

首先,需要给 dotnet 安装 EF Core 工具:

shell
dotnet tool install --global dotnet-ef

然后运行下面的命令:

shell
# 创建迁移 dotnet ef migrations add InitUser --project src/Infrastructure/Infrastructure.csproj --startup-project src/Api/Api.csproj # 更新数据库 dotnet ef database update --project src/Infrastructure/Infrastructure.csproj --startup-project src/Api/Api.csproj

这是这个工具的厉害之处。创建迁移的代码,可以根据你当前项目,也就是在 Infrastructure 中,AppDbContext 配置数据访问入口,也就是前面的 public DbSet<User> Users => Set<User>(); 自动生成变更脚本,然后在更新数据库操作时,自动将这些变更同步到数据库。 没错,直接将代码中的实体类结构,直接和数据表进行同步!没有表就帮你创建,字段变了就帮你修改。

运行后,数据库会出现如下两个表 image.png

最后运行下面三个命令,就可以运行启动了

shell
dotnet restore src/Api/Api.csproj dotnet build src/Api/Api.csproj dotnet run --project src/Api/Api.csproj

如果要修改端口和地址,在 Api 项目的 Properties / launchSettings.json 文件也可以改:

json
{ "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, "applicationUrl": "http://localhost:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, "applicationUrl": "https://localhost:7258;http://localhost:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } }

依次执行下面的命令,来测试接口:

shell
curl -X POST "http://localhost:5001/api/User" -H "Content-Type: application/json" -d '{ "username":"alice","password":"Pass@123","role":1,"status":3}' curl -X GET "http://localhost:5001/api/User" # 下面几条命令,记得将 {id} 换成上面查出来的实际 id curl -X PUT "http://localhost:5001/api/User" -H "Content-Type: application/json" -d '{"id":"{id}","username":"alice_updated","password":"Pass@456","role":2,"status":1}' curl -X GET "http://localhost:5001/api/User/{id}" curl -X DELETE "http://localhost:5001/api/User/{id}"

image.png

日志: image.png

6 优化

上面的代码中,有两个问题。

第一,就是 Service 的注册位置,如果项目做大,Service 变多,全部都放在 Program.cs 中注册,那会大大增加 Program.cs 代码量,使其变得臃肿。

第二,就是控制台的日志太难读了。

接下来,我们就一个一个解决两个问题。

6.1 日志库集成

首先是日志。我选择使用 Serilog 这个日志库。

SpringBoot 用的比较多的 Log4j 日志库在 dotNET 中也有,叫做 Log4net。

首先在 Directory.Packages.props 文件中,添加相关的版本依赖。

xml
<ItemGroup> ...... <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> </ItemGroup>

然后依次执行下面的命令:

shell
dotnet add .\src\Api\Api.csproj package Serilog.AspNetCore dotnet add .\src\Api\Api.csproj package Serilog.Settings.Configuration dotnet add .\src\Api\Api.csproj package Serilog.Sinks.Console dotnet restore .\src\Api\Api.csproj

库就安装好了。

然后在 Api 项目的 appsettings.Development.json 文件中,删除原本的 Logging 配置,添加如下配置:

json
"Serilog": { "Using": [ "Serilog.Sinks.Console" ], "MinimumLevel": { "Default": "Debug", "Override": { "Microsoft": "Information", "Microsoft.AspNetCore": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, "WriteTo": [ { "Name": "Console", "Args": { "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u4}] {SourceContext,-64} : {Message:lj}{NewLine}{Exception}" } } ] },
  • Using:会用到的扩展库
  • MinimumLevel.Default:全局最低日志级别
  • MinimumLevel.Override.Microsoft:覆盖以 Microsoft 开头的日志的日志级别(下面几个同理)。只有这个级别以上的才能被记录。
  • WriteTo中的第一个对象:指的是为 Console ,也就是控制台输出的日志,添加参数。参数 theme 的值表示使用彩色输出;outputTemplate 的值表示日志输出格式。

然后在 Program.cs 中,注册 Serilog 日志服务,并在 Http 请求管道中启动。

cs
using Application.Interfaces; using Application.Services; using Domain.Interfaces.Repository; using Infrastructure.Data; using Infrastructure.Repository; using Microsoft.EntityFrameworkCore; using Serilog; var builder = WebApplication.CreateBuilder(args); // Add services to the container. // 注册日志库 builder.Host.UseSerilog((ctx, services, lc) => lc .ReadFrom.Configuration(ctx.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext()); builder.Services.AddControllers(); builder.Services.AddDbContext<AppDbContext>(options => { options.UseNpgsql(builder.Configuration.GetConnectionString("Default")); }); builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IUserService, UserService>(); var app = builder.Build(); // Configure the HTTP request pipeline. // 在请求管道中启用 app.UseSerilogRequestLogging(); app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();

构建并运行 image.png

6.2 分层注册服务

然后是解决 Service 注册问题。在 dotnet-backend-patterns 这个 skill 中的解决办法是,Application 层 和 Infrastructure 层分别创建专门的 Service 注册组件,在该组件注册本层的 Service,然后再由 Program.cs 来应用这些组件,完成分离。

为了实现这个功能,我们需要给 Application 层 和 Infrastructure 添加相关库。首先在 Directory.Packages.props 中,添加两个库的版本管理。

xml
<ItemGroup> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" /> <PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.5" /> ...... </ItemGroup>

然后在 Application 项目 和 Infrastructure 项目中的 csproj 文件,添加这两个库的依赖。 Infrastructure 项目:

xml
<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> <ProjectReference Include="..\Domain\Domain.csproj" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.Configuration" /> <PackageReference Include="Microsoft.EntityFrameworkCore" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> </ItemGroup> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>

Application 项目:

xml
<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> <ProjectReference Include="..\Domain\Domain.csproj" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.Configuration" /> </ItemGroup> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>

在 Infrastructure 项目和 Application 项目中,分别创建 DependencyInjection 目录,添加 ServiceCollectionExtensions.cs 类: Infrastructure 项目:

cs
using Domain.Interfaces.Repository; using Infrastructure.Data; using Infrastructure.Repository; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace Infrastructure.DependencyInjection; public static class ServiceCollectionExtensions { public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration ) { services.AddDbContext<AppDbContext>(options => { options.UseNpgsql(configuration.GetConnectionString("Default")); }); services.AddScoped<IUserRepository, UserRepository>(); return services; } }

Application 项目:

xml
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Application.Interfaces; using Application.Services; namespace Application.DependencyInjection; public static class ServiceCollectionExtensions { public static IServiceCollection AddApplication( this IServiceCollection services, IConfiguration configuration ) { services.AddScoped<IUserService, UserService>(); return services; } }

最后,在 Program.cs 中,删除原有的 Service 注册逻辑,转而调用上述定义的两个静态方法。

cs
using Application.DependencyInjection; using Infrastructure.DependencyInjection; using Serilog; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Host.UseSerilog((ctx, services, lc) => lc .ReadFrom.Configuration(ctx.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext()); // 调用定义 Service 注册的方法, builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddApplication(builder.Configuration); builder.Services.AddControllers(); var app = builder.Build(); // Configure the HTTP request pipeline. app.UseSerilogRequestLogging(); app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();

注意,Service 的注册方法必须按照各个项目的依赖顺序来,这里 Controllers 依赖 Application ,Application 依赖 Infrastructure,所以要按照 Infrastructure -> Application -> Controllers 的顺序注册 Service ,否则会报错说某些 Service 找不到。

然后继续,还原、构建、运行。运行不出错,调用接口没问题,就完成了。 image.png

7 集成 Redis

7.1 安装与集成

ASP.NET 中使用比较多的,集成 Redis 的库的是 Microsoft.Extensions.Caching.StackExchangeRedis ,所以我也打算使用这个库。

首先,在 Directory.Packages.props 中,添加如下库:

xml
<ItemGroup> <PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5" /> </ItemGroup>

在 Infrastructure 项目添加这个包:

shell
dotnet add src/Infrastructure/Infrastructure.csproj package Microsoft.Extensions.Caching.StackExchangeRedis --version 10.0.5 dotnet restore src/Api/Api.csproj dotnet build src/Api/Api.csproj

然后就是编写连接逻辑,和集成 PostgreSQL 一致,只不过不用编写 DbContext。 首先在 Api 模块的 appsettings.Development.json 中,在 ConnectionStrings 字段添加一个新的字段:

json
"ConnectionStrings": { "Postgres": "Host=127.0.0.1;Port=5432;Database=postgres;Username=webapi_admin;Password=12345678..Postgres%", "Redis": "127.0.0.1:6379,password=12345678..Redis%,defaultDatabase=0,abortConnect=false,connectTimeout=5000" }

PostgreSQL 的字段键我改成了 "Postgres",记得在 Infrastructure 层的 ServiceCollectionExtensions 中修改。

然后,在 Infrastructure 层的 ServiceCollectionExtensions ,添加 Service 注册逻辑:

cs
public static class ServiceCollectionExtensions { public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration ) { // PostgreSQL DbContext services.AddDbContext<AppDbContext>(options => { options.UseNpgsql(configuration.GetConnectionString("Postgres")); }); // Redis Cache services.AddStackExchangeRedisCache(options => { // 获取连接字符串 options.Configuration = configuration.GetConnectionString("Redis"); // 这里会给通过这个添加的缓存 key,加一个顶层的 webapi_learn_cache: 前缀 options.InstanceName = "webapi_learn_cache"; }); services.AddSingleton<ICacheService, RedisCacheService>(); // Repository services.AddScoped<IUserRepository, UserRepository>(); return services; } }

7.2 基础封装使用

来编写使用逻辑。

要说明的是,ASP.NET 官方没有推荐使用类似 Spring Cache 那样的,基于注解式的缓存构建和清除方式。官方的推荐的集成 Redis 的缓存库是 StackExchangeRedis

既然没有注解,那就说明:请求 -> 查询缓存 -> 命中则返回 -> 否则查询数据库 -> 构建缓存 -> 返回这段流程,得自己手动写。

这是非常麻烦的,如果你有很多业务逻辑,都有重复的流程,那么你每个业务逻辑代码都得写相似的逻辑。

我查了几个文章,找到了两种比较好用的,简化这个流程实现的方式:

第一个方法,核心就是将:请求 -> 查询缓存 -> 命中则返回构建缓存 -> 返回 这两段流程,封装起来,而实际的业务逻辑,也就是查询数据库这一原业务逻辑方法,通过参数转入来调用。

我们首先在 Domain 模块中,在 Interfaces 中,新建 Service 包,再新建一个接口 ICacheServcie

cs
namespace Domain.Interfaces.Service; public interface ICacheService { Task<T?> GetOrCreateAsync<T>( string cacheKey, Func<Task<T?>> factory, // 关键,让原业务逻辑方法通过参数传入 TimeSpan? expiration = null ) where T : class; }

然后回到 Infrastructure 模块,在 Caching 包中,新建 RedisCacheService 类,继承上面的接口:

cs
using System.Text.Json; using Domain.Interfaces.Service; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; namespace Infrastructure.Caching; public class RedisCacheService : ICacheService { private readonly IDistributedCache _cache; public ILogger<RedisCacheService> _logger = default!; public RedisCacheService(IDistributedCache cache, ILogger<RedisCacheService> logger) { _cache = cache; _logger = logger; } public async Task<T?> GetOrCreateAsync<T>(string cacheKey, Func<Task<T?>> factory, TimeSpan? expiration = null) where T : class { // 查询缓存 var cached = await _cache.GetStringAsync(cacheKey); if (!string.IsNullOrWhiteSpace(cached)) { // 缓存命中,直接返回 _logger.LogDebug("缓存命中, key={CacheKey}", cacheKey); return JsonSerializer.Deserialize<T>(cached); } // 否则,现在调用原来的方法 var result = await factory(); // 空值判断 if (result is null) return null; // 创建缓存设置 var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = expiration ?? TimeSpan.FromMinutes(10) }; // 构建缓存 await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result), options); _logger.LogDebug("缓存建立, key={CacheKey}", cacheKey); // 返回结果 return result; } }

然后在 DependencyInjection 包的 ServiceCollectionExtensions 中,添加这个 RedisCacheService 服务的注册:

cs
using Domain.Interfaces.Repository; using Domain.Interfaces.Service; using Infrastructure.Caching; using Infrastructure.Data; using Infrastructure.Repository; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace Infrastructure.DependencyInjection; public static class ServiceCollectionExtensions { public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration ) { // PostgreSQL DbContext services.AddDbContext<AppDbContext>(options => { options.UseNpgsql(configuration.GetConnectionString("Postgres")); }); // Redis Cache services.AddStackExchangeRedisCache(options => { options.Configuration = configuration.GetConnectionString("Redis"); options.InstanceName = "webapi_learn_cache"; }); // 注册 RedisCacheService services.AddSingleton<ICacheService, RedisCacheService>(); // Repository services.AddScoped<IUserRepository, UserRepository>(); return services; } }

然后回到 Application 模块,在 UserService 中应用:

cs
#x22;user:getById:{id}", // 原本的业务逻辑写在这 async () => { var entity = await _userRepository.GetByIdAsync(id, ct); return entity is null ? null : new User { Id = entity.Id, Username = entity.Username, Password = entity.Password, Role = entity.Role, Status = entity.Status, CreatedAt = entity.CreatedAt, UpdatedAt = entity.UpdatedAt }; }, TimeSpan.FromMinutes(10) ); } }" aria-label="Copy code">
using Application.DTOs; using Application.Interfaces; using Domain.Entities; using Domain.Interfaces.Repository; using Domain.Interfaces.Service; namespace Application.Services; public class UserService : IUserService { private readonly IUserRepository _userRepository; private readonly ICacheService _cacheService; public UserService(IUserRepository userRepository, ICacheService cacheService) { _userRepository = userRepository; _cacheService = cacheService; } public async Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) { // 嵌套一层 return await _cacheService.GetOrCreateAsync<User>( $"user:getById:{id}", // 原本的业务逻辑写在这 async () => { var entity = await _userRepository.GetByIdAsync(id, ct); return entity is null ? null : new User { Id = entity.Id, Username = entity.Username, Password = entity.Password, Role = entity.Role, Status = entity.Status, CreatedAt = entity.CreatedAt, UpdatedAt = entity.UpdatedAt }; }, TimeSpan.FromMinutes(10) ); } }

运行一下: image.png

7.2 装饰器模式使用

第二个文章介绍的方法,是基于装饰器的,这个方法的优点是:完全不入侵业务逻辑代码。

说起来可能不好理解,我一边写代码一边说。

首先,在 Application 模块,在 Service 包添加一个 Caching 包,然后添加一个 CacheUserService 类:

cs
using Application.DTOs; using Application.Interfaces; using Domain.Entities; using Domain.Interfaces.Service; namespace Application.Services.Caching; public class CacheUserService : IUserService { // 核心依赖 Cache 服务 private readonly ICacheService _cacheService; // 核心依赖,原业务逻辑服务 private readonly IUserService _userService; public CacheUserService(ICacheService cacheService, IUserService userService) { _cacheService = cacheService; _userService = userService; } public Task<IReadOnlyList<User>> GetAllAsync(CancellationToken ct = default) { return _userService.GetAllAsync(ct); } public Task AddAsync(CreateUserRequest request, CancellationToken ct = default) { return _userService.AddAsync(request, ct); } public Task DeleteAsync(Guid id, CancellationToken ct = default) { return _userService.DeleteAsync(id, ct); } public Task UpdateAsync(UpdateUserRequest request, CancellationToken ct = default) { return _userService.UpdateAsync(request, ct); } public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) { return _userService.GetByIdAsync(id, ct); } }

不需要缓存的接口,直接调用原业务逻辑方法。

由于第一个方法是使用 GetByIdAsync,这个方法写缓存逻辑,这里我们换一个,使用 GetAllAsync 方法来写第二中方法的逻辑。

cs
using Application.DTOs; using Application.Interfaces; using Domain.Entities; using Domain.Interfaces.Service; namespace Application.Services.Caching; public class CacheUserService : IUserService { private readonly ICacheService _cacheService; private readonly IUserService _userService; public CacheUserService(ICacheService cacheService, IUserService userService) { _cacheService = cacheService; _userService = userService; } public async Task<IReadOnlyList<User>> GetAllAsync(CancellationToken ct = default) { // 设置缓存键 var key = "users:GetAll"; // 通过调用原 Service 层服务,构建缓存 return await _cacheService.GetOrCreateAsync( key, async () => await _userService.GetAllAsync(ct), TimeSpan.FromMinutes(10)) ?? new List<User>(); } }

还没完,既然叫装饰,那就得让程序知道,这个服务被装饰了。就像 Java 的代理一样,你得让 Spring 容器知道要拿这个类的代理类,才能执行代理方法。

所以在注册 Service 时,有个关键技巧,我们在 Application 模块的 DependencyInjection 包中 的 ServiceCollectionExtensions ,修改 UserService 这个服务的注册方式:

cs
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Application.Interfaces; using Application.Services; using Domain.Interfaces.Service; using Application.Services.Caching; namespace Application.DependencyInjection; public static class ServiceCollectionExtensions { public static IServiceCollection AddApplication( this IServiceCollection services, IConfiguration configuration ) { // 先注册原服务 services.AddTransient<UserService>(); // 再注册接口依赖,让接口的实例使用装饰器类 CacheUserService services.AddTransient<IUserService>(provider => { var cacheService = provider.GetRequiredService<ICacheService>(); var userService = provider.GetRequiredService<UserService>(); return new CacheUserService(cacheService, userService); }); return services; } }

这一巧妙的实现了“装饰”,完全不入侵原 Service 逻辑,并且还实现了缓存逻辑。

运行一下: image.png

从两者的实现来看,两个方式各有优缺点,第一种方式实现简单,但是会入侵 Service 层代码;第二种方式不入侵代码,但是给 Service 层增加了一层逻辑,代码量偏多。

总体而言,我个人还是喜欢第二种实现,虽然多了一层逻辑,但胜在不入侵代码,让 Service 层的代码保持干净。

8 登录认证

简单说一下我的登录认证逻辑,我的登录认证主要是参考 Sa-Token 框架的思路,并且进行简单实现。

用户登录成功后,服务端签发 Token 给用户,同时将 Token 存入 Redis。此后的登录状态均以 Redis 的为主,如果 Redis 中的 Token 过期或者被删除了,用户登录状态失效。

而登录认证,就是需要用户在请求头携带 Token 请求,后端根据请求头中的 Token 在 Redis 中查找,找到就放行,否则拦截。

这部分我在 ASP.NET 中的实现比较简单,因为我本身只是试试能不能行,考虑不是很周到,见谅一下。

8.1 数据模型、Token 生成器、Redis 操作仓库

首先,定义实体类,主要是定义 Session 类,也就是在 Redis 中存储认证信息的格式。

在 Domain 模块,添加 Record 包,添加 TokenSession 类:

cs
namespace Domain.Entities; public sealed record TokenSession( Guid userId, string username, string role, // token 值 string token, // 失效时间 DateTime expiresAt, // 签发时间 DateTime issuedAt );

然后在 Interfaces 包中,添加 Security 包,添加两个接口:

cs
namespace Domain.Interfaces.Security; public interface ITokenGenerator { public string GenerateToken(int byteLength = 64); }

这个接口主要用于生成 Token

cs
using Domain.Entities; namespace Domain.Interfaces.Security; public interface ITokenSessionStore { Task SetAsync(string token, TokenSession session, TimeSpan ttl, CancellationToken ct = default); Task<TokenSession?> GetAsync(string token, CancellationToken ct = default); Task RemoveAsync(string token, CancellationToken ct = default); }

这个接口主要用于操作 Redis,将 Token 存入、获取、删除。

到 Infrastructure 模块,添加一个 Security 包,添加两个类,分别实现上面两个接口:

cs
using System.Security.Cryptography; using Domain.Interfaces.Security; namespace Infrastructure.Security; public class RamdomStringTokenGenerator : ITokenGenerator { // 生成 byteLength 位随机字符串 public string GenerateToken(int byteLength = 64) { var bytes = RandomNumberGenerator.GetBytes(byteLength); return Convert.ToHexString(bytes).ToLowerInvariant(); } }
cs
using System.Text.Json; using Domain.Entities; using Domain.Interfaces.Security; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; namespace Infrastructure.Security; public class RedisTokenSessionStore : ITokenSessionStore { private const string PREFIX = "auth:token:"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly IDistributedCache _cache; private readonly ILogger<RedisTokenSessionStore> _logger; public RedisTokenSessionStore(IDistributedCache cache, ILogger<RedisTokenSessionStore> logger) { _cache = cache; _logger = logger; } public async Task SetAsync(string token, TokenSession session, TimeSpan ttl, CancellationToken ct = default) { var key = PREFIX + token; var val = JsonSerializer.Serialize(session, JsonOptions); var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }; await _cache.SetStringAsync(key, val, options, ct); } public async Task<TokenSession?> GetAsync(string token, CancellationToken ct = default) { var key = PREFIX + token; var val = await _cache.GetStringAsync(key); if (string.IsNullOrWhiteSpace(val)) return null; return JsonSerializer.Deserialize<TokenSession>(val); } public Task RemoveAsync(string token, CancellationToken ct = default) { return _cache.RemoveAsync(PREFIX + token, ct); } }

记得在依赖注入容器里注册:

cs
using Domain.Interfaces.Repository; using Domain.Interfaces.Security; using Domain.Interfaces.Service; using Infrastructure.Caching; using Infrastructure.Data; using Infrastructure.Repository; using Infrastructure.Security; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace Infrastructure.DependencyInjection; public static class ServiceCollectionExtensions { public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration ) { ...... // Security services.AddScoped<ITokenGenerator, RamdomStringTokenGenerator>(); services.AddScoped<ITokenSessionStore, RedisTokenSessionStore>(); return services; } }

这部分就完成了。

8.2 认证服务层、控制层

回到 Application 模块,在 DTOs 包添加 AuthDtos,定义登录和响应需要的传输模型:

cs
using System.ComponentModel.DataAnnotations; namespace Application.DTOs; public sealed record LoginRequest { [Required] public Guid UserId { get; init; } [Required] public string Password { get; init; } = string.Empty; } public sealed record LoginResponse( string AccessToken, DateTime ExpiresAt );

我数据库中忘记对 Username 做唯一约束,这里就用 UserId 替代一下,实际开发不要这么用。

然后,在 Interfaces 包中添加接口:

cs
using Application.DTOs; namespace Application.Interfaces; public interface IAuthService { Task<LoginResponse> LoginAsync(LoginRequest request, CancellationToken ct = default); Task LogoutAsync(string token, CancellationToken ct = default); }

最后在 Services 包实现:

cs
using Application.DTOs; using Application.Interfaces; using Domain.Entities; using Domain.Interfaces.Security; namespace Application.Services; public class AuthService : IAuthService { private readonly IUserService _userService; private readonly ITokenGenerator _tokenGenerator; private readonly ITokenSessionStore _tokenSessionStore; public AuthService( IUserService userService, ITokenGenerator tokenGenerator, ITokenSessionStore tokenSessionStore ) { _userService = userService; _tokenGenerator = tokenGenerator; _tokenSessionStore = tokenSessionStore; } public async Task<LoginResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) { // 获取用户信息 var user = await _userService.GetByIdAsync(request.UserId, ct); if (user is null) throw new UnauthorizedAccessException("用户名不存在"); if (user.Password != request.Password) throw new UnauthorizedAccessException("密码错误"); // 生成 Token var token = _tokenGenerator.GenerateToken(); // 创建 Session 模型 var expriesAt = DateTime.Now.AddHours(1); var session = new TokenSession( userId: user.Id, username: user.Username, role: user.Role.ToString(), token: token, expiresAt: expriesAt, issuedAt: DateTime.Now ); // 存入 Redis await _tokenSessionStore.SetAsync(token, session, TimeSpan.FromHours(1), ct); // 返回 Token return new LoginResponse(token, expriesAt); } public Task LogoutAsync(string token, CancellationToken ct = default) { return _tokenSessionStore.RemoveAsync(token, ct); } }

认证的逻辑比较简单,密码没有加密,所以校验也很简单,漏洞很多,实际环境一定要严谨。

注册服务:

cs
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Application.Interfaces; using Application.Services; using Domain.Interfaces.Service; using Application.Services.Caching; namespace Application.DependencyInjection; public static class ServiceCollectionExtensions { public static IServiceCollection AddApplication( this IServiceCollection services, IConfiguration configuration ) { ...... services.AddScoped<IAuthService, AuthService>(); return services; } }

然后是控制器,直接在 Api 模块的 Controllers 包中,添加控制器:

cs
using Application.DTOs; using Application.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; [ApiController] [Route("api/[controller]")] public class AuthController : ControllerBase { private readonly IAuthService _authService; public AuthController(IAuthService authService) { _authService = authService; } [AllowAnonymous] [HttpPost("login")] public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request, CancellationToken ct) { var result = await _authService.LoginAsync(request, ct); return Ok(result); } [Authorize] [HttpPost("logout")] public async Task<IActionResult> Logout(CancellationToken ct) { var auth = Request.Headers.Authorization.ToString(); if (!System.Net.Http.Headers.AuthenticationHeaderValue.TryParse(auth, out var parsed) || !string.Equals(parsed.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(parsed.Parameter)) { return Unauthorized("Authorization 头必须为 Bearer Token"); } var token = parsed.Parameter.Trim(); await _authService.LogoutAsync(token, ct); return Ok(); } }

[AllowAnonymous] 是一个授权特性,用于显式允许匿名访问,即不用登录也能访问,可以作用在控制器类上,也可以作用在方法上。 上述代码中,从请求头提取 token 的逻辑没有封装,生产环境记得封装一下,方便调用。

8.3 认证处理器、运行测试

由于 ASP.NET 原生就集成了认证功能,不需要手写拦截器,只需要重写认证处理器即可。

在 Api 模块中,新建 Auth 包,创建认证处理器:

cs
using System.Security.Claims; using System.Text.Encodings.Web; using Domain.Interfaces.Security; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; namespace Api.Auth; public sealed class RedisTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> { // SCHEME 是框架用于区分不同认证方式的标识 public const string SCHEME = "RedisToken"; private readonly ITokenSessionStore _tokenSessionStore; public RedisTokenAuthenticationHandler( IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ITokenSessionStore tokenSessionStore ) : base(options, logger, encoder) { _tokenSessionStore = tokenSessionStore; } protected override async Task<AuthenticateResult> HandleAuthenticateAsync() { // 提取请求头,这里比较简单,实际开发记得做严谨一点的提取验证 if (!Request.Headers.TryGetValue("Authorization", out var authHeader)) return AuthenticateResult.NoResult(); var token = authHeader.ToString().Replace("Bearer ", string.Empty).Trim(); if (string.IsNullOrWhiteSpace(token)) return AuthenticateResult.Fail("Token 为空"); // 从 Redis 中获取 Session var session = await _tokenSessionStore.GetAsync(token, Context.RequestAborted); if (session == null) return AuthenticateResult.Fail("Token 无效或已过期"); // 这里是 ASP.NET 的认证机制实现步骤,必须返回有效的认证凭据,才能算完成认证 // 将 Session 转化为框架可识别的 Claims 格式 var claims = new[] { new Claim(ClaimTypes.NameIdentifier, session.userId.ToString()), new Claim(ClaimTypes.Name, session.username), new Claim(ClaimTypes.Role, session.role), }; // 创建身份对象 var identity = new ClaimsIdentity(claims, SCHEME); // 创建当前认证用户对象 var principal = new ClaimsPrincipal(identity); // 创建凭据 var ticket = new AuthenticationTicket(principal, SCHEME); // 认证成功 return AuthenticateResult.Success(ticket); } }

最后,就是注册这个认证处理器,在 Api 包下的 Program.cs 中,在 // Add services to the container. 部分 添加如下代码:

cs
using Api.Auth; using Application.DependencyInjection; using Infrastructure.DependencyInjection; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Serilog; var builder = WebApplication.CreateBuilder(args); // Add services to the container. ...... // 注册认证处理器 builder.Services.AddAuthentication( options => { // 指定默认认证方案 options.DefaultAuthenticateScheme = RedisTokenAuthenticationHandler.SCHEME; options.DefaultChallengeScheme = RedisTokenAuthenticationHandler.SCHEME; } ).AddScheme<AuthenticationSchemeOptions, RedisTokenAuthenticationHandler>( // 添加认证方案 RedisTokenAuthenticationHandler.SCHEME, _ => { } ); // 添加授权配置 builder.Services.AddAuthorization( options => { // FallbackPolicy 是默认授权策略,为全局提供统一的默认行为 options.FallbackPolicy = new AuthorizationPolicyBuilder() // 使用Redis Token方案 .AddAuthenticationSchemes(RedisTokenAuthenticationHandler.SCHEME) // 要求用户已认证,这里要求所有接口必须认证,除非加了 [AllowAnonymous] 特性 .RequireAuthenticatedUser() .Build(); } ); ...... var app = builder.Build(); // Configure the HTTP request pipeline. ..... // 这两个记得加,默认就有 app.UseAuthorization(); app.MapControllers(); app.Run();

接下来就可以用了: image.png

  • 黄色框是登录接口,建了 token session 缓存。
  • 红色框是没带 Token 访问 User 的 GetById 方法,返回 401 响应码。
  • 蓝色框是带 Token 访问User 的 GetById 方法,成功返回。

image.png

  • 黄色框是登出接口。
  • 蓝色框是带原来的 Token 访问,访问User 的 GetById 方法,提示 Token 过期。

9 基础知识补充

  1. C# 、.NET、ASP.NET 三者是什么关系? C# 是编程语言,.NET 是开发、运行平台、ASP.NET 是 Web 框架。 .NET 是一个跨语言、跨平台的开发平台,提供程序开发环境和运行环境,语言可以是 C#,f#,j#,vb.net 等等。 这三者的关系有点像 Java、JVM、Spring MVC,只不过 JVM 只是跨平台。 补充:
  2. .NET 的特性Attribute是什么? 特性是可以为类、方法、属性等添加声明式的元数据标签的机制。在编译后,这些信息会嵌入到程序集的元数据中,可以通过反射在运行时读取。和 Java 的注解类似,都是在源代码层面,给类、方法、属性等添加标签,然后在编译时写入元数据,告诉程序要做什么处理。
  3. 装饰器模式和代理模式的区别?
    • 设计目标不同,装饰器模式的目的是增强功能,为对象添加/扩展出新功能;代理模式目的是控制访问,管理对原始对象的访问,或者叫做管理代码的执行(不止增强)。
    • 实现方式不同,装饰器模式一般通过多层嵌套来装饰;代理模式一般是一对一代理。
  4. 各个依赖注入策略?
    • Transient:每次注入都会创建一个新的实例。
    • Scoped:每个 HTTP 请求内共享同一个实例。
    • Singleton:整个应用生命周期只创建一个实例。

10 总结

  1. 学习了 C# 相关语法。
  2. 学习了 dotNET 相关命令。
  3. 学会如何使用 dotNET 命令搭建洋葱架构项目。
  4. 学习了 ASP.NET 洋葱架构项目以及使用方式。
  5. 学习了 ASP.NET 异步编程的简单实践。
  6. 学习了如何在 ASP.NET 中连接、操作数据库。
  7. 学习了 EF Core 库的使用方法。
  8. 学习了如何基于洋葱项目架构编写基本的 CURD 操作。
  9. 学习了如何在 ASP.NET 中集成 Serilog 日志库。
  10. 学习了如何进行简单的分层注册 Service。
  11. 学习了如何在 ASP.NET 下载三方库、构建项目、运行项目。
  12. 学习了如何在 ASP.NET 集成 Redis。
  13. 学习了在 ASP.NET 中编写缓存逻辑的两种方式。
  14. 学习了在 ASP.NET 中,编写登录认证的过程,了解了 ASP.NET 进行认证的流程。
作者: Xigrut发布时间: 2026-04-13 17:44:30上次编辑时间: 2026-06-18 19:20:54 许可协议: CC BY-NC-SA 4.0
留言区