File-Based Apps: Rodando um Único Arquivo .cs (.NET 10)
Objective
O SDK do .NET 10 consegue compilar e rodar um único arquivo .cs, sem
.csproj: dotnet run app.cs. A configuração que antes ficava no arquivo de
projeto passa para diretivas #: no topo do arquivo, e o SDK gera um projeto em
memória para compilar. Isso torna o C# usável para scripts e ferramentas
pequenas, mas o projeto gerado não é neutro: ele mira Native AOT por padrão, e
esse padrão muda o comportamento da aplicação até no dotnet run. Este conceito
mostra o que o SDK gera, quais diretivas existem, a armadilha do AOT e quando
converter o arquivo num projeto de verdade. Tudo abaixo foi executado no SDK
10.0.100.
Use Cases
- Trocar um script Bash ou Python num repositório .NET por C#, usando as mesmas bibliotecas que a aplicação usa.
- Experimentar um pacote NuGet ou uma API em segundos, sem criar projeto.
- Distribuir uma ferramenta de linha de comando pequena como um único executável nativo.
- Começar um protótipo num arquivo e convertê-lo em projeto quando ele crescer.
Deep Dive
Rodando um arquivo, com pacotes e propriedades
csharp#:package [email protected]
#:property PublishAot=false
using Humanizer;
Console.WriteLine($"Args: {string.Join(", ", args)}");
Console.WriteLine(TimeSpan.FromMinutes(90).Humanize(precision: 2));
Console.WriteLine("file based app".Pascalize());text$ dotnet run hello.cs -- one two Args: one, two 1 hour, 30 minutes FileBasedApp
Os argumentos da aplicação vão depois do --. dotnet hello.cs a b é uma forma
mais curta, e echo 'Console.WriteLine("hello from stdin");' | dotnet run - lê
o programa da entrada padrão. As diretivas são:
#:package Nome@Versãoadiciona umPackageReference.#:property Nome=Valordefine uma propriedade do MSBuild.#:sdk Nometroca oMicrosoft.NET.Sdkpadrão.#:project caminhoreferencia um projeto (documentado; não exercitado aqui).
As diretivas precisam vir antes de qualquer código; uma colocada depois de um
statement falha com CS9297: '#:' directives cannot be after first token in file. #:sdk Microsoft.NET.Sdk.Web basta para uma minimal API:
csharp#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
var app = WebApplication.Create(args);
app.MapGet("/ping", () => new { pong = true, at = "file-based app" });
app.Run("http://localhost:5240");
// curl http://localhost:5240/ping -> {"pong":true,"at":"file-based app"}O projeto virtual e o cache de build
O SDK sintetiza um projeto a partir do arquivo e o compila como qualquer outro.
O jeito mais fácil de ver esse projeto é pedir por ele: dotnet project convert hello.cs grava uma pasta hello/ com uma cópia do código, sem as diretivas, e
este .csproj (o arquivo original fica intacto):
plaintext<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <PackAsTool>true</PackAsTool> <UserSecretsId>hello-fc9e4937...</UserSecretsId> <PublishAot>false</PublishAot> </PropertyGroup> <ItemGroup> <PackageReference Include="Humanizer" Version="2.14.1" /> </ItemGroup> </Project>
O mesmo comando num arquivo sem #:property PublishAot=false gera
<PublishAot>true</PublishAot>: esse é o padrão. PackAsTool e um
UserSecretsId derivado do arquivo também são padrões.
A saída do build não fica ao lado do arquivo. Nesta máquina macOS ela foi para
~/Library/Application Support/dotnet/runfile/hello-<hash>/bin/debug/ (a
documentação descreve uma pasta dentro do diretório temporário do sistema, então
o local varia por plataforma). O SDK pula o build quando nada mudou: a primeira
execução depois de uma edição levou 0,79 s aqui e a seguinte, 0,15 s.
O padrão AOT chega ao dotnet run
O dotnet run app.cs continua usando o JIT; ele não compila para nativo. Mas
com PublishAot=true o build grava os feature switches do Native AOT no
runtimeconfig.json gerado, entre eles:
plaintext"System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": false, "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, "System.Diagnostics.Tracing.EventSource.IsSupported": false, "System.Reflection.Metadata.MetadataUpdater.IsSupported": false
Então código que gera código em tempo de execução falha durante o
desenvolvimento, exatamente como falharia num binário nativo. Três execuções no
SDK 10.0.100 sem PublishAot=false:
text// Dapper 2.1.66, db.Query<Row>("select 1 as Id, 'dapper' as Name") IsDynamicCodeSupported: False Unhandled exception. System.PlatformNotSupportedException: Dynamic code generation is not supported on this platform. at System.Reflection.Emit.DynamicMethod..ctor(...) // EF Core 10.0.0 com SQLite, primeiro uso do DbContext Unhandled exception. System.InvalidOperationException: Model building is not supported when publishing with NativeAOT. Use a compiled model. // JsonSerializer.Serialize(new Point(1, 2)) warning IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(...)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Unhandled exception. System.InvalidOperationException: Reflection-based serialization has been disabled for this application.
Com #:property PublishAot=false (ou dotnet run app.cs -p:PublishAot=false)
os mesmos três programas imprimem Dapper: 1 dapper, EF Core: 1 product(s) e
{"X":1,"Y":2}. Os avisos de build IL2026 e IL3050 são o sinal antecipado:
quando aparecem numa file-based app, a execução provavelmente vai bater num
feature switch.
Shebang: um arquivo C# como executável
No Unix, uma linha de shebang e o bit de execução transformam o arquivo num comando:
csharp#!/usr/bin/env -S dotnet --
// Counts non-empty lines of each file passed as argument.
foreach (var path in args)
{
var lines = File.ReadLines(path).Count(l => l.Trim().Length > 0);
Console.WriteLine($"{lines,6} {path}");
}text$ chmod +x count-lines.cs $ ./count-lines.cs count-lines.cs ../fba/hello.cs 8 count-lines.cs 6 ../fba/hello.cs
O -- no shebang importa: ele faz o dotnet repassar todos os argumentos para a
aplicação. Rodar ./count-lines.cs --help entregou --help ao programa (que
tentou abrir um arquivo com esse nome) em vez de imprimir a ajuda da CLI. A
documentação pede finais de linha LF e nenhum BOM nesses arquivos.
Publicando e convertendo
O dotnet publish count-lines.cs respeita o padrão AOT e gera um executável
nativo em artifacts/count-lines/ ao lado do arquivo: aqui, um binário Mach-O
arm64 de 1,4 MB que roda sem dotnet, gerado em menos de 5 segundos.
O dotnet project convert é a porta de saída. Use quando o script precisar de
um segundo arquivo de código, de testes ou de um lugar numa solution.
Um arquivo só, e fora de pastas de projeto
No SDK 10.0.100 a aplicação é exatamente um arquivo. Uma classe num greeter.cs
vizinho não é compilada:
textapp.cs(1,19): error CS0103: The name 'Greeter' does not exist in the current context
e #:include greeter.cs falha com error: Unrecognized directive 'include'. A
documentação atual descreve o #:include para o SDK 10.0.300 em diante, que não
estava disponível para teste aqui.
O arquivo também herda o que está em volta: a documentação lista
Directory.Build.props, Directory.Packages.props, nuget.config e
global.json das pastas acima. Um arquivo colocado numa pasta que tem um
.csproj é pior: dotnet run side.cs compilou o projeto, que incluiu o
side.cs pelo globbing e falhou com CS8802: Only one compilation unit can have top-level statements. Mantenha scripts numa pasta própria.
Trade-offs
- Sem arquivo de projeto há menos cerimônia e menos visibilidade. Padrões
como
PublishAot=trueePackAsTool=trueficam invisíveis até você converter o arquivo ou ler oruntimeconfig.jsongerado. - O padrão AOT protege o publish e surpreende o script. Ele mantém a
file-based app publicável como binário nativo, mas bibliotecas que dependem de
reflection falham no simples
dotnet run.csharp#:property PublishAot=false // primeira linha a adicionar antes de usar Dapper, EF Core ou JSON por reflection - O cache de build é rápido, mas indexado pelo arquivo. Mudanças em arquivos
de build herdados podem passar despercebidas; a documentação recomenda
dotnet clean app.csquando um build parece desatualizado. - Um arquivo só é um limite rígido no SDK 10.0.100. Helpers compartilhados significam copiar código ou converter para projeto; dividir exige um SDK mais novo.
- Um shebang amarra o script a um SDK instalado. A máquina de destino precisa
do .NET 10 no
PATH; para máquinas sem ele, publique o binário nativo.