Выпущенная вчера юбилейная версия .NET 10 содержит достаточно много нововведений: выделение стековой памяти для некоторых массивов/ссылочных полей структур/делегатов; новый WebSocketStream, предоставляющий Stream-based API для работы с вебсокетами; сравнение строк на основе содержащихся в них чисел; и множество других.

Однако, приз зрительских симпатий уходит появлению в .NET SDK 10 так называемых File-Based App, т.е. приложений на основе файлов (однофайловых приложений?) или проще выражаясь - скриптов (со звёздочкой*).

Зачем это?

C# давно склоняется к минимализму, например те же top-level statements, упрощающие входную точку программы, или свойство <ImplicitUsings>, позволяющее обращаться к наиболее распространённым в C# пространствам имён без их явного указания.
Но с точки зрения файловой структуры проекта - у нас всё еще есть .csproj файл, содержащий конфигурацию MSBuild для сборки приложения. Вдобавок, даже при запуске проекта в режиме отладки, мы получим папку obj, где содержатся тестовые и релизные сборки и их метаданные.

В большинстве случаев это не является какой-либо проблемой: в любом project-based приложении помимо одного или нескольких .csproj будут и sln файлы, конфигурации отдельных модулей, ресурсы и т.д.. Но может возникнуть ситуация, например когда нам нужно быстро набросать какую-то логику и перекинуть/запустить её на другом хосте (где уже установлен .NET), или когда мы хотим использовать C# под какие то хозяйственные нужды в другой языковой среде, протестить открытую для себя фичу языка или автоматизировать рутинный процесс - во всех этих примерах хочется минимализировать количество создаваемых и передаваемых файлов, в чём нам и может помочь file-based приложение.

Как это?

Установим .NET 10 SDK и откроем пустую папку в Visual Studio Code (думаю наше сообщество хотя бы здесь прийдёт к консенсусу, что поднимать студию ради 1 файла занятие странное).
Создаём SampleApp.cs файл, и, если у нас установлены VSC аддоны C# и C# Dev Kit, то мы сразу можем наблюдать работу автодополнения Intellisense - хотя в папке нет ни .csproj, ни тем более .sln файлов.

К однофайловому приложению есть только одно требование - один файл исходного кода на проект, в рамках которого вы можете пользоваться всеми возможностями языка и библиотек SDK, в том числе объявлять классы и интерфейсы. Создание еще одного .cs файла в той же директории расценивается как новое однофайловое приложение, никак не связанное с предыдущим (если только мы не укажем ссылку явно, но об этом позже). При этом в любой момент вы можете ввести в проект .csproj и sln, если ваш проект разросся и превратить его в csproj-based.

В качестве примера рассмотрим простейший скрипт, забирающий JSON данные с фейкового веб-сервиса и фильтрующий их по свойству UserId:

#!/usr/bin/dotnet run // На эти 4 строки пока что не обращаем внимания
#:package Newtonsoft.Json@13.0.4
#:sdk Microsoft.NET.Sdk
#:property PublishAot=false

using Newtonsoft.Json;

HttpClient client = new() { BaseAddress = new("http://jsonplaceholder.typicode.com") };

HttpResponseMessage response = await client.GetAsync("/todos/");
string json = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();

List<Todo>? todos = JsonConvert.DeserializeObject<List<Todo>>(json);
foreach (Todo todo in todos?.Where(t => t.UserId == 5) ?? throw new Exception())
{
    Console.WriteLine(todo);
}

Console.WriteLine("Press enter to exit.");
Console.ReadLine();


public record class Todo(
    int? UserId = null,
    int? Id = null,
    string? Title = null,
    bool? Completed = null);

Для десериализации JSON данных мы используем пакет Newtonsoft.Json, то есть внешнюю зависимость, при этом .csproj файл, где её можно было бы указать, отсутствует.
На этот случай вместо свойств в .csproj, однофайловые приложения используют директивы, размещаемые в начале файла. Например, для ссылки на пакет - директива #:package name@version, где name и version вы заменяете на соответствующие для желаемого пакета значения, что и указано в начале блока кода (#:package Newtonsoft.Json@13.0.4)
К слову, у всех пакетов на https://www.nuget.org появилась новая вкладка "File-based Apps", откуда вы можете скопировать текст директивы интересующего пакета. На уровне редактора кода (при использовании VSC) информация о зависимостях и подтягивание указанных осуществляется при сохранении файла также, как это происходило при сохранении .csproj.

Изначально я планировал использовать System.Net.Http.Json

для одновременного HTTP запроса и десериализации JSON, однако при попытке указать ссылку на данный пакет - получал новое предупреждение менеджера NuGet, утверждающее, что пакет уже включён в состав .NET и не будет вырезан при сборке, а значит нет смысла его указывать явно. Немного погуглив наткнулся на описание этой фичи, которая включена по умолчанию с .NET 10.

Аналогичным образом указываются и ссылки на проекты-зависимости, используя директиву #:project ../ClassLib/ClassLib.csproj с указанием пути до нужного проекта.
Для указания целевого SDK используется директива #:sdk name@version, а для всех прочих свойств проекта директива #:property key=value.

Если вы пользуетесь Unix-подобной системой, то подобные директивы в начале файла должны были вам напомнить шебанг (shebang) строку - текст в начале файла, указывающий на программу-интерпретатор, которая будет запущена для обработки файла (в нашем случае - dotnet CLI).
И эта строка поддерживается в однофайловых приложениях, поэтому добавляем к директивам в начале #!/usr/bin/dotnet run (при условии, что dotnet у вас расположен по этому пути, как у меня на Debian 12) для того, чтобы в дальнейшем запускать наш скрипт без dotnet run.

Наконец, запускаем наше приложение, указывая путь к .cs файлу в качестве аргумента команде dotnet run

dotnet run SampleApp.cs

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

chmod +x SampleApp.cs
./SampleApp.cs

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

Что это?

Подобным образом файл указывается и для прочих dotnet CLI команд вроде dotnet restore SampleApp.cs/dotnet build SampleApp.cs/dotnet publish SampleApp.cs.
На последних двух я бы хотел заострить внимание, во-первых при выполнении build мы можем наблюдать сообщение об успешном билде, при этом папка проекта всё еще содержит один .cs файл без появления /obj/, а в консоли указан странный путь до билда вида C:\Users\%username%\AppData\Local\Temp\dotnet\runfile\SampleApp-aedbf4d53f4187535aa4835b\bin\debug\SampleApp.dll.

Содержимое каталога
Содержимое каталога

И вот тут я хочу вернуться к звёздочке, оставленной в начале статьи возле слова "скриптов".
C# не стал скриптовым языком в узком смысле, на деле вы всё еще пишете обычное C# приложение, где .csproj был заменён на передаваемые MSBuild директивы в начале файла, а промежуточный билд и все остальные файлы перемещены в /Temp/ директории (в том числе для ускорения всех последующих сборок).
Здесь же есть небольшая ложка дёгтя - для запуска такого "скрипта" на другой платформе, там должен быть установлен .NET SDK 10, поскольку это всё еще файл исходного кода, который должен быть скомпилирован в промежуточный IL-код, который JIT-ится уже в машинный код.
Да, это в какой то степени похоже на интерпретацию условного Python, и для меня это хорошая замена как Python, так и возможно даже bash, но не нужно думать об этом как о "настоящих" скриптах, хотя лично мне даже нравится подобная реализация.

Последний оставшийся вопрос - а что с публикацией такого проекта, т.е. итоговой сборкой, она вообще есть? Если вы копировали код из моей статьи, то у вас с самого начала висит предупреждение IL2026, которое указывает, что код JSON-сериализатора динамически создаёт типы и использует рефлексию, и что последующая AOT компиляция и сопутствующий ей тримминг могут повлиять на работу сериализатора. И действительно, как говорит нам документация - однофайловые приложения по-умолчанию нацелены паблишиться в Ahead Of Time режиме, т.е. итоговая (не /Temp/ промежуточная) сборка представляет собой машинный код целевой платформы.

И это довольно логично, учитывая, что во-первых в большинстве случаев вы не собираетесь билдить и деплоить скрипт, ведь весь смысл file-based приложений это функциональные одиночные .cs файлы, представляющие собой какие-то полезные или тестовые консольные утилиты, которые можно быстро прогнать через dotnet run везде, где установлен SDK 10-й версии. А если такой возможности нет - то однофайловое приложение теряет весь смысл, и единственное, что можно с ним сделать ещё - превратить в одиночный исполняемый файл под целевую платформу, который также легко передать и запустить.
Впрочем, AOT режим можно отключить, разместив в начале файла директиву-свойство #:property PublishAot=false.
В результате publish в проект добавится директория artifacts, где и будет расположен билд.

Вывод

File-based Apps призваны быть инструментом разработчика. Они не изменяют принципам языка и платформы, и на самом деле мало значат на фоне других изменений .NET 10. Однако, они хорошо подходят для экспериментов и позволяют находить новые применения C# (а ведь часть людей всё еще думает, что мы WinForms шлёпаем).

Обложка статьи сгенерирована с использованием ChatGPT.

Комментарии (0)