Skip to content
Go back

不要 .csproj 也能行:用 .NET 11 file-based app 构建完整 Web API

不要 .csproj 也能行:用 .NET 11 file-based app 构建完整 Web API

作者 Artem Makarov 花了一个周末验证一件事:能不能用 .NET 11 的 file-based apps 特性,在没有 .csproj 的情况下,搭出一个真实的 ASP.NET Core Web API——不是 Hello World,而是带 EF Core、PostgreSQL、迁移、OpenAPI、中间件和 PATCH 端点的分层 CRUD 服务。

结论是:能。实验结果放在 GitHub 仓库 csharp-looks-like-go。他说,写完之后整个项目”感觉很像在写一个小的 Go 服务”——对于一个用 C# 六年的人来说,这不是他预期会打出来的话。

file-based app 是什么

这个特性从 .NET 10 开始引入,在 .NET 11 preview 3 里进一步成熟。核心想法很简单:你可以直接运行、发布、交付一个 .cs 文件,不需要创建 .csproj,也不需要 solution 文件。

SDK 所需的一切配置,通过文件顶部的预处理指令声明:

#!/usr/bin/env dotnet run
#:property TargetFramework=net11.0
#:property LangVersion=preview
#:sdk Microsoft.NET.Sdk.Web
#:package Npgsql.EntityFrameworkCore.PostgreSQL@10.0.1
#:include domain/user.cs

var app = WebApplication.CreateBuilder(args).Build();
app.MapGet("/", () => "hello from a file-based app");
app.Run();

上面这段就是 main.cs 的全部开头。运行方式是 dotnet run main.cs。加上 shebang 并 chmod +x,可以像 Python 脚本一样直接执行 ./main.cs

作者最常用的五个指令:

最后这个传递性标志尤其关键。没有它,所有包引用和文件包含必须堆在 main.cs 里;开启后,指令可以从被 include 的文件里传播出来,整个项目才能真正按模块拆分。

项目结构

作者的仓库采用分层架构:handler -> service -> repository -> db,两个实体(UserPost),一对多关系。整体目录结构如下:

csharp-looks-like-go/
├── main.cs          # 入口、属性、路由表
├── packages.cs      # #:sdk + #:package 声明
├── includes.cs      # 所有 #:include 的平铺列表
├── globals.cs       # global using 指令
├── config/
├── db/
├── domain/
├── handler/
├── middleware/
├── migrations/
├── model/
├── repository/
├── service/
└── util/

顶部四个文件是”项目级配置”,其余都是业务代码。main.cs 只有几行,可以在一屏内读完。

文件名用 snake_case,命名空间用小写单数(handlerservicerepository),服务类用 C# 12 的主构造函数语法:

class UserService(UserRepository repository)
{
    public Task<IReadOnlyList<User>> List() => repository.List();

    public async Task<User> Create(CreateUserRequest request)
    {
        var name = request.Name.RequireNonEmpty(nameof(request.Name));
        var email = request.Email.RequireNonEmpty(nameof(request.Email));
        await EnsureEmailIsUnique(email);
        return await repository.Create(name, email);
    }
    // ...
}

RequireNonEmpty 是放在 util/string_utils.cs 里的扩展方法,用了 C# 14 的新 extension(string str) 块语法:

namespace util;

static class StringExtensions
{
    extension(string str)
    {
        public string RequireNonEmpty(string fieldName)
        {
            var trimmed = str.Trim();
            if (string.IsNullOrWhiteSpace(trimmed))
                throw new ArgumentException($"{fieldName} is required");
            return trimmed;
        }
    }
}

和以前的 public static string X(this string s) 语法相比,新写法更简洁,也支持扩展属性和静态扩展成员。

实体类用 required 属性,不再写 = null!;

class User
{
    public long Id { get; set; }
    public required string Name { get; set; }
    public required string Email { get; set; }
    public ICollection<Post> Posts { get; set; } = [];
}

方法名去掉了 Async 后缀——这是个服务,不是库,每个方法都是 async,后缀在调用端没有信息量。

发布产物:30 MB 单一二进制

让作者觉得有必要写这篇文章的不是指令系统,而是发布命令的结果:

dotnet publish main.cs -o bin

输出是单个 bin/main 文件,33 MB,原生 Mach-O 可执行文件(AOT 编译),目标机器不需要安装 .NET Runtime,也不需要 dotnet CLI。

加几个体积优化参数后可以压到约 30 MB:

dotnet publish main.cs -o bin \
  -p:OptimizationPreference=Size \
  -p:InvariantGlobalization=true \
  -p:DebuggerSupport=false \
  -p:EventSourceSupport=false \
  -p:HttpActivityPropagationSupport=false \
  -p:StripSymbols=true

这个 30 MB 里包含了 ASP.NET Core、EF Core、Npgsql 驱动、内置 OpenAPI、验证逻辑。scp 到 Linux 服务器,直接运行,没有其他依赖。

写 Go 的人对这个模式不陌生——go build 就是这样的。对 C# 开发者来说,这可能是第一次 dotnet publish 没有输出一个包含几十个文件的目录。

唯一没解决干净的地方:EF Core 迁移

作者在这里没有绕弯子。

dotnet ef 目前不识别 file-based apps。dotnet ef migrations add 需要解析 .csproj,而它不存在。传 --project main.cs 的话,工具会把 main.cs 当 MSBuild XML 去解析,立刻报错。EF Core 10 stable 和 EF 11 preview 都试过,结果一样。

变通方案:在代码旁边临时生成一个 .csproj,对它运行 EF,完了删掉。

仓库里记录了一个放在 .tooling/tooling.csproj 的最小项目文件,用 glob 把父目录里的实体和 DbContext 文件包进来:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>minimal_web_api</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
  </ItemGroup>
  <ItemGroup>
    <Compile Remove="**/*.cs" />
    <Compile Include="design_time_factory.cs" />
    <Compile Include="..\domain\*.cs" />
    <Compile Include="..\db\*.cs" />
    <Compile Include="..\migrations\*.cs" />
  </ItemGroup>
</Project>

加一个带连接字符串的 IDesignTimeDbContextFactory<AppDbContext>,然后执行:

cd .tooling
dotnet ef migrations add AddPosts \
  --output-dir ../migrations \
  --namespace minimal_web_api.migrations
cd ..
rm -rf .tooling

第一次还需要手动把 AppDbContextModelSnapshot.cs 从工具项目的输出路径挪回 migrations/,因为 EF 按工具项目根路径写快照,不管 --output-dir 怎么设置。第二次做就习惯了,第一次确实容易绕晕。

作者说,这是他目前不会把团队项目迁移到 file-based apps 的主要原因——其他地方感觉已经生产就绪,迁移这块还不是。

适合哪些场景

根据这个周末的实验,作者给出的判断:

现在比较适合:

还不适合:

几个让作者意外的瞬间

作者的总结是:这些功能本身大多不是新的——原生 AOT 有一阵子了,主构造函数是 C# 12,required 是 C# 11,扩展成员是 C# 14。但 file-based apps 第一次把这些东西整合到一个配置里,让 C# 项目在视觉上接近 Go 项目的感觉。他说找不到更好的词,就是”气质变了”。

参考


Tags


Previous

C# 状态模式完整实战指南:从接口到依赖注入

Next

C# 字符串转字节数组:UTF-8、编码方式与 Span 零分配技巧