☰
ASP.NET Core实战:从零构建轻量任务清单API
2026/10/20 3:04:28 网站建设 项目流程

平时逛技术社区的朋友,可能见过一个叫“哥本哈士奇”的ID,后面的签名栏永远挂着四个字母:aspnetx。这个名字初看像个段子,哥本哈根和哈士奇拼在一起,后面还跟着一个.NET技术栈的缩写。其实迈过了那段解释“我不是丹麦人”的尴尬期之后,这个ID倒也帮我认识了不少做.NET的朋友。今天想聊的,就是顶着这个ID做的一个小项目:一个面向个人和小团队使用的轻量任务清单API服务。它没有复杂的UI,就是一套REST接口,负责把待办事项、重复计划和简易备注存下来,再提供查询和提醒的能力。如果你正在学ASP.NET Core,或者想找一个能练手又能真跑起来的后端项目,这文章应该能帮你少踩几个坑。

1. 这个昵称是怎么来的:哥本哈士奇与aspnetx

1.1 从一个“狗名”开始的开发签名

“哥本哈士奇”最早其实是我在一个宠物主题交流群里随口起的ID,当时家里养了一只拆家能力极强的哈士奇,又正好在看哥本哈根的旅游攻略,把两个词一拼就成了这个听起来有点荒诞的昵称。后来注册代码托管平台、写技术文章账号,发现这个ID基本没被人占用,干脆一直用到现在,反而成了我在技术社区里的一层固定标识。

aspnetx这个签名更有意思。x在这里不是某个具体版本,而是“拓展、未知、交叉体验”的意思:从早期的ASP.NET Web Forms到后来的ASP.NET Core MVC,再到跨平台部署的.NET 6+,我把这一路踩过的框架迭代、从Windows服务器迁到Linux容器、从单体页面改成前后端分离,都用这个签名作为一个技术坐标。熟悉这个签名的人看到它就知道:这是一个在.NET生态里摸爬滚打过的开发者。

有人会问,为什么不用一个更“专业”的名字?我的看法是,越是在技术圈里混久了,越觉得代码和项目能不能解决实际问题,比ID气质重要得多。一个轻松的名字反而拉低了沟通门槛,很多朋友在社区里私信第一句不是“请问你用什么框架”,而是“你真的养过哈士奇吗”。顺着这个话题,反而更容易聊到技术方案。

1.2 这个项目到底做什么

做这个任务清单API的起因很现实:我自己的待办事项长期分散在手机备忘录、电脑上的纯文本文件、团队群里同事@我的记录里,每换一个地方就漏掉一件事。试过几款现成的待办应用,要么数据在别人的服务器上,表结构还绑得死死的,想加个自定义字段都困难;要么调用接口要层层申请权限,个人使用完全没必要。

后来我给自己定了一个小目标:写一个自己能完全掌控的待办后端服务。核心功能其实很简单,就是通过REST接口对“任务”做增删改查,再加上标签、截止时间、重复规则和备注这几个常用属性。做成API而不是带页面的完整产品,是因为前端的形态变化太快——今天想用网页管理,明天可能想在手机端看,后天又想用命令行脚本批量导入。只要后端接口是稳定的,前端想怎么换都行。

项目跑到现在,功能比初版多了一点,包括:

  • 任务的CRUD:创建、查询、修改、删除,支持分页和状态筛选。
  • 重复任务:按每天、每周、每月生成下一次到期时间。
  • 标签与搜索:给任务打标签,按关键字检索标题和备注。
  • 提醒标记:记录提醒时间与提醒状态,供后续推送服务调用。
  • 多端共用:任何一个客户端只要持有API密钥,都能访问同一份数据。

整个项目用ASP.NET Core实现,数据存储用PostgreSQL,部署时用Docker跑容器,所有代码都是我一个人在维护。说不上多复杂,但胜在结构清楚,每个部分都能看懂、能改、能替换。

1.3 谁适合读这篇内容

这篇内容大概能给三类人提供参考。第一类是刚开始学.NET开发的初学者,跟着文章可以完整看到从项目初始化、写接口、跑迁移到容器化部署的全流程,相当于一份动手练习手册。第二类是想做独立小工具或内部系统的开发者,文章里有很多关于项目结构、依赖注入、中间件顺序、环境配置这些“真要自己搭一次才会遇到”的细节。第三类是对服务端性能问题有些好奇的朋友,后半部分记录的连接池耗尽、N+1查询和时区坑,都是真实项目里会出现的典型场景。

如果你完全没有接触过.NET,也不用担心。前半部分会给出每个命令的含义,代码块也可以直接复制运行。我更希望读者能把它当成一个可以“抄作业”的起点,而不是说教式的教程。

2. 技术选型:为什么是ASP.NET Core而不是别的

2.1 框架选择的账要算清楚

每次说到用.NET做后端,总有人问“为什么不用Node或者Python”。这个问题我认真算过账,技术选型不是看热度,而是看场景。

做这类需要长期维护的接口服务,我最看重三点:类型安全、生态稳定、部署简单。C#在这三点上的表现都不错。强类型意味着重构时编译器能帮你拦住一大批低级错误;NuGet上的成熟库足够多,官方文档也一直在更新;部署方面.NET已经跨平台,写好的程序打一个镜像就能在Linux服务器上跑,不再有“Windows才能跑ASP.NET”的老旧印象。

再看性能层面,.NET的异步IO和响应速度在同类框架里属于第一梯队。平时接口的P99响应时间在20毫秒以内,跑在单台云服务器上完全够用。用Node或Python当然也能做出同样功能,但在团队协作和大型文件项目的长期维护上,C#的IDE提示和代码审查体验对我来说更顺手。

对照一个简表来看:

对比维度ASP.NET CoreNode.js技术栈Python技术栈
类型系统强类型,编译期检查动态类型动态类型
运行性能高中高中
生态成熟度高高高
微服务支持成熟成熟成熟
个人维护成本中低中低
跨平台部署支持容器支持容器支持容器

我这里不是说其他技术不行,而是从“个人长期维护”“小团队协作”“接口稳定性优先”这几个原则出发,ASP.NET Core是最不折腾的选择。

2.2 项目结构的设计思路

很多初学者拿到模板之后,喜欢在一个文件夹里堆一堆Controller,然后在每个Controller里直接写业务逻辑。短期跑得通,但接口一多就乱了。这个项目我采用的是轻量的模块化分层,没有引入特别重的DDD或CQRS,只按“接口层-服务层-数据层”拆开,保持每个文件的职责单一。

实际目录结构大概是这样的:

HuskyTodo/ ├── Controllers/ # API入口 ├── Services/ # 业务逻辑与接口实现 ├── Models/ # 实体与DTO ├── Data/ # DbContext与配置 ├── Middleware/ # 自定义中间件 ├── Filters/ # 统一异常处理 ├── Migrations/ # EF Core生成的迁移文件 ├── Program.cs # 应用入口 ├── appsettings.json # 配置文件 └── HuskyTodo.csproj # 项目文件

这个结构最核心的出发点是把“输入输出”和“业务规则”分开。Controller只负责从HTTP请求里拿到参数、调用服务、返回结果,不直接操作数据库;服务层处理任务状态流转、重复日期计算这些核心逻辑;数据层用EF Core的仓储模式封装DbContext访问,这样就算以后把PostgreSQL换成MySQL,需要改动的也只是数据层和配置。

为了不让小项目变得过度设计,我没有使用Repository模式套壳,直接让服务层注入DbContext。这样代码量少,也更容易读懂。重要的是逻辑不能分散,每个业务动作应该能在服务层的同一个方法里找到完成记录。

2.3 依赖注入与中间件管道的理解

ASP.NET Core内置依赖注入容器,平时写代码时对这个东西可能没什么感觉,但项目一大就特别有用。以这个任务清单项目为例,任务服务需要访问数据库,需要在创建任务时记录操作日志,还可能需要调用一个时间服务来计算重复任务的到期日。没有依赖注入的话,这些对象就得一层层手动传,代码又长又容易错。

实际做法是在Program.cs里注册服务:

builder.Services.AddScoped<ITodoService, TodoService>(); builder.Services.AddDbContext<TodoDbContext>(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); builder.Services.AddSingleton<TimeProvider>();

AddScoped的意思是每个HTTP请求范围内,TodoService是同一个实例;AddSingleton表示全局共享单例;AddTransient则每次获取都新建。这三个生命周期我一直觉得用生活场景来理解最好:Singleton像办公室里的咖啡机,大家共享一台;Scoped像会议室的投影仪,同一个会议内是那一台,换开会就换了;Transient像便签纸,每次拿到的都是新的一张。

中间件管道则是HTTP请求进入应用后按顺序经过的一串关卡。这个顺序很关键,比如CORS、认证、异常处理的执行位置错了,接口行为就会和预期完全不同。我在项目中用到的基础顺序是:

app.UseHttpsRedirection(); app.UseCors("HuskyCors"); app.UseMiddleware<ApiKeyMiddleware>(); app.UseAuthorization(); app.MapControllers();

CORS要放在认证前面,不然跨域请求会在认证阶段直接被浏览器拦截;异常处理中间件则尽量放在管道最外层,这样即使后续某个环节抛了异常,也能被统一捕获并转成固定格式的响应。这个顺序踩过一次坑之后,基本不会再弄错。

3. 实操:从零搭建一个“哈士奇清单”API

3.1 初始化项目与环境准备

开始之前,先确保本机装好了.NET SDK。可以打开终端执行一下版本检查:

dotnet --version

正常会输出类似8.0.100的版本号。如果提示找不到命令,去.NET官网下载对应操作系统的SDK安装包即可。版本建议选当前LTS,比如.NET 8或.NET 6,不要追预览版,不然第三方包兼容问题会浪费时间。

然后创建项目:

dotnet new webapi -n HuskyTodo cd HuskyTodo

如果是在Visual Studio里操作,也可以直接使用“ASP.NET Core Web API”模板。这里用命令行的原因是清晰,每个步骤都看得见。

生成的模板里自带一个天气预报示例接口,我们先把它删掉,同时顺手引入需要用到的NuGet包:

dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Swashbuckle.AspNetCore

第一个是PostgreSQL的EF Core驱动,第二个用于执行迁移命令,第三个方便在开发模式下查看Swagger文档。装完后可以把模板生成的WeatherForecast相关文件删除,保持项目干净。

审核一下Program.cs,去掉不需要的Swagger条件块?开发阶段我习惯保留,线上发布时会用环境判断关掉。模板生成的HTTPS重定向和开发环境的异常页都先保留,这和后面的部署配置并不冲突。

3.2 数据模型与数据库迁移

接下来说明一下数据模型。任务清单里最核心的实体是TodoItem,我最初的设计特别朴素,只有几个字段:Id、标题、完成状态、截止时间、创建时间、标签、备注。后来又增加了“重复计划”的字段,因为很多任务是周期性出现的,比如“每周五给哈士奇驱虫”“每天检查一次服务器磁盘”这类。

核心实体定义:

public class TodoItem { public Guid Id { get; set; } public string Title { get; set; } = string.Empty; public string? Note { get; set; } public DateTime? DueAt { get; set; } public bool IsCompleted { get; set; } public string? Tags { get; set; } public RepeatType Repeat { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } public enum RepeatType { None = 0, Daily = 1, Weekly = 2, Monthly = 3 }

关于Tags字段,很多人会纠结要不要建一个Tag表和一个多对多关系表。对于这个项目来说,最常用的操作是“按标签过滤”和“按标签名搜索”,用逗号分隔存成一个字符串反而简单高效。真到了需要按标签做聚合统计的那一天,再把Tags拆成独立表也不迟。过早引入关联表只会让CRUD复杂度成倍增加。

DbContext代码如下:

public class TodoDbContext : DbContext { public TodoDbContext(DbContextOptions<TodoDbContext> options) : base(options) { } public DbSet<TodoItem> TodoItems => Set<TodoItem>(); } } 然后配置连接字符串。在appsettings.json里加一段: { "ConnectionStrings": { "Default": "Host=localhost;Port=5432;Database=huskytodo;Username=todo_user;Password=todo_pass" } } 数据库我使用PostgreSQL,原因是PostgreSQL的JSON支持、索引能力、并发控制都做得很好,而且部署时体积可控。如果只是想本地跑一下看看效果,也可以把连接字符串改成SQLite,EF Core的代码几乎不用动。 创建迁移文件: ```bash dotnet ef migrations add InitialCreate dotnet ef database update

第一条命令会根据当前模型和之前的迁移记录,生成一个增量脚本;第二条命令把脚本真正执行到数据库里。迁移文件必须提交到代码版本管理里,这样团队其他成员拿到代码后直接执行database update就能同步数据库结构。另外,如果模型改了但没有重新加迁移,运行时会出现“模型与数据库不一致”的异常,初次遇到的人都以为是自己代码写错了,其实是忘了迁移这一步。

3.3 接口逻辑与服务层实现

数据模型有了,迁移也跑了,接下来写接口。我习惯先定义服务接口,再写实现类,Controller只管调用。这样以后加缓存、加权限控制,Controller代码几乎不用动。

服务接口定义:

public interface ITodoService { Task<List<TodoItem>> GetTodosAsync(string? tag, bool? completed, int page, int pageSize); Task<TodoItem?> GetByIdAsync(Guid id); Task<TodoItem> CreateAsync(TodoItem item); Task<TodoItem?> UpdateAsync(Guid id, TodoItem item); Task<bool> DeleteAsync(Guid id); Task<TodoItem?> CompleteAsync(Guid id); Task<int> MarkOverdueAsync(); }

这里加了一个CompleteAsync,专门处理“标记完成”的操作。很多人做CRUD时会把更新和完成混在一起,导致接口调用方必须传整个对象才能更新一个状态字段。这个设计一开始就不合理:标记完成应该是轻量操作,只要传ID过去就行。

服务实现里有一个值得说的逻辑:重复任务的“下一次到期时间”计算。当用户把一条重复任务标记为完成时,行为应该是:当前这条任务的DueAt顺延到下一个周期,而不是把IsCompleted设置为true。我最初实现时搞反了,结果每周任务完成一次之后就再也不出现,后来不得不加了个字段修复线上数据。

顺延逻辑的核心代码:

public async Task<TodoItem?> CompleteAsync(Guid id) { var item = await _context.TodoItems.FindAsync(id); if (item == null) return null; if (item.Repeat == RepeatType.None) { item.IsCompleted = true; item.UpdatedAt = DateTime.UtcNow; } else { item.DueAt = CalculateNextDueTime(item.DueAt, item.Repeat); item.UpdatedAt = DateTime.UtcNow; } await _context.SaveChangesAsync(); return item; }

CalculateNextDueTime做的事很简单:Daily加一天,Weekly加七天,Monthly加一个月,同时把时间部分保留下来。这里没有用复杂的日期库,纯粹是为了控制依赖数量。如果以后要支持“每个工作日”“每两周”这类更灵活规则,可以引入一个专门的递归计划库,但现在够用就行。

Controller层则按REST风格组织。由于篇幅原因,只看Create这个Action:

[HttpPost] public async Task<ActionResult<TodoItem>> Create([FromBody] TodoItem item) { var created = await _todoService.CreateAsync(item); return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); }

这里有个小细节:数据校验我放在服务层而非Controller层。Controller层只做HTTP协议级别的参数绑定,业务规则校验(比如标题不能空、截止时间不能在创建时间之前)用服务层统一处理。这样同一套业务代码可以被其他调用方复用,而不只是被REST接口使用。

接口返回统一风格:

{ "id": "93e1a1b2-3f0a-4c1d-b67d-2a52e5c8f19a", "title": "给哈士奇准备驱虫药", "note": "每月初检查", "dueAt": "2025-06-01T08:00:00+00:00", "isCompleted": false, "tags": "宠物,健康", "repeat": 1, "createdAt": "2025-05-24T02:30:11+00:00", "updatedAt": "2025-05-24T02:30:11+00:00" }

很多新手会直接把数据库实体类当DTO返回。在简单场景下确实没问题,但一旦实体里加了EF Core的导航属性,序列化就会遇到循环引用,要么报错要么响应体巨大。我建议哪怕项目很小,也单独建一个DTO类,字段不一定一一对应,可以按客户端需要取舍。

3.4 认证与请求日志

个人项目不需要完整的用户注册登录体系,但这不意味着接口可以裸奔。我采用的方案是简单的API密钥校验:每个客户端在请求头里带上Authorization,服务端通过自定义中间件检查这个值和配置中的密钥是否一致。

中间件代码:

public class ApiKeyMiddleware { private readonly RequestDelegate _next; private readonly string _configKey; public ApiKeyMiddleware(RequestDelegate next, IConfiguration config) { _next = next; _configKey = config["ApiKey"] ?? ""; } public async Task InvokeAsync(HttpContext context) { if (!context.Request.Headers.TryGetValue("Authorization", out var token) || token != _configKey) { context.Response.StatusCode = 401; await context.Response.WriteAsJsonAsync(new { error = "Invalid API Key" }); return; } await _next(context); } }

这个方案胜在简单,密钥放在环境变量里,不硬编码到代码中,适合个人和小团队使用。如果你的项目要支持多用户、细粒度权限、密码找回这些能力,那还是应该上完整的身份认证框架,不要自己重复造轮子。

请求日志我同时用了两种手段:ASP.NET Core内置的ILogger输出结构化日志,以及一个轻量的耗时记录中间件。后者主要抓慢请求:

public class RequestLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger<RequestLoggingMiddleware> _logger; public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { var sw = System.Diagnostics.Stopwatch.StartNew(); await _next(context); sw.Stop(); if (sw.ElapsedMilliseconds > 500) { _logger.LogWarning("慢请求 {Method} {Path} 耗时 {Elapsed}ms", context.Request.Method, context.Request.Path, sw.ElapsedMilliseconds); } } }

500毫秒的阈值是观察一段时间后定下来的。正常情况下这个项目接口都在100毫秒以内,超过500毫秒一定有问题。靠这个日志抓到过几次没有加索引的全表扫描查询,后面会详细讲。

4. 部署与容器化:让项目跑起来

4.1 用Docker打包镜像

开发环境跑通以后,接下来要考虑部署。Docker把.NET应用打包成镜像,到任何一台Linux服务器上都能跑,不再受制于“服务器没有装.NET运行时”的尴尬。

这里用了多阶段构建的Dockerfile:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY HuskyTodo.csproj . RUN dotnet restore COPY . . RUN dotnet publish -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/publish . ENV ASPNETCORE_ENVIRONMENT=Production EXPOSE 8080 ENTRYPOINT ["dotnet", "HuskyTodo.dll"]

构建阶段使用SDK镜像完成编译和发布,运行阶段则使用更精简的ASP.NET运行时镜像。这样最终镜像体积会更小,之前我试过直接把SDK镜像当运行镜像用,一个镜像硬生生多出一倍多体积,没必要。

构建和启动命令:

docker build -t huskytodo:1.0 . docker run -d --name huskytodo \ -p 8080:8080 \ -e ConnectionStrings__Default="Host=host.docker.internal;Database=huskytodo;Username=todo_user;Password=password" \ -e ApiKey="your-api-key" \ huskytodo:1.0

这里要注意环境变量名称的写法。在容器里,ASP.NET Core会自动把环境变量中的双下划线__映射成配置层次结构的分隔符。也就是说,ConnectionStrings__Default对应appsettings.json里的ConnectionStrings:Default。理解这个规则以后,很多配置管理问题就迎刃而解了。

4.2 Compose编排与数据库一起跑

只跑API不算完,还要把PostgreSQL数据库也容器化,否则换一台机器就要手动装数据库,非常麻烦。用docker-compose可以把API和数据库一起编排起来。

services: api: build: . ports: - "8080:8080" environment: ASPNETCORE_ENVIRONMENT: Production ConnectionStrings__Default: "Host=db;Port=5432;Database=huskytodo;Username=todo_user;Password=postgres_pass" ApiKey: "${API_KEY:-dev-key}" depends_on: db: condition: service_healthy restart: always db: image: postgres:16 environment: POSTGRES_DB: huskytodo POSTGRES_USER: todo_user POSTGRES_PASSWORD: postgres_pass volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U todo_user -d huskytodo"] interval: 5s timeout: 5s retries: 5 restart: always volumes: pgdata:

这里有两个点值得强调。第一个是depends_on配合healthcheck,数据库容器真正准备就绪后,API才开始启动。如果没有健康检查,两个容器同时启动时API可能数据库还没就绪,连接直接失败,结果就是API进程退出。第二个是volumes挂载,数据库数据存放在命名卷里,容器删了数据还在。一开始我没挂卷,一次docker compose down把整个数据库清空了,教训深刻,差点想把哈士奇的狗粮减半当成惩罚。

启动命令:

docker compose up -d

然后可以检查容器状态和日志:

docker compose ps docker compose logs -f api

如果API启动后没有报连接数据库的错误,部署这一步基本就成功了。

4.3 Nginx反向代理与HTTPS配置

服务器上已经跑着其他服务,直接让API暴露8080端口并不理想。我习惯在前面加一层Nginx做反向代理,好处有几个:统一管理多个服务的域名转发、方便加HTTPS证书、可以做请求体大小限制和基础缓存控制。

Nginx配置文件片段:

server { listen 443 ssl; server_name todo.example.com; ssl_certificate /etc/letsencrypt/live/todo.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/todo.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 10m; proxy_connect_timeout 10s; proxy_read_timeout 60s; } }

这里Proxy Timeout的配置特别容易踩坑。如果你的接口需要处理长时间运行的任务,默认的60秒超时可能会提前切断连接。我当时把一条时间跨度很长的批量导入接口暴露在Nginx后面,迟迟拿不到响应,排查到最后才发现是Nginx的超时设置太短。解决方式很简单:根据业务量调大proxy_read_timeout,或者在接口设计上把重任务改成异步提交+状态查询,让接口本身快速返回。

关于证书,现在有很多免费证书工具可以自动申请和续期,不需要自己手动创建私钥和CSR。申请成功后,用定时任务检查续期即可。需要注意的是,Nginx配置里的路径和证书路径要一致,证书更新后需要nginx -s reload才能生效,我第一次自动续期后发现还是旧证书,就是因为没有运行reload命令。

4.4 环境变量与配置管理

部署阶段最容易出问题的就是配置管理。直接把数据库密码、API密钥这些敏感信息写进appsettings.json再打包进镜像,是最要不得的做法。镜像会被多人拉取、分发,里面的密钥等于直接裸奔。

我的做法是把所有敏感信息放到环境变量里,在Compose文件中通过${API_KEY}占位符引用宿主机上的.env文件:

# .env 文件,不提交到代码仓库 API_KEY=my-secure-key-123 POSTGRES_PASSWORD=postgres_pass

Compose会自动读取同目录下的.env文件填充变量。代码仓库里保留一份.env.example作为模板,只写变量的名字,不写真实值。这样新环境部署时,复制模板然后逐个填入真实值就行。

非敏感配置,比如日志级别、允许的CORS来源列表,可以保留在appsettings.Production.json中,然后通过配置文件覆盖开发期间的默认值。我的习惯是“默认值安放在appsettings.json里,生产环境差异放在Production配置文件中,真正的机密只走环境变量”。这样在服务器上浏览配置时可以快速分清哪些是常量、哪些是变量、哪些是秘密。

5. 性能优化与坑位实录

5.1 连接池与超时问题的排查

项目跑了大半年后,有一次开始收到接口偶发性超时的告警。错误日志里的关键信息是“Timeout expired while getting connection from pool”。这个报错说明PostgreSQL的连接池被占满了,所有连接处于等待状态。

排查第一步是看并发量。这套服务平时并发很低,按说不应该打满连接池。后来发现罪魁祸首是程序中有一个定时任务,每次批量检查“过期未完成任务”时会循环更新几千条记录,并且每一条都用了独立的SaveChanges。原本一条SQL就能完成的事,被拆成了一千次交互,每次交互都要从连接池里取一次连接,一跑就把连接池占满了。

解决办法很直接:把批量更新改成一次性提交。EF Core里可以用ExecuteUpdate高效处理这类场景,不需要逐条load到内存再更新。

await _context.TodoItems .Where(t => t.DueAt < DateTime.UtcNow && !t.IsCompleted) .ExecuteUpdateAsync(setters => setters.SetProperty(t => t.IsCompleted, true));

同时把连接池的最大连接数调到一个合理值,在连接字符串里设置:

Host=db;Port=5432;Database=huskytodo;Username=todo_user;Password=postgres_pass;Maximum Pool Size=20

20这个数字对个人项目来说已经足够。没有定论的配置建议,一定要结合自己的压测结果来调整,上来就设置几百个连接往往会适得其反,因为数据库同时能处理的活跃连接有上限,超过之后反而会排队。

5.2 EF Core的N+1查询问题

N+1查询是EF Core使用中最经典的性能问题。现象是列表接口响应很慢,打开日志一看,发现查询数量比预期多得多。

我项目里的具体场景是这样的:列表接口返回任务时,会顺带根据Tags字段去解析出一个标签列表。如果直接在循环里逐个查询标签,那么一次请求返回50条任务,就会产生50次额外查询。这就是N+1:第一次查询查出50条主数据,后面每个主数据又触发一次子查询。

解决办法有两个思路。一个是用EF Core的Include方法预先加载关联数据,让几次查询合并为一次;另一个是改造查询方式,把关联数据一次性查询出来,再用内存组合。考虑到Tags本身是字符串字段,我的实现里并不需要Join关联表,真正的问题出在“循环里调用外部服务和AWAIT多次数据库操作”,本质上不是N+1的标准形态,但同样会造成多次往返。

后来我把这类列表查询统一修改:先查任务集合,再把所有用到的标签一次性查询出来,拼接成Dictionary,最后在内存里组合返回。这样无论列表返回多少条任务,数据库查询次数都是固定的2次。

var todos = await _context.TodoItems .Where(...) .Skip(page * pageSize) .Take(pageSize) .ToListAsync(); var allTags = todos .SelectMany(t => t.Tags?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? []) .Distinct() .ToList(); var tagMeta = await _context.Tags .Where(t => allTags.Contains(t.Name)) .ToDictionaryAsync(t => t.Name);

最后组合数据时遍历todos,从tagMeta字典中取值。这样的性能提升非常可观,接口耗时从平均600毫秒降到了80毫秒以内。

排查N+1这类问题,最直接的手段是开启EF Core的SQL语句日志。在开发环境下配置:

optionsBuilder .LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging();

看到日志里一个接口执行了几十条SELECT,基本就能断定存在查询过多问题。

5.3 时区与日期序列化的教训

时区问题是后端开发里最不容易察觉的坑。任务清单涉及“到期时间”,如果我直接存本地时间而服务器又设置成UTC时区,那前端看到的截止日期就会差8个小时。第一次线上数据出现“明明设置的截止时间是明天,前端却显示昨天”这个诡异问题时,我花了不少时间才定位到是时区序列化问题。

解决方案是统一的日期存储规范:数据库里一律存UTC时间,接口输出时保留ISO 8601带时区偏移的格式,前端展示时根据用户本地时区做转换。不依赖服务器所在时区,也不允许前端自己处理UTC换算,因为那等于把同样的逻辑在每一个客户端各写一遍。

具体实现直接在.NET侧配置JSON序列化:

builder.Services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; });

在写入数据库时统一用DateTime.UtcNow:

item.CreatedAt = DateTime.UtcNow; item.UpdatedAt = DateTime.UtcNow;

这样一来,无论服务器在哪个地域,数据存进数据库都是标准UTC时间;客户端拿到接口返回值后,如果需要展示“今天”“明天”这样的提示,再基于用户的浏览器时区做处理。这一条经验看起来不起眼,但对所有涉及时间的项目都适用。

5.4 请求体过大的坑

任务清单的备注字段一开始没做长度限制,结果有个用户往备注里粘贴了一大段包含图片Base64编码的文本,请求体直接超过了1MB。Kestrel默认有请求体大小限制,超出后接口返回413错误。

对HTTP API来说,请求体大小限制是一个安全特性,不应该轻易放开。我当时在需要支持较大文本的场景下,单独给这个接口配置了更大的限制:

builder.Services.Configure<FormOptions>(options => { options.MultipartBodyLengthLimit = 20 * 1024 * 1024; });

但更合理的做法是客户端上传的文件类数据走专门的对象存储通道,而不是用JSON塞进接口里。这个项目后来也确实把附件功能挪到了独立上传接口,任务接口只保存文件地址。

所以我的建议是:不要一遇到413就盲目调大全局限制。先看是否有其他更合适的存储路径;如果确实需要调大,也尽量限定到特定控制器或特定路由,缩小风险面。

6. 开源维护与社区运营的个人经验

6.1 文档怎么写才有人看

项目开源以后,写文档是最容易被忽视但回报最高的一件事。我在早期犯过的错误是README写得像一个专业报告,第一段放一堆架构说明和技术栈清单,读者看了半天还不知道项目能干什么。后来我把README的第一屏内容改成了“一句话介绍+运行截图+快速开始命令”,效果立竿见影。

好的项目文档,最起码包含一个让新人能在5分钟内跑起来的快速开始。拿这个项目举例:

git clone <repo-url> cd HuskyTodo cp .env.example .env docker compose up -d

然后给一个最简单的新增任务和查询任务的curl示例:

curl -X POST http://localhost:8080/api/todo \ -H "Authorization: dev-key" \ -H "Content-Type: application/json" \ -d '{"title": "给哈士奇准备晚饭", "tags": "宠物"}'

能把“让新手10分钟内跑通”这件事做好,文档就成功了一半。剩下那些详细说明、配置表格、常见Q&A都是锦上添花。很多项目写复杂是因为作者想显得专业,但用户需要的只是“我能不能快速用起来”这个答案。

6.2 需求反馈的处理节奏

一个人维护开源项目,最大的问题不是代码,而是需求管理。有人会提交各种奇奇怪怪的功能请求:希望在任务里支持子任务、希望增加看板界面、希望支持WebDAV同步等等。如果每个请求都立刻动手,很快就会把自己拖垮。

我的处理流程是:所有请求先写进Issue列表,不急着回复“在做”或“不做”。等一段时间,看相同请求出现的频率和场景,优先做那些“多个独立用户都提到同一个真实痛点”的功能。单个用户提出的复杂需求,大多时候可以通过脚本或外部工具解决,不要为了一个特殊场景污染主流程的设计。

版本发布上,我用语义化版本号管理:主版本号不轻易动,次版本号对应新增功能,修订号对应修复Bug和文档调整。每次打Tag后写清楚变更日志,让使用者知道升级可能带来的影响。

维护了半年以后,最大的体会是代码维护要“克制”。宁可少加功能,也不要让已有的核心功能变复杂。项目之所以还有人用,往往不是因为功能多,而是因为它稳定、简单、好部署。

6.3 自动化测试与CI的落地

个人项目也要写测试,这点我一开始也不以为然。直到有一次改动日期计算相关代码,结果把一个已经跑了几周的重复任务功能弄坏了,还是用户先发现的。从那以后,我给自己定了一条规矩:涉及核心业务逻辑的修改,必须带上测试。

测试大致分两层。单元测试主要覆盖服务层的纯逻辑,尤其是计算下一次到期时间这部分,给Daily、Weekly、Monthly各写几个用例。集成测试用WebApplicationFactory启动内存中的API,模拟HTTP请求验证接口的增删改查。前者跑得快,适合频繁执行;后者更接近真实环境,但启动慢一些,放在CI里跑就行。

CI流程我放在托管服务商提供的流水线上,主要任务是三件事:编译、跑测试、构建镜像。三条通过后再发起合并请求或打版本Tag。由于项目本身不大,整个CI跑完也就几分钟。这一点投入换来的好处是,每次改动合并前,至少机器会明确告诉你“有没有把已有功能弄坏”。

6.4 社区里的那些有趣反馈

这个项目本身没什么商业价值,但它意外地帮我积累了不少有趣的反馈。有人拿它管理便利店的补货清单,把每个货架的缺货情况当成一条任务;有人拿它当团队的轻量值班排班表;还有人把它接到自己写的命令行工具里,用终端快速添加待办。这些用法是我设计时完全没有想到的。

这些反馈倒也不是毫无用处。正因为有人用它管理便利店库存,我才注意到重复任务里“每两周”这种频率的需求;因为有人做命令行工具,我才在接口返回里保留了足够多的字段,而不是只返回精简DTO。这提醒我一件事:接口设计时尽量让数据字段完整,多保留几个扩展字段,比什么都好。客户端可以不使用这些字段,但后端一旦砍掉,再想加回来就要面临版本兼容问题。

做开源项目其实不太会有“一夜爆红”的时刻,更多是细水长流:今天一个Issue,明天一个修正PR,后天有人在社区里分享使用体验。相比技术上的新鲜感,我更喜欢这种被真实场景需要的踏实感。

最后再说一个维护细节吧。项目里我加了一个“哈士奇日志”的约定:每次发版时在变更记录里附上一张哈士奇表情包对应的文字描述,算是给稳定版本的一点点仪式感。别小看这些彩蛋,它能让维护者保持愉快的心态。遇到再难的问题,想想自己当初为什么要给自己一头拆家犬起这个名,就会觉得一切都没那么严肃。技术栈可以换,架构可以推倒重来,但保持长期维护下去的耐心和兴趣,才是个人项目真正稀缺的资源。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询