Start Debugging

Как запустить файловое приложение на C# командой `dotnet run app.cs` в .NET 11

Полное руководство по файловым приложениям на C#: запуск одного файла .cs через dotnet run, директивы #:package, #:sdk, #:property, #:project и #:include, многофайловые скрипты с #:ref, обработка аргументов и stdin, кеш сборки, публикация с native AOT, упаковка в виде инструмента dotnet и dotnet project convert, когда скрипт перерастает себя.

Чтобы запустить файл на C# без проекта, сохраните его как app.cs и выполните dotnet run app.cs. Это всё. SDK синтезирует проект в памяти, восстанавливает пакеты, собирает результат в каталог кеша внутри временной папки и запускает его. Ни .csproj, ни класса Program, ни метода Main не требуется. Конфигурация, которая обычно жила бы в файле проекта, размещается в директивах #: в начале исходного файла: #:package Humanizer@2.14.1 добавляет ссылку на пакет NuGet, #:sdk Microsoft.NET.Sdk.Web превращает скрипт в веб-приложение, а #:property PublishAot=false задаёт любое свойство MSBuild. Файловые приложения появились в SDK .NET 10 и получили поддержку нескольких файлов в .NET 11. Эта статья охватывает всю поверхность возможностей, включая те части, которые удивляют: куда на самом деле попадает результат сборки, почему .csproj в рабочем каталоге молча перехватывает команду и каким директивам какая версия SDK нужна.

Всё, отмеченное ниже как “проверено”, выполнялось на SDK 10.0.201 (среда выполнения .NET 10.0.5) под Windows. .NET 11 на момент написания находится в стадии Preview 6, выход финальной версии ожидается в ноябре 2026 года, и возможности .NET 11 отмечены по версиям там, где они отличаются.

Шаги запуска файлового приложения на C#

  1. Сохраните код в файле с расширением .cs, используя инструкции верхнего уровня. Без class, без Main.
  2. Добавьте нужные директивы #: в начало файла: #:package для ссылок на пакеты NuGet, #:sdk для смены SDK, #:property для свойств MSBuild.
  3. Выполните dotnet run app.cs из каталога, в котором нет файла проекта.
  4. Передавайте аргументы приложению после разделителя --: dotnet run app.cs -- arg1 arg2.
  5. Когда скрипт перерастёт один файл, выполните dotnet project convert app.cs, чтобы сгенерировать эквивалентный .csproj.

Остальная часть статьи раскрывает каждый шаг и описывает поведение, которое обнаруживается только при столкновении с ним.

Минимальное, что запускается

Инструкции верхнего уровня служат точкой входа. args доступен в области видимости без всяких церемоний:

// app.cs -- verified on SDK 10.0.201
Console.WriteLine($"args: {string.Join(",", args)}");
Console.WriteLine($"tfm: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");
Console.WriteLine($"asm: {System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name}");
dotnet run app.cs -- one two
args: one,two
tfm: .NET 10.0.5
asm: app

Обратите внимание на имя сборки: app, взятое из имени файла. Это важно дальше, потому что каталог кеша сборки, идентификатор user secrets и имя упакованного инструмента выводятся именно из него.

Есть три эквивалентных способа вызова. dotnet run app.cs это обычная форма. dotnet run --file app.cs это явная форма, которую стоит применять в скриптах, поскольку она однозначна. И dotnet app.cs это сокращённая форма. Все три дали идентичный вывод при тестировании.

Можно также полностью обойтись без файла и передать исходный код через стандартный ввод, используя - в качестве аргумента:

echo 'Console.WriteLine("hello from stdin!");' | dotnet run -

Это выводит hello from stdin!. С - SDK не сканирует рабочий каталог в поисках профилей запуска или других файлов, хотя текущий каталог по-прежнему остаётся рабочим каталогом для сборки. Это действительно полезный запасной выход для скриптов оболочки, генерирующих C#.

Что SDK генерирует на самом деле

Понятнее всего разобраться в файловом приложении, посмотрев на проект, который SDK собирает за вас. dotnet project convert записывает его на диск. Для файла, не содержащего ничего кроме Console.WriteLine("plain");, сгенерированный проект выглядит так:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PublishAot>true</PublishAot>
    <PackAsTool>true</PackAsTool>
    <UserSecretsId>plain-c7cf82264bd176cef60e04b947ef58d1b133625432bf800179babd82aa79722e</UserSecretsId>
  </PropertyGroup>

</Project>

Четыре из этих значений по умолчанию стоит запомнить. ImplicitUsings и Nullable включены оба, поэтому Console разрешается без using System; и поэтому компилятор будет придираться к допустимости null даже в одноразовом скрипте. PublishAot по умолчанию равен true, так что dotnet publish app.cs создаёт нативный исполняемый файл, если вы не откажетесь явно. А PackAsTool по умолчанию равен true, поэтому dotnet pack app.cs даёт пакет, устанавливаемый через dotnet tool install, без дополнительной настройки. UserSecretsId представляет собой стабильный хеш полного пути к файлу, а значит, user secrets работают сразу, но перестают разрешаться, если вы переместите файл.

TargetFramework следует за установленным SDK. На SDK 10.0.201 это net10.0, а на SDK .NET 11 это net11.0. Зафиксируйте его явно через #:property TargetFramework=net10.0, если это для вас важно.

Пять директив

Директивы располагаются в начале файла с префиксом #:. Документированный набор: #:include, #:package, #:project, #:property и #:sdk.

#:package добавляет ссылку на пакет NuGet. Версия указывается после @:

// pkg.cs -- verified on SDK 10.0.201
#:package Humanizer@2.14.1

using Humanizer;
Console.WriteLine(TimeSpan.FromMinutes(90).Humanize(2));

Это выводит 1 hour, 30 minutes. Используйте @*, чтобы всегда брать последнюю версию. Полное отсутствие версии работает только тогда, когда файл Directory.Packages.props переводит вас на централизованное управление пакетами; иначе зафиксируйте версию или используйте @*.

#:sdk заменяет SDK MSBuild, и именно так из одного файла получается веб-приложение:

// web.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false

var app = WebApplication.Create();
app.MapGet("/", () => "ok");
app.Run();

#:sdk также принимает версию, как в #:sdk Aspire.AppHost.Sdk@13.0.2. Переход на Microsoft.NET.Sdk.Web меняет и стандартные шаблоны элементов: файлы конфигурации *.json в каталоге подхватываются автоматически.

#:property задаёт любое свойство MSBuild и не ограничивается литералами. Функции свойств MSBuild работают, поэтому можно читать переменные окружения со значением по умолчанию:

#:property LogLevel=$([MSBuild]::ValueOrDefault('$(LOG_LEVEL)', 'Information'))

#:project ссылается на настоящий файл проекта или на каталог, который его содержит, и служит мостом обратно к обычному решению:

#:project ../SharedLibrary/SharedLibrary.csproj

Многофайловые скрипты и версия SDK, которая их ограничивает

#:include втягивает другие файлы в ту же компиляцию. Сопоставление идёт по расширению: *.cs становится Compile, *.resx становится EmbeddedResource, *.json становится None, *.razor становится Content. Работают литеральные пути, шаблоны glob и свойства MSBuild:

#:include helpers.cs
#:include models/customer.cs
#:include shared/**/*.cs

Ключевое ограничение: включённые файлы .cs могут добавлять типы, методы и пространства имён, но не могут содержать инструкции верхнего уровня. Они есть только у файла точки входа.

#:include требует SDK .NET 10.0.300 либо .NET 11 Preview 3 и новее. На более старом SDK вы получите сухой отказ вместо полезного сообщения о версии. На 10.0.201 точный текст ошибки такой:

inc.cs(1): error: Unrecognized directive 'include'.

Если вы это видите, проверьте dotnet --version, прежде чем искать опечатку. Это тот же самый пробел, из-за которого #:include в .NET 10 стал заметной вехой, когда появился.

В .NET 11 Preview 5 добавился второй, иной способ охватить несколько файлов: директива #:ref, которая ссылается на другое файловое приложение как на библиотеку, а не сливает его в одну компиляцию, с поддержкой транзитивных ссылок (dotnet/sdk#53480). Та же предварительная версия убрала feature flag у #:include и #:exclude (dotnet/sdk#53775) и сделала обработку директив внутри включённых файлов транзитивной (dotnet/sdk#54012). Preview 6 расширил #:include на скомпилированные сборки, так что #:include ./libs/MyLibrary.dll теперь работает без флага.

Две поведенческие детали из этих заметок легко упустить. Дублирующиеся записи #:project и #:ref допускаются, что соответствует семантике элементов MSBuild. Дублирующиеся директивы других видов среди включённых файлов выдают диагностику, а не принимаются молча, хотя Preview 6 смягчил это для #:sdk, #:property и #:package, когда дублирующиеся значения совпадают. Учтите, что #:ref и #:exclude документированы в заметках о выпуске SDK, но пока не перечислены в статье MS Learn о файловых приложениях, поэтому для этих двух директив авторитетными считайте заметки о выпуске.

Аргументы, переменные окружения и куда попадает вывод

Аргументы после -- передаются вашему приложению, а не поглощаются CLI. Переменные окружения можно задать прямо в командной строке через -e:

dotnet run -e FOO=bar env.cs

Это выводит FOO=bar из Environment.GetEnvironmentVariable("FOO"). Заметки о выпуске .NET 11 перечисляют dotnet run -e как новую опцию SDK, но она уже работала на проверенном здесь SDK 10.0.201.

Результат сборки не оказывается рядом с вашим файлом. Он попадает в каталог с адресацией по содержимому внутри системной временной папки, в форме <temp>/dotnet/runfile/<appname>-<sha>/bin/<configuration>/. Проверенный путь под Windows:

C:\Users\...\AppData\Local\Temp\dotnet\runfile\app-82b0b938fb24db69...\bin\debug\app.dll

Перенаправьте его через --output у dotnet build или задайте значение по умолчанию в самом файле через #:property OutputPath=./output.

Кеш сборки определяет всю историю производительности

SDK кеширует результат сборки по ключу из содержимого исходного файла, конфигурации директив, версии SDK, а также наличия и содержимого неявных файлов сборки. Разница достаточно велика, чтобы изменить ощущение от инструмента. Измерено на SDK 10.0.201, та же машина, тот же тривиальный скрипт:

ВызовВремя по часам
Первый запуск после dotnet clean app.cs1.174 с
Запуск из кеша0.252 с

Четверть секунды попадает в диапазон, где файл .cs становится жизнеспособной заменой скрипту оболочки. Холодная сборка в него не укладывается.

Три особенности кеша вызывают путаницу. Изменения в неявных файлах сборки вроде Directory.Build.props не всегда вызывают пересборку. Перемещение файла в другой каталог не сбрасывает кеш. А использование шаблона glob в #:include в настоящее время полностью отключает кеширование сборки, так что строка shared/**/*.cs молча лишает вас быстрого пути.

Чтобы очистить кеш:

dotnet clean file-based-apps

Эта команда сканирует <temp>/dotnet/runfile и удаляет папки артефактов, не использовавшиеся минимум 30 дней; передайте --days, чтобы изменить порог. Для одного приложения dotnet clean app.cs с последующим dotnet build app.cs принудительно выполняет чистую пересборку.

Одно замечание о параллелизме: запуск нескольких экземпляров одного файлового приложения параллельно может завершиться ошибкой из-за конкуренции за файлы вывода сборки. Сначала соберите один раз, затем запускайте с --no-build:

dotnet build app.cs
dotnet run app.cs --no-build

Публикация, упаковка и запуск из оболочки

dotnet publish app.cs создаёт самодостаточный исполняемый файл в каталоге artifacts рядом с файлом .cs. Поскольку PublishAot по умолчанию равен true, это бинарный файл native AOT с быстрым стартом и без зависимости от среды выполнения, что именно то, чего вы хотите для распространяемого инструмента командной строки, и именно то, чего вы не хотите, если скрипт использует библиотеки с активной рефлексией. Отказаться можно через #:property PublishAot=false. Если не уверены, по какую сторону этой границы находится ваш код, компромиссы те же, что описаны в статье во что на самом деле обходится Native AOT, а разница между сборкой и публикацией тоже заслуживает точности, как описано в dotnet build против dotnet publish.

dotnet pack app.cs создаёт пакет NuGet, и, так как PackAsTool по умолчанию равен true, этот пакет устанавливается как глобальный инструмент. Путь от одного файла .cs до готового к распространению dotnet tool без файла проекта действительно короток.

В Unix-подобных системах файл можно сделать непосредственно исполняемым с помощью shebang:

#!/usr/bin/env -S dotnet --
#:package Spectre.Console@*

using Spectre.Console;

AnsiConsole.MarkupLine("[green]Hello, World![/]");
chmod +x file.cs
./file.cs

Флаг -S позволяет env разбить остаток строки на отдельные аргументы, а завершающий -- не даёт dotnet проглотить аргументы, похожие на его собственные (например, --help). Используйте окончания строк LF и не добавляйте BOM, иначе shebang не будет распознан. Если ваш env не поддерживает -S, используйте #!/usr/bin/env dotnet и примите риск конфликта аргументов.

Подвох, который отнимает больше всего времени

Если в текущем рабочем каталоге есть файл проекта, dotnet run app.cs запускает этот проект и передаёт ему app.cs как аргумент командной строки. Это намеренная обратная совместимость, и происходит она молча.

Проверено: из каталога, содержащего pkg.csproj, команда dotnet run ../env.cs выполнила pkg.csproj и вывела его результат, а не результат env.cs. Ничего не предупреждает. Используйте dotnet run --file ../env.cs, когда нужна уверенность, и держите файловые приложения вне дерева каталогов любого проекта:

MyProject/
  MyProject.csproj
  Program.cs
scripts/
  utility.cs

Смежная ловушка это неявные файлы сборки. Файловые приложения учитывают Directory.Build.props, Directory.Build.targets, Directory.Packages.props, nuget.config и global.json из текущего и родительских каталогов. Файл Directory.Build.props в корне репозитория, задающий TreatWarningsAsErrors, будет применён и к вашему одноразовому скрипту. Отведите скриптам собственный каталог с собственным Directory.Build.props, когда нужна изоляция.

Ещё две мелочи. Профили запуска живут в плоском файле app.run.json рядом с app.cs, а не в Properties/launchSettings.json; если существуют оба, побеждает традиционное расположение, и CLI пишет предупреждение. А dotnet user-secrets требует опции --file, чтобы нацелиться на скрипт: dotnet user-secrets set "ApiKey" "value" --file app.cs.

Когда скрипт перестаёт быть скриптом

dotnet project convert app.cs это путь выпуска во взрослую жизнь. Команда копирует файл .cs и записывает .csproj с эквивалентными SDK, свойствами и ссылками на пакеты, выведенными из ваших директив #:; оба файла помещаются в новый каталог с именем приложения. Исходный файл остаётся нетронутым, поэтому преобразование не разрушительно, и вы можете сравнить результат до того, как на него положиться.

Запуск команды на приведённом выше примере с Humanizer дал в точности ожидаемый перевод: #:package Humanizer@2.14.1 превратился в PackageReference, а #:property PublishAot=false стал свойством:

  <ItemGroup>
    <PackageReference Include="Humanizer" Version="2.14.1" />
  </ItemGroup>

Эта плавность и есть настоящий замысел возможности. Начните с одного файла. Вынесите вспомогательный код через #:include. Повысьте помощника до библиотеки через #:ref. Укажите на настоящий проект через #:project. Преобразуйте, когда церемония MSBuild наконец окупится. Каждый шаг занимает одну строку, и ни один не заставляет вас отказаться от dotnet run. Что касается внутреннего цикла разработки, когда проект у вас уже есть, следующее, что стоит знать, это различие между dotnet watch и dotnet run.

Связанные статьи

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад