Source Generators в действии

C# 9 дал долгожданную возможность кодогенерации, интегрированную с компилятором. Тем, кто мечтал избавиться от тысяч строк шаблонного кода или попробовать метапрограммирование, стало проще это сделать.

Ранее Андрей Дятлов TessenR выступил на конференции DotNext с докладом «Source Generators в действии». А теперь, пока мы готовим следующий DotNext, сделали для Хабра текстовую расшифровку его доклада.

Что вообще такое эти Source Generators? Как их использовать? Как предоставить пользователю вашего генератора необходимую гибкость конфигурации и понятные сообщения о возникающих проблемах? Как разобраться, когда что-то пошло не так?

Ответы на все эти и другие вопросы — в тексте.

Оглавление

Помимо того, что в последнее время я работал над поддержкой Source Generators, за свою карьеру я также успел поработать и с другими технологиями метапрограммирования: IL Weaving, Fody, PostSharp, ILGenerator, CodeDOM, то есть практически со всем, что представлено в мире .NET. Сегодня я расскажу о том, какие преимущества и недостатки есть у Source Generators. Еще покажу, как вообще работают Source Generators, и даже напишу один, сравню их со старыми технологиями. Расскажу о некоторых проблемах, которые могут встретиться в процессе работы с ними, и дам несколько советов о том, как сделать работу с генераторами менее болезненной и не натыкаться на типичные проблемы.

Но сначала давайте разберемся, для чего нам вообще нужны генераторы.

Какие задачи должны решить генераторы?

В первую очередь — создание шаблонного кода. Если у вас, например, есть методы Equals, GetHashCode, операторы равенства и неравенства, скажем, обеспечивающие структурное сравнение данных, писать их вручную для каждого типа очень неудобно. Было бы неплохо отдать эту задачу генератору, который напишет этот код за нас. В том числе можно, например, добавить всем типам в проекте осмысленный метод ToString, создавать типы по схеме, добавить mapping, например, как в AutoMapper, материализацию объектов баз данных.

Во вторых, благодаря тому, что мы теперь легко и просто можем создавать шаблонный код, открываются некоторые интересные возможности по оптимизации наших приложений. Например там, где мы раньше использовали рефлексию просто для того, чтобы не писать руками код. Скажем, регистрация типов для dependency injection, методы сериализации.

Перейдем к примеру того, чем нам могут быть полезны генераторы:

Я думаю, всем известен интерфейс INotifyPropertyChanged. В нём есть всего одно событие, сообщающее о том, что одно из свойств объекта изменилось и, например, его требуется обновить на пользовательском интерфейсе.

public interface INotifyPropertyChanged {     event PropertyChangedEventHandler PropertyChanged; }

Его очень легко реализовать, например, если у меня есть класс, представляющий модель машины, у которого есть свойство скорости в километрах в час, мне надо просто добавить одну строчку, реализующую событие из интерфейса…

public class CarModel : INotifyPropertyChanged {     public double SpeedKmPerHour { get; set; }     public event PropertyChangedEventHandler? PropertyChanged; }

…но потом оказывается, что его нужно постоянно вызывать. Автосвойство этого не делает, поэтому мне придется переписать его на свойство с отдельным полем для хранения данных и в сеттере этого свойства вызывать событие. Это уже довольно много кода.

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

public class CarModel : INotifyPropertyChanged {     private double SpeedKmPerHourBackingField;     public double SpeedKmPerHour     }         get => SpeedKmPerHourBackingField;         set         {             SpeedKmPerHourBackingField = value;             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SpeedKmPerHour)));         }     }     public event PropertyChangedEventHandler? PropertyChanged; }

Это отличная задача для генераторов кода, потому что все свойства создаются по одному и тому же шаблону. В геттере я просто возвращаю поле, в сеттере записываю его и вызываю событие с именем свойства. С генераторами можно оставить в основном коде только поле для данных, а автосвойство и сам ивент может создать генератор:

Во-первых, с этим кодом будет проще работать — теперь тип данных и название поля упомянуты в коде всего один раз, и при рефакторинге не требуется синхронизировать их в нескольких местах.

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

Что такое Source Generators?

Source Generators — новая технология метапрограммирования от Microsoft. Вы пишете тип, который будет частью процесса компиляции, у него будет доступ к модели вашего кода, и результатом его работы будут новые C#-файлы.

Roslyn начинает компилировать ваш проект, доходит до шага генерации кода, передает управление в ваш класc, который может посмотреть, что уже есть в проекте, поисследовать типы, создать на их основе новые файлы, добавить их, и компиляция продолжится так, как будто эти типы всегда там были, словно если бы вы написали этот код вручную.

Покажу это на примере небольшого демо.

Примечание: в исходном докладе использован пример с сайта sourcegen.dev, который более недоступен.

Можно посмотреть примеры генераторов от Microsoft, выложенные на гитхабе.

Начнем с примера реализации INotifyPropertyChanged при помощи генератора.

Он работает со следующим исходным кодом в «целевом» проекте.

В нем есть partial-класс ExampleViewModel, в котором есть несколько полей:

// The view model we'd like to augment public partial class ExampleViewModel {         [AutoNotify]         private string _text = "private field text";          [AutoNotify(PropertyName = "Count")]         private int _amount = 5; }

Есть атрибут AutoNotify и тест, который подписывается на событие PropertyChanged этой модели, меняет несколько свойств и записывает информацию о них в консоль.

Как можно заметить, в этой программе мы подписываемся на событие и работаем со свойствами, которые в исходном коде нигде не написаны.

Если запустить эту программу, она выведет:

Text = private field text Count = 5 Property Text was changed Property Count was changed

Это достигается за счет файлов, добавленных при помощи генератора, в данном случае он добавляет два файла:

  • Декларацию атрибута AutoNotify

using System; namespace AutoNotify {     [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]     [System.Diagnostics.Conditional("AutoNotifyGenerator_DEBUG")]     sealed class AutoNotifyAttribute : Attribute     {             public AutoNotifyAttribute()             {             }             public string PropertyName { get; set; }     } }

  • partial-декларацию использованного в основной программе типа ExampleViewModel, которая как раз реализует событие PropertyChanged и добавляет свойства, которые будут его вызывать:

namespace GeneratedDemo {   public partial class ExampleViewModel : System.ComponentModel.INotifyPropertyChanged   {     public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;     public string Text     {       get       {         return this._text;       }        set       {         this._text = value;         this.PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Text)));        }     }      public int Count     {       get       {         return this._amount;       }         set        {          this._amount = value;          this.PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Count)));        }     }   } }

Можно также посмотреть на сам исходный текст генератора.

В нем есть исходный код для атрибута AutoNotify и метод Execute, который может посмотреть, что есть в проекте, найти объявленные в нем типы, посмотреть на их поля, посмотреть, какие из них отмечены атрибутом AutoNotify, и на основе этих полей создать partial-часть, реализующую NotifyPropertyChanged.

В этом примере может быть сложно сходу разобраться, особенно разработчикам, прежде не работавшим с код-моделью компилятора, — много кода, много неизвестных типов, к которым требуется явное приведение, и незнакомого API. Может показаться, что для работы с генераторами требуется иметь опыт работы с API компилятора или приложить серьезные усилия, чтобы разобраться в нём с нуля.

Можно показать более простой пример, где будет понятно, с чего можно начать и что действительно нужно знать для написания своего первого генератора. На самом деле, чтобы начать писать свои генераторы, достаточно знать буквально 3 метода из API компилятора.

У меня есть проект с view-моделью CarModel, которую я показывал ранее. В ней есть три поля с данными, метод ускорения машины и 50 строк бойлерплейта, реализующего NotifyPropertyChanged.

Сейчас этот тип выглядит вот так:

public class CarModel : INotifyPropertyChanged {     private double SpeedKmPerHourBackingField;     private int NumberOfDoorsBackingField;     private string ModelBackingField = "";      public void SpeedUp() => SpeedKmPerHour *= 1.1;      public double SpeedKmPerHour     {         get => SpeedKmPerHourBackingField;         set         {                 SpeedKmPerHourBackingField = value;                 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SpeedKmPerHour)));         }     }      public int NumberOfDoors     {         get => NumberOfDoorsBackingField;         set         {                 NumberOfDoorsBackingField = value;                 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NumberOfDoors)));         }     }      public string Model     {         get => ModelBackingField;         set         {                 ModelBackingField = value;                 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Model)));         }     }      public event PropertyChangedEventHandler? PropertyChanged;   } 

В самой программе я подписываюсь на событие PropertyChanged этой модели, меняю какие-то свойства, получаю от них нотификации, то есть просто тестирую, что всё это работает.

Исходную версию проекта до применения генератора можно посмотреть на гитхабе.

Далее я покажу, как можно избавиться от этого шаблонного кода с помощью генератора.

Для того чтобы добавить генератор, мне потребуется новый проект — это будет обычная .NET standard-библиотека и несколько NuGet-пакетов, чтобы работать с Roslyn и теми данными о проекте, которые мне предоставит компилятор.

Все проекты с генераторами должны быть под .NET standard 2.0, но версию языка C# в них можно использовать любую.

<Project Sdk="Microsoft.NET.Sdk">    <PropertyGroup>     <TargetFramework>netstandard2.0</TargetFramework>     <LangVersion>preview</LangVersion>   </PropertyGroup>    <ItemGroup>     <PackageReference Include="IndexRange" Version="1.0.0" />     <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0-4.20464.1" PrivateAssets="all" />     <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.0.0" PrivateAssets="all" />   </ItemGroup> </Project>

Дальше мне нужно подключить этот генератор к проекту с основной программой. Нужно указать, что это не просто ссылка на сборку, типы из которой я смогу использовать, а именно генератор, который может анализировать и дополнять код проекта. Для этого мне нужно указать, что эта ссылка имеет тип OutputItemType="Analyzer". Также поскольку генератор нужен только в момент компиляции, можно убрать зависимость от сборки с генератором в скомпилированной программе:

<ProjectReference Include="..\NotifyPropertyChangedGenerator\NotifyPropertyChangedGenerator.csproj"                 OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>

Теперь пришло время написать сам генератор — он должен реализовать интерфейс ISourceGenerator и быть отмечен атрибутом [Generator]. В самом интерфейсе всего два метода: Initialize и Execute. Initialize для этого генератора не требуется, и я объясню его функцию в следующем демо. А в методе Execute есть контекст, в котором есть свойство Compilation, и это как раз вся информация, которую собрал о целевом проекте Roslyn, то есть какие типы, файлы там есть, можно на них посмотреть.

  [Generator]   public class NotifyPropertyChangedGenerator : ISourceGenerator   {     public void Initialize(GeneratorInitializationContext context)     {     }      public void Execute(GeneratorExecutionContext context)     {         var compilation = context.Compilation;     }   }

Первый метод, который нужно знать — GetTypeByMetadataName, который позволяет получить тип по его имени. Меня интересует System.ComponentModel.INotifyPropertyChanged интерфейс, который я и буду реализовывать в своих типах.

var compilation = context.Compilation;     var notifyInterface = compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged");

Дальше можно посмотреть на синтаксические деревья, которые есть в этом объекте компиляции. То есть фактически те файлы, которые есть в проекте, для которого запущен генератор.

Второй метод, который нужно знать, — compilation.GetSemanticModel(syntaxTree). Он вернет семантическую модель, которая позволяет нам перейти от синтаксиса, то есть фактически текста, написанного в файле — ключевого слова class и какого-то имени для него, к семантике — то есть информации о том, что это за тип, какие у него есть атрибуты, какие интерфейсы он реализует, какие есть члены типа.

Дальше моему генератору нужно обойти все файлы в программе. Для этого я могу использовать метод GetRoot, метод DescendantNodesAndSelf. Меня будут интересовать только декларации классов, в Roslyn они будут представлены элементом типа ClassDeclarationSyntax.

Как правило, языковые конструкции названы относительно понятно, и можно просто поискать подходящий тип среди наследников типа, который вам вернул Roslyn API, в данном случае метод DescendantNodesAndSelf возвращает коллекцию SyntaxNode. Можно посмотреть список его наследников и легко найти типы для каких-то конкретных интересующих вас элементов, например, ClassDeclarationSyntax, InterfaceDeclarationSynttax, MethodDeclarationSyntax. Чаще всего примерное название можно просто угадать и найти элемент в поиске.

  foreach (var syntaxTree in compilation.SyntaxTrees)   {     var semanticModel = compilation.GetSemanticModel(syntaxTree);     syntaxTree.GetRoot().DescendantNodesAndSelf()       .OfType<ClassDeclarationSyntax>()      }   } }

Есть и второй способ, если не хочется возиться с поиском нужного типа или угадать его название не получается — можно зайти на сайт SharpLab и выбрать режим отображения синтаксического дерева. Вы сможете просто набрать программу с нужными вам элементами и посмотреть, какие синтаксические элементы будут для него созданы. Например, если вы не знаете, как будет называться синтаксический элемент для вызова метода, можно ввести туда Console.WriteLine(); и узнать, что это будет ExpressionStatement, в котором будет находиться InvocationExpression.

Дальше, когда я получил типы, объявленные в проекте, для которого будет запущен генератор, мне нужно перейти как раз к семантической модели для моей декларации. Для этого нужно передать декларацию типа в метод semanticModel.GetDeclaredSymbol().

Три метода, которые я уже использовал — Compilation.GetTypeByMetadataName, Completion.GetSemanticModel и SemanticModel.GetDeclaredSymbol — это как раз те методы, которые вам, скорее всего, потребуются в любом генераторе и которые не так просто найти самостоятельно. Практически всё остальное можно быстро найти, посмотрев доступные методы в автодополнении или поискав нужный вам тип среди наследников интерфейса, который вам вернул API компилятора.

Например, я вижу, что вызов semanticModel.GetDeclaredSymbol вернул мне ISymbol, но я знаю, что буду работать только с декларациями типов и легко могу найти в наследниках ISymbol нужный мне тип ITypeSymbol, представляющий семантическую информацию о типе, например, классе или интерфейсе, объявленном в целевом проекте.

При помощи свойств этого объекта можно посмотреть, какие интерфейсы он реализует, и отфильтровать только типы, реализующие интерфейс INotifyPropertyChanged. Все такие типы в своем генераторе я сложу в HashSet, это те типы, которые мой генератор должен дополнить реализацией интерфейса INotifyPropertyChanged.

foreach (var syntaxTree in compilation.SyntaxTrees) {         var semanticModel = compilation.GetSemanticModel(syntaxTree);         var immutableHashSet = syntaxTree.GetRoot()         .DescendantNodesAndSelf()             .OfType<ClassDeclarationSyntax>()             .Select(x => semanticModel.GetDeclaredSymbol(x))         .OfType<ITypeSymbol>()             .Where(x => x.Interfaces.Contains(notifyInterface))         .ToImmutableHashSet(); }

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

Для этого мне понадобится объявить еще одну декларацию этого типа с таким же неймспейсом и именем, объявить в ней событие PropertyChanged и добавить все нужные мне свойства.

private string GeneratePropertyChanged(ITypeSymbol typeSymbol)     {         return $@" using System.ComponentModel; namespace {typeSymbol.ContainingNamespace} {{   partial class {typeSymbol.Name}   {{     {GenerateProperties(typeSymbol)}     public event PropertyChangedEventHandler? PropertyChanged;   }} }}";     }

Дальше я буду создавать свойства. Я не знаю, сколько их будет, и мне потребуется StringBuilder. Я могу посмотреть, какие в моем типе есть члены, при помощи метода GetMembers и отфильтровать только поля по типу IFieldSymbol. И нужный метод и интерфейсы для конкретных членов типа легко можно найти в автодополнении просто по имени.

Так как я делаю максимально простой генератор, я буду просто обрабатывать те поля, у которых имя заканчивается на суффикс BackingField. То есть вместо атрибутов будет конвенция наименования. Если тип реализует NotifyPropertyChanged, то для всех его полей с суффиксом BackingField я буду создавать свойства с нотификациями.

Дальше мне просто потребуется создать шаблон для свойства, вызывающего событие PropertyChanged в сеттере, и подставить в него нужные данные — тип свойства, совпадающий с типом поля для хранения данных, имя свойства, совпадающее с именем поля до суффикса BackingField и т. д.:

private static string GenerateProperties(ITypeSymbol typeSymbol) {   var sb = new StringBuilder();   var suffix = "BackingField";    foreach (var fieldSymbol in typeSymbol.GetMembers().OfType<IFieldSymbol>()     .Where(x=>x.Name.EndsWith(suffix)))   {     var propertyName = fieldSymbol.Name[..^suffix.Length];     sb.AppendLine($@"     public {fieldSymbol.Type} {propertyName}     {{     get => {fieldSymbol.Name};     set     {{     {fieldSymbol.Name} = value;     PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof({propertyName})));     }}     }}");   }    return sb.ToString(); }

Всё, что мне осталось, — просто добавить этот сгенерированный файл в компиляцию. Для этого есть метод context.AddSource, которому нужно передать имя нового файла и сам исходный код. Я создам файл с таким же именем, как тип, который я расширяю с суффиксом Notify.cs.

foreach (var typeSymbol in immutableHashSet) {   var source = GeneratePropertyChanged(typeSymbol);   context.AddSource($"{typeSymbol.Name}.Notify.cs", source); }

Теперь этот генератор будет просто работать с моим проектом, а я у себя могу стереть весь бойлерплейт, который реализует NotifyPropertyChanged и сделать этот тип partial, чтобы часть созданная при помощи генератора была добавлена в этот же тип. Теперь CarModel тип в моем проекте выглядит вот так:

public partial class CarModel : INotifyPropertyChanged {     private double SpeedKmPerHourBackingField;     private int NumberOfDoorsBackingField;     private string ModelBackingField = "";      public void SpeedUp() => SpeedKmPerHour *= 1.1; }

Теперь мне осталось только перекомпилировать программу, и всё заработает точно так же, как раньше — у меня будет подписка на PropertyChangedEvent, точно так же будут приходить сообщения о том, что свойства модели изменились, всё будет работать точно так же, как раньше, но мне больше не потребуется писать это всё руками.

Соответственно, если мне понадобится добавить новое свойство в этот тип, я теперь могу сделать это в одну строчку, если мне потребуется изменить имя или тип какого-то из существующих свойств, достаточно будет сделать это в одном месте.

О чем нужно помнить, когда вы написали свой первый генератор: Visual Studio и Roslyn увидят его только после того, как вы перекомпилируете проект, закроете IDE с ним и и откроете проект заново. То же самое относится и к случаю, когда вы скачали, например, это демо с гитхаба и открыли в IDE — при первом запуске вы увидите много ошибок, вызванных отсутствием в IDE информации о сгенерированном коде.

namespace NotifyPropertyChangedLiveDemo {   static class Program   {     Static void Main()     {       var carModel = new CarModel       {         Model = "MyCar",         NumberOfDoors = 4,         SpeedKmPerHour = 200       };        Console.Write("Got ");       PrintCarDetails();        carModel.PropertyChanged += OnPropertyChanged();        Console.WriteLine();       Console.WriteLine("Updating to racing model...");       carModel.Model = "Racing " + carModel.Model;       carModel.NumberOfDoors = 2;        while (carModel.SpeedKmPerHour <250)

Генератор загружается в память один раз в момент открытия проекта. Таким образом, IDE не увидит никаких изменений — добавления или редактирования генераторов, которые были сделаны в момент, когда проект был открыт. Предполагается, что генераторы будут один раз загружены с NuGet или написаны, затем часто меняться не будут. Необходимость переоткрытия проекта относится только к изменению кода самого генератора, если вы изменили что-то в вашем проекте, то генератор сразу же сможет создать новый код с учетом этого изменения.

Если вам нужно часто редактировать генератор и получать обратную связь в IDE без переоткрытия проекта, вы можете воспользоваться Rider, но думаю, что рано или поздно эта фича появится и в Visual Studio.

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

Когда мы слышим о метапрограммировании, помимо вопросов о том, как сделать, чтобы что-то заработало, возникает много вопросов о том, как потом его читать и поддерживать. Можно ли посмотреть на сгенерированный код, подебажить его, протестировать процесс генерации?

Генераторы были созданы в том числе для удобства поддержки кода, созданного ими, поэтому вы можете легко работать со сгенерированным кодом. Все файлы, созданные генераторами, видны в проектной модели, к типам и методам из сгенерированного кода можно снавигироваться, и в любой точке сгенерированного файла можно поставить брейкпоинт, который сработает при отладке приложения — всё как если бы вы написали этот код вручную!

Кроме того, вы можете с помощью API компилятора запустить генератор в тесте, для того исходного кода, который вы ему предоставите, и проверить, что результат его работы соответствует вашим ожиданиям. Пример юнит-теста запускающего генератор можно посмотреть на гитхабе.

Весь код примера с генератором можно найти на гитхабе.

Fody

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

Но на самом деле, здесь нет ничего совершенно нового — уже 5–7 лет назад я пользовался Fody и IL Weaving для того же самого INotifyPropertyChanged, и еще тогда с помощью этих тулов фактически создавал точно такую же реализацию интерфейса, как сейчас с помощью генератора. Отличие в том, что в Fody это делается не при помощи кода, а при помощи манипуляций с байт-кодом.

Покажу, как это выглядит.

public class Person : INotifyPropertyChanged {     public event PropertyChangedEventHandler PropertyChanged;      public string GivenNames { get; set; }     public string FamilyName { get; set; }     public string FullName => $"{GivenNames} {FamilyName}"; }

Есть класс Person с NotifyPropertyChanged-интерфейсом, в котором есть сам ивент и несколько автосвойств. После того, как этот проект заканчивает компиляцию, модифицируется байт-код, и свойства становятся вычислимыми свойствами, у которых в сеттере есть какая-то логика, например, сравнение нового значения с тем, которое сейчас хранится в поле, и вызов события при изменении значения.

Возникает вопрос: а этот способ Microsoft чем-то лучше, или мы получили то же самое, но реализованное немного по-другому? Способов добавить что-то в компиляцию, на самом деле, было много. Это и IL Weaving в лице Fody и PostSharp, T4, ILGenerator, Codedom. Давайте разберемся, чем Source Generators лучше или хуже. Я сравню их с IL Weaving, как с наиболее популярным способом сделать то же самое, добавить что-то в проект. Все плюсы и минусы проистекают здесь из разницы в подходах.

Генераторы только добавляют файлы, а IL weaving переписывает байткод. Это накладывает разные ограничения на код, который пользуется этими технологиями. Покажу на примере.

C Fody мне приходится писать ивент PropertyChanged, который в своем демо я тоже перенес в генератор. Чтобы Fody начал работать, мне требуется готовая, уже скомпилированная сборка, ведь чтобы начать модифицировать IL, надо, чтобы компилятор его сначала создал. Если код проекта не скомпилируется, то до Fody дело просто не дойдет. Таким образом, при помощи IL Weaving будет гораздо труднее добавить методы, от которых зависит компиляция. Например, ивент PropertyChanged или методы, которые реализуют интерфейсы. С другой стороны, в Fody я могу просто написать автосвойство, и всё это магически будет работать.

С Source Generators, как вы помните, я могу только добавить новый файл. Это значит, что если у меня где-то есть автосвойство, то я не смогу подменить его реализацию — автосвойство не будет вызывать нужное мне событие. То есть этот подход для Source Generators не подойдет.

Вместо этого мы можем полагаться:

  • на конвенции, как в моём демо со схемой имен для полей;
  • на атрибуты, и указать в них имя свойства, дополнительные нотификации;
  • на конфигурационные файлы.

С атрибутами это может выглядеть следующим образом.

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

К сожалению, после компиляции проекта с генератором в коде в любом случае будет и свойство, и поле.

С одной стороны это хорошо, потому что у нас есть код, который можно подебажить.

С другой стороны, это не очень удобно, потому что в автодополнении у меня будет видно оба члена типа: и поле, и свойство. Это означает, что с генераторами я могу случайно напрямую использовать поле вместо свойства и потерять нотификацию об изменении.

Можно с этим бороться при помощи, например, атрибута [Obsolete] и #pragma warning disable в сгенерированном коде, но это тоже будет не очень удобно.

Возвращаясь к плюсам и минусам, генераторы только добавляют файлы, и это значит, что строчка, которую вы сгенерируете, еще пройдет через компилятор. Даже если вы забудете точку с запятой, компилятор вам об этом сообщит. С IL Weaving мы переписываем байткод, если где-то оплошали, то уже при запуске программы получим InvalidProgramException, и будет очень трудно впоследствии разобраться, что же именно к нему привело.

С генераторами у нас есть код, который можно посмотреть и подебажить, с IL Weaving у нас кода для дебага просто нет, т. к. все модификации были уже непосредственно в байткоде.

Генераторы можно легко протестировать, просто добавив тест на то, какой код генератор создает для конкретного кода на входе генератора. С ILWeaving вам скорее всего потребуется тестировать поведение уже обработанной сборки реального проекта.

И гораздо ниже порог вхождения. В генераторе мы создаем код в виде строчки текста и это то, что мы с вами и так делаем каждый день. Это гораздо проще, чем пытаться корректно модифицировать байткод.

Кроме того, в случае с генераторами код не обязан компилироваться до того, как будет запущен генератор, таким образом решая возможную проблему циклической зависимости, которая в том или ином виде может возникать во многих технологиях метапрограммирования — когда для того чтобы запустить генератор, нужно скомпилировать проект, а для его компиляции требуются методы, которые добавит генератор.

Есть два небольших минуса: для расширения типа генератором требуется partial, то есть вы должны заранее подумать о том, какой класс вы хотите расширить. Кроме того, они не могут поменять существующий код, в то время как при помощи IL Weaving вы можете удалять или менять любой код, например, в целях оптимизации.

Так IL Weaving мертв?

В Fody и PostSharp есть огромное количество модификаций, которые легко заменить при помощи генераторов: PropertyChanged, реализация методов эквивалентности, ToString, структурная эквивалентность. Думаю, что в ближайшее время появятся генераторы, реализующие то же самое, просто на другой технологии, черпая из них вдохновение.

Но помимо этого, как я уже говорил, IL Weaving может модифицировать сам код методов, и это часто используется, чтобы добавить в него функциональные аспекты: кэширование, логирование, обработку исключений. В Fody, например, есть плагин, который позволяет выдавать запись в лог каждый раз, когда мы создаем disposable-объект, и каждый раз, когда он финализируется, таким образом позволяя по этим логам найти, где мы создали объект, на котором забыли вызвать Dispose(). Возникает вопрос: а можно ли то же самое сделать при помощи генераторов?

На самом деле, на официальном сайте Microsoft, когда представляли генераторы, сказали, что всё это — code rewriting, оптимизация, logging injection, IL Weaving, переписывание кода. Всё это — замечательные и полезные сценарии, но генераторам они официально не подходят. Поэтому я хочу рассказать о нескольких обходных путях, которыми можно реализовать многие из подобных сценариев.

Например, LoggingInjection обычно подразумевает, что мы логируем какую-то информацию о вызове, как правило, в начале метода мы пишем какую-то информацию, например, о том, с какими аргументами был вызов, в конце — что он вернул, случились ли исключения, сколько времени занял вызов.

Например, добавлять подобную диагностическую информацию из коробки умеет
PostSharp, при помощи атрибута Log.

[Log] public Request(int id) {     Id = id; }

После компиляции получится 75 строк кода, суть которых сводится к тому, что мы в начале залогировали, с каким аргументом был вызов. Затем под try вызвали исходный код, который там был. Потом записали, что либо всё прошло успешно, либо что случилось исключение.

public Request(int id) {     if (localState.IsEnabled(LogLevel.Debug)) {         logRecordInfo = new LogRecordInfo(MethodEntry, …);         recordBuilder1.SetParameter<int>(..., id);     }     try {         this.Id = id;         logRecordInfo = new LogRecordInfo(MethodException, …);     }     catch(Exception ex) {         logRecordInfo = new LogRecordInfo(MethodException, …);               recordBuilder1.SetException(ex);         throw;     } } 

Можно ли то же самое сделать при помощи генераторов и из старого доброго ООП?

Как заведено в ООП, любая проблема решается введением еще одного уровня абстракции.

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

В этом случае исходный код в вашем проекте будет содержать только бизнес-логику:

interface IAccountingService {     AccountsSet GetAccounts(Client client);     decimal GetTotalBalance(AccountsSet accounts); }  class AccountingServiceCore : IAccountingService {     public AccountsSet GetAccounts(Client client) => …     public decimal GetTotalBalance(AccountsSet accounts) => … }

А генератором вы создадите декоратор с поддержкой логирования:

На самом деле, с генераторами можно обойтись даже без интерфейса — сделать приватную реализацию, в которой будет только бизнес-логика, а затем генератором добавить в тот же самый тип публичный метод, в котором уже будет логирование.

Но все возможности IL Weaving заменить всё равно не выйдет, либо оно просто этого не стоит. Например, в Fody есть NullGuard, который позволяет добавить проверку на null всем аргументам каждого метода. Можно ли сделать это декораторами? Наверное, можно, но вряд ли это будет удобно.

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

Возможность модифицировать исполняемый код в любом месте программы нужна сравнительно редко, но может предоставлять поистине уникальные возможности. В PostSharp, например, есть возможность обнаружить deadlock за счет того, что мы каждый раз, когда работаем с синхронизационными примитивами, создаем запись в лог о том, какие локи мы взяли, какие отпустили, и если программа зависла, по ним потом можно будет найти, где и как именно случилась проблема.

Поскольку мы модифицируем код внутри метода, то генераторами это сделать уже не удастся, ведь генератор не может изменить существующий код, только добавить новый…

Или удастся? Ведь генератор видит весь исходный код вашего проекта, и это значит, что вы можете создать шаблон, заготовку метода или даже всего типа, посмотреть на нее генератором и создать дубликат, вставив в него нужные строки в каких-то местах.

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

Если очень хочется сделать это именно при помощи генераторов, то с креативным подходом возможно всё. Но, скорее всего, это будет уже не очень удобно.

Подводя итог разнице между генераторами и IL Weaving, я думаю, он станет более нишевым, потому что у него есть большое число заменяемых плагинов. Но часть его возможностей всё равно уникальна, поэтому он останется с нами.

Есть небольшая разница в реализации: генераторы гораздо проще дебажить и тестировать, зато на этапе компиляции у IL Weaving не видно деталей реализации. Поэтому, например, PropertyChanged я, скорее всего, буду реализовывать при помощи Fody, дебажить эти свойства мне никогда не надо. Но, например, уже при создании какого-то метода эквивалентности, который я возможно захочу подебажить, я сделаю при помощи генераторов.

И есть еще один вид кодогенерации — это рантайм-генерация при помощи класса ILGenerator. В рантайме мы создаем генератор и просто пишем байткод. Возможно, напрямую вы им не пользовались, но, например, могли пользоваться Expression<T>.Compile().

Часто этим классом тоже пользуются, чтобы просто сэкономить время на шаблонной реализации логирования, сериализации, маппинга, шифрования. Поскольку всё это известно на этапе компиляции, то такие сценарии можно отлично заменить генератором.

Тем не менее, зачастую ILGenerator используется для обработки данных, которые доступны только в рантайме, например, если вам хочется создать код для интерпретации переданного пользователем регулярного выражения. В этом случае на этапе компиляции, когда отрабатывают новые генераторы, вы просто не знаете, какой код должны будете создавать, поэтому для таких сценариев вам по-прежнему потребуется ILGenerator.

Рефлексия

Еще один инструмент метапрограммирования, который хотелось бы упомянуть, — это рефлексия.

Казалось бы, какая здесь связь? Рефлексией мы никакого нового кода не создаем. Мы просто работаем с существующими объектами. Но на самом деле, часто рефлексия используется просто чтобы сократить код. Например, когда вы сериализуете объект, неважно чем — Newtonsoft.Json, System.Text.Json, DataContractSerializer — чтобы сериализовать объект, нужно посмотреть, какие в нём есть поля и свойства, разумеется, при помощи рефлексии.

Мы могли бы этого избежать, если бы у нас в каждом объекте просто был метод, скажем, сериализации в JSON или XML. В этом случае нам не пришлось бы прибегать к рефлексии, а код работал бы быстрее. Просто мы обычно не пишем специальный код для сериализации каждого отдельного типа, потому что это очень много кода, и его потом приходится поддерживать и обновлять каждый раз, когда вы добавляете или изменяете поля сериализуемого объекта.

Я нашел генератор JsonSrcGen, который как раз предоставляет методы сериализации для каждого типа в проекте, и согласно замерам автора, время первого старта приложения и первой сериализации уменьшилось на несколько порядков. Если это является узким местом производительности для вашего приложения, то здесь тоже можно использовать генераторы и сократить использование рефлексии до минимума.

Можно также оптимизировать Dependency Injection. Ниже представлен пример заполнения контейнера для AutoFac-фреймворка, который регистрирует все типы в сборке в качестве интерфейса, в котором они реализуются. Делает он это, разумеется, рефлексией.

var builder = new ContainerBuilder(); var asm = GetExecutingAssembly(); builder.RegisterAssemblyTypes(asm)     .AsImplementedInterfaces();

С генератором мы могли бы предоставить метод наполнения этого контейнера, который бы статически регистрировал все типы из сборки, известные ему на этапе компиляции.

Так же как и с ILGenerator, заменить рефлексию сгенерированным кодом будет возможно, только если все данные, нужные вам для генерации кода, доступны на этапе компиляции. Если вы используете рефлексию для того, чтобы исследовать разные объекты в рантайме или обойти ограничения компилятора, например, поработать с приватными методами или свойствами, то здесь, разумеется, заменить ее генераторами не выйдет.

Как поменяется мир метапрограммирования?

Подводя итог существующим технологиям, можно сказать, что IL Weaving в лице Fody и PostSharp, скорее всего, станет нишевым, но свои преимущества у него есть. ILGenerator и рефлексия — тут всё зависит от ваших сценариев.

Единственная технология, которая, скорее всего, уйдет, — это T4-шаблоны. Как и генераторы, они только создают новые файлы, у них свой синтаксис, у которого нет прямой поддержки в IDE. Например, Roslyn уже использует генераторы там, где мог бы быть T4. У команды компилятора есть XML-файл с описанием языка C#, из которого создаются классы синтаксических элементов. Раньше они пользовались самописным скриптом, который генерировал C#-файлы. Можно посмотреть пулл-реквест, в котором они заменили его на генератор. Единственное преимущество T4 — он может создавать не только C#-файлы, а вообще что угодно, и вам гораздо проще контролировать время запуска.

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

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

Проблемы при использовании генераторов

Возможно, вы не очень хотите писать генератор самостоятельно. Здесь хорошие новости: генератор можно подключить как обычный NuGet-пакет, и всё будет работать. Поделиться своим генератором не сложнее, чем Roslyn-анализатором. Только помните, что в этом случае вы пишете код для чужого проекта.

Первое, что рекомендуется сделать — проверить версию языка. Существует распространенное заблуждение, что генераторы — фича C# 9. Но на самом деле это просто фича компилятора. Подключить генератор вы можете к любому проекту, например, хоть на C# 4. Если вы при этом воспользуетесь в сгенерированном коде рекорд-типами или паттерн-матчингом, то они просто создадут потребителям генератора ошибки компиляции.

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

Думайте обо всех сценариях. Приведу пример:

           // begin building the generated source             StringBuilder source = new StringBuilder($@" namespace {namespaceName} {{     public partial class {classSymbol.Name} : {notifySymbol.ToDisplayString()}     {{ ");

Autonotify-реализация от Microsoft — генератор, создающий NotifyPropertyChanged-свойства. Вот так начинается генерация этого типа: создается namespace, в нём лежит public partial class, реализующий этот интерфейс, дальше добавляются свойства. Всё очень похоже на мое демо.

Я часто занимаюсь модификациями кода, которые предлагает ReSharper. Поэтому первое, что мне здесь бросается в глаза, — это то, что здесь использован class. Структуры, реализующей NotifyPropertyChanged, наверное, не будет, а record — почему бы и нет?

Почему public class, а не internal class? Почему он лежит напрямую в namespace, ведь тип, который вы хотите расширить, может быть вложен в другой? Почему здесь вообще всегда пишется namespace, ведь я могу оставить его в глобальном неймспейсе сборки? Тогда имя неймспейса будет пустым и этот код тоже не скомпилируется. Так что даже в таком простом деле, как объявить тип в чужом проекте, существует огромное количество подводных камней и, к сожалению, как только вы начинаете заниматься метапрограммированием, обо всём этом приходится думать.

В том числе вам придется подумать, какие зависимости есть у кода, который вы создаете. Например, если вы создаете методы сериализации объекта, и у вас есть какой-то fallback, например, для массивов примитивных типов вы хотите использовать Newtonsoft.Json, то вам потребуется указать, что тот, кто добавит пакет с генератором в ваш проект, должен также подключить и Newtonsoft.Json.

Как это сделать, можно посмотреть по ссылке.

Я также рекомендую проверить в самом генераторе, что этот пакет действительно есть, потому что через несколько лет человек может забыть, зачем ему Newtonsoft.Json, который никогда не используют, и просто удалить его.

Также для работы генератора может оказаться недостаточно информации о C#-коде, предоставленной ему компилятором, например, вам могут потребоваться дополнительные данные, скажем, XML-файл с настройками, конфигурацией или схемой генерируемых типов. Они не являются частью C#-проекта, поэтому по умолчанию вам компилятор их не предоставит.

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

Сделать всё это можно при помощи еще одного тега — CompilerVisibleItemMetadata.

<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="NamespaceName" /> <CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="ClassName" />  <ItemGroup>   <AdditionalFiles Include="Assets/Sample.svg" NamespaceName="Assets" ClassName="Sample" /> </ItemGroup> 

Также можно предоставить генераторам доступ к MSBuild-свойствам, сделать через них любую top level-конфигурацию, например, включить или отключить генератор, выдавать из генератора диагностическую информацию. Мне очень понравился пример, когда человек по MSBuild-флажку заходит в ветку с Debugger.Launch() и дебажит генератор, только указав, что хочет это сделать.

Примечание: со времени подготовки доклада появилась более простая возможность подключения дебаггера к генератору. Вы можете добавить в проект генератора файл launchSettings.json и указать что это генератор, и добавить команду DebugRoslynComponent с указанием, для какого конкретно проекта должен быть запущен генератор при исполнении этой команды. Теперь вы можете просто выбрать конфигурацию с генератором в качестве запускаемого проекта и дебажить его в один клик.

Пример конфигурации в launchSettings.json:

{   "profiles": {     "Generator": {     "commandName": "DebugRoslynComponent",     "targetProject": "..\\Target.csproj"     }   } }

Можно также сконфигурировать код, который создается, например, указав через отдельное свойство неймспейс для всех сгенерированных типов. Если это logging generator, то выбрать фреймворк, с каким уровнем логировать отдельные элементы, или что вообще логировать.

Сделать это можно при помощи CompilerVisibleProperty, которое затем можно прочитать из генератора при помощи свойства GlobalOptions с префиксом build_property.

Например, если вы хотите передавать опцию, включающую/выключающую логирование, это можно сделать следующим кодом:

В .csproj:
<CompilerVisibleProperty Include="EnableLogging" />

В коде генератора:
context.AnalyzerConfigOptions.GlobalOptions .TryGetValue("build_property.EnableLogging" , out var emitLoggingSwitch);

Еще один интересный вопрос: что делать, если генераторов несколько? С чем будет работать второй генератор?

Допустим, нашли на NuGet генератор для логгинга и хотите сами написать кэширование. Сможет ли один из генераторов увидеть код, созданный другим генератором для того же проекта, и воспользоваться им?

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

Создав информацию о коде проекта один раз, компилятор передает иммутабельный объект с ней во все генераторы, таким образом, они абсолютно не зависят друг от друга и создают новые файлы, которые затем становятся частью целевого проекта одновременно, после того как все генераторы завершили работу.

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

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

Затем уже либо в контейнере зависимостей, либо вручную собрать из этих декораторов правильную последовательность. В зависимости от того, в каком порядке вы эти типы будете оборачивать в декораторы, вы можете даже настроить поведение — либо будут логироваться все вызовы, либо только уникальные, которые не пошли в кэш.

var logsAllCalls = new LoggingLogic(new CachingLogic(new LogicImpl()));

var logsUniqueArguments = new CachingLogic(new LoggingLogic(new LogicImpl()));

Последнее, что хочу здесь посоветовать, — используйте новый partial. В C# 8 partial-методы могли быть только приватными методами, которые ничего не возвращают.

В C# 9 у нас появилась возможность делать их публичными, возвращать из них значения.

partial class MyType {    partial void OnModelCreating(string input); // C# 8    public partial bool IsPetMatch(string input); // C# 9 }

Но если уж мы из этого метода возвращаем значение, то оно должно откуда-то взяться. А именно, из реализации этого partial-метода в другой части типа.

partial class MyType {     public partial bool IsPetMatch(string input)         => input is "dog" or "cat" or "fish"; }

Поэтому такой partial-метод обязан иметь реализацию в другой части этого типа. У вас получается abstract в рамках одного типа. То есть если вы его объявили, то обязаны и предоставить реализацию.

Возникает закономерный вопрос: зачем тогда вообще нужен partial, если я все равно должен предоставить реализацию? Может быть, ее сразу же сюда и написать? И ответ на этот вопрос — для удобства использования генераторов. Вторая часть типа, содержащая реализацию такого partial-метода, может быть в сгенерированном коде. В этом случае у такой декларации есть две важные функции.

Во-вторых, когда через несколько лет вы (или ваш коллега) откроете файл с этим типом, вы сразу увидите в нем декларации методов и будете знать всё о том, что это за тип и какие методы в нем есть, без необходимости исследовать все его сгенерированные части. Простая partial-декларация позволит вам сразу узнать о функциях, доступных в этом типе, и предоставит точку для удобной навигации к реализации этого метода.

Best practices

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

Я начинаю вызов GetTotalAccountBalanceRemainder для клиента Пети, запрашиваю счета этого клиента (GetClientAccounts). Возвращается несколько счетов (Return value : Accounts), сумма минус 5 000 рублей — Петя мне должен. Всё это заняло 216 мс.

А при запросе для клиента Васи у меня вообще случилось исключение. Сделано это как раз при помощи logging-декоратора. Если я поищу реализации использованных интерфейсов в программе, я увижу, что у меня есть две реализации: реализация с бизнес-логикой в моем исходном коде и вторая реализация, которая предоставлена генератором. Здесь тот же самый интерфейс реализуется методом, который добавляет запись в лог, а затем передает управление основному объекту бизнес-логики.

Сделано это генератором, который очень похож на тот, что в первоначальном демо. Мы точно так же обходим все файлы в проекте, находим все объявления типов, затем я смотрю, что эта декларация имеет атрибут Log. Если это интерфейс, то я создаю к нему какую-то partial-часть, которая реализует этот интерфейс, уже добавляя запись в лог.

Всё очень похоже на мой исходный пример, только кода больше, поэтому это отличная стартовая точка, чтобы рассказать о всех нюансах реализации, которые я для краткости опустил в первом примере.

Вы можете посмотреть исходный проект и модификации в работе генератора в нем по ссылке на GitHub.

Не полагайтесь на синтаксис для семантических проверок. Например, если вам нужно проверить, что тип отмечен атрибутом LogAttribute, вы можете просто проверить синтаксически наличие атрибута [Log] для типа, и это будет работать в простых ситуациях. Но в реальном коде он может быть записан и как [Log], и как [LogAttribute], и даже любым другим именем благодаря тайп-алиасам, например [Generate], если в файле или проекте есть using GenerateAttribute = LogAttribute;. Для того чтобы вам не пришлось обрабатывать все эти случаи самостоятельно, а пользователи вашего генератора не ломали голову над тем, почему что-то не работает, вы можете сразу перейти к семантической модели типа и посмотреть, какие у него есть атрибуты. В этом случае вам уже не придется думать ни о том, как именно они записаны, ни о том, что у типа может быть несколько деклараций и атрибут может быть на любой из них.

Проверяйте CancellationToken, доступный в контексте генератора. Компилятор может запускать генераторы очень часто, буквально несколько раз на один введенный символ в редакторе кода. Скорее всего, если разработчик активно печатает код в редакторе, к моменту, когда генератор завершит работу, созданный им код будет уже не нужен — код в проекте уже изменится, и генератор уже будет запущен заново.

Чтобы избежать ненужной работы в таком сценарии, вы можете проверять, что компилятор всё еще ждет от вашего генератора результат, проверив context.CacnellationToken.

Используйте SyntaxReceiver, чтобы сохранить интересующие вас синтаксические элементы до запуска генератора. Зачастую генератору потребуется исследовать код проекта и посмотреть, например, какие типы в нем объявлены. Вы можете сделать это, обойдя код всего проекта в методе ISourceGenerator.Execute, но в таком случае вы каждый раз будете тратить на это время, а результаты не могут быть переиспользованы другими генераторами.

Вместо того чтобы самостоятельно обходить все синтаксические деревья в проекте, вы можете в методе Initialize зарегистрировать ISyntaxReceiver. Это тип со всего одним методом OnVisitSyntaxNode, который будет вызван компилятором для того, чтобы оповестить генератор о наличии в проекте данного синтаксического элемента. Затем вы можете сохранить интересующие вас элементы, например декларации типов или методов, и в основной работе генератора работать с этой созданной заранее коллекцией, вместо того чтобы исследовать весь код проекта заново.

В этом случае вы фактически заменяете pull-модель. Когда вы сами ищете нужную вам информацию в том, что предоставил компилятор на push-модель, то он, один раз подготавливая модель кода для всех подключенных к проекту анализаторов и генераторов, один раз сообщит вам о наличии какого-то элемента. Эта информация будет переиспользована всеми генераторами, задействующими SyntaxReceiver.

Обратите внимание, что в SyntaxReceiver вы работаете исключительно с синтаксисом, на данном этапе вы не можете проверить никакую семантическую информацию — она просто еще не готова, поэтому здесь вам нужно будет ограничиться простыми синтаксическими проверками, например, типом синтаксического элемента, который вы хотите обработать.

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

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

Предоставьте нужные для работы генератора типы, например, атрибуты для управления генератором как часть созданного генератором кода. Одним из распространенных вариантов конфигурации генератора является использование атрибутов в целевом проекте для того чтобы обозначить типы, которые каким-либо образом будут обработаны генератором.

К сожалению, это создает еще одну интересную проблему: откуда в пользовательском проекте появятся эти типы? Не распространять же генератор с инструкцией «а для запуска объявите, пожалуйста, вот эти атрибуты в вашем проекте»?

К сожалению, у каждого способа их предоставить есть свои недостатки.

Лучшим, но, возможно, не слишком удобным способом будет создать еще одну, отдельную от генератора сборку, объявить нужные вам атрибуты в ней, а затем указать зависимость генератора от пакета с атрибутами. Однако, это означает, что вам придется разбивать генератор на несколько пакетов, зависящих друг от друга, в одном из которых, вероятно, будет всего несколько маркерных атрибутов.

Еще один способ — объявить их в проекте с генератором. К сожалению, это будет значить, что у проекта, обработанного вашим генератором, будет рантайм-зависимость от сборки с генератором, ведь теперь в этом проекте используются атрибуты из этой сборки. Это означает, что потребители вашего генератора обязаны будут поставлять его как часть своего продукта, что может быть неудобно.

Еще один способ — создать нужные вам атрибуты прямо в генераторе. Ведь вы же создаете код для пользовательского проекта, почему бы не создать еще и несколько типов, которые ему потребуются? Однако, здесь есть интересная проблема — если помните, весь код, добавленный всеми генераторами, появится в проекте одновременно, когда они все завершат работу. Это означает, что ваш генератор не сможет воспользоваться атрибутом, который сам же создал — атрибут будет добавлен лишь после того, как он завершит работу! Но здесь есть и обходной путь — вы можете модифицировать тот объект Compilation, который передал вам Roslyn. Сам объект иммутабелен, поэтому это никак не помешает остальным генераторам. Всё, что вам нужно — создать синтаксическое дерево нужного вам атрибута и добавить его при помощи метода AddSyntaxTrees.

var logSyntaxTree = CSharpSyntaxTree.ParseText(logSrc, options);   compilation = compilation.AddSyntaxTrees(logSyntaxTree);  var options = (CSharpParseOptions)  compilation.SyntaxTrees.First().Options;

Примечание: со времени подготовки доклада появилась возможность добавлять подобные статичные ресурсы в методе ISourceGenerator.Initialize с помощью вызова GeneratorInitializationContext.RegisterForPostInitialization. Это более эффективно, так как вам не требуется менять семантическую модель проекта, с которым вы работаете, добавлением в него новых типов; и делается однократно, а не на каждый запуск генератора.

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

Но согласитесь, гораздо лучше было бы указать на конкретную проблему. К счастью, у вас есть для этого все возможности! Генераторы на самом деле также являются и анализаторами кода. На самом деле разница между ними уже довольно размыта: вы можете написать генератор, который только анализирует код и выдает предупреждения, не создавая нового кода.

Например, если ваш генератор может работать только с интерфейсами, а пользователь при помощи атрибута попросил его обработать класс, вы можете воспользоваться методом context.ReportDiagnostic(Diagnostic.Create(...)) и сообщить ему, что именно пошло не так. Также у каждого синтаксического элемента есть метод .GetLocation(), при помощи которого вы можете показать разработчику конкретную строчку, на которой случилась проблема, и позволить ему снавигироваться к ошибке.

Используйте CompilerVisibleProperty и AdditionalFiles, чтобы предоставить дополнительную информацию генератору. Иногда генератору недостаточно той информации, которую он может получить, исследовав C#-проект, к которому он подключен. В таких случаях вы можете предоставить дополнительную информацию из msbuild-свойств или дополнительных файлов, сделав их доступными генератору при помощи новых тегов в .csproj.

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

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

Помните о том, что генераторы — это third-party код, и они могут быть использованы, чтобы добавить в ваше приложение уязвимость. С большими возможностями приходит и большая ответственность. Генераторы могут быть использованы в том числе и в злонамеренных целях, причем возможности для этого у них огромны.

Вероятно, все помнят нашумевшую историю с Solarwinds, когда злоумышленники смогли исполнить свой код как часть билд-процесса и модифицировать сборку продукта, поставляемую клиентам компании. Если задуматься, генераторы — это и есть часть билд-процесса, которая к тому же явным образом модифицирует сборку, которую вы будете поставлять вашим клиентам как часть вашего продукта. Причем генератор — это код, который исполняется при каждой сборке, поэтому он может по косвенным признакам попытаться обнаружить, что сборка идет на билд-сервере, что идет сборка релизного билда, и сгенерировать по этим условиям код, отличный от того, что он создавал на машине разработчика.

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

Во-вторых, для того чтобы исполнить какой-то код, генератору даже не требуется, чтобы вы вызвали сгенерированный им метод. В C# 9 появились инициализаторы модулей, которые исполняются в момент загрузки вашей сборки. Генератор может добавить инициализатор модуля и запустить в нем какую-то асинхронную активность, например, майнинг криптовалюты или поиск файла с конфигурацией подключения к базе данных с персональной информацией ваших клиентов. Причем все эти действия будут исполняться как часть вашего приложения — на машинах ваших клиентов и с правами вашего приложения.

По ссылке вы можете посмотреть простой пример генератора, который добавляет в проект симулятор майнера при помощи инициализатора модуля, но делает это только в релизном билде.

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

<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>..\GeneratedFiles</CompilerGeneratedFilesOutputPath>

Выводы

  • Проверяйте CancellationToken
  • Используйте ISyntaxReceiver
  • Поднимите CS8785 до ошибки
  • Выдавайте диагностики
  • Предоставьте необходимые атрибуты как часть генератора
  • Делайте генераторы конфигурируемыми
  • Проверяйте, что добавляете в проект

Что в итоге?

  • Генераторы делают создание шаблонного кода еще проще
  • Они решают многие типичные проблемы метапрограммирования, такие как навигация, дебаг, тестирование, порог вхождения
  • Возможности оптимизации старта приложения
  • Многие виды кодогенерации становятся более нишевыми
  • Генераторы только добавляют код

Примеры генераторов

  • JsonSrcGen — сериализация в JSON без рефлексии
  • ThisAssembly — константы текущей сборки: версия, название сборки, продукта
  • StringLiteralGeneratorReadonlySpan<byte> любой строки, заданной в атрибуте

Полезные ссылки

На этом всё, спасибо за внимание!

Следующий DotNext пройдёт в ноябре по хитрой схеме:
— 3-4 ноября: онлайн-часть
— 20 ноября: офлайн-часть в Москве с онлайн-трансляцией для тех, кто не готов добраться

Так что можно хоть посмотреть все доклады удалённо, хоть увидеть спикеров и других участников лично. Как обычно, будет много подобного технического контента про .NET — так что, если вы .NET-разработчик, обратите внимание.


ссылка на оригинал статьи https://habr.com/ru/company/jugru/blog/690040/

Управление учетными записями в Linux. Часть 2. Не/правильная настройка доступа

В предыдущей статье мы рассмотрели вопросы хранения учетных данных в ОС семейства Линукс. Теперь перейдем к обсуждению вопросов правильной и не очень настройки прав доступа к различным объектам операционной системы.

Напомню основные моменты относительно учетных записей в Линукс: есть суперпользователь root (id=0), который может все и есть все остальные учетные записи (id от 500 или 1000), которые имеют ряд ограничений и по идее не могут нанести большого вреда системе.

Но на практике возможны различные ситуации, когда обычному пользователю необходимы административные права. Например, обычный пользователь не может прочитать файл с хэшами паролей /etc/shadow, но он может изменить свой собственный пароль с помощью команды passwd. Очевидно, что для внесения изменений в защищенный файл команда должна выполняться с правами суперпользователя. И таких примеров может быть довольно много.

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

su и sudo, когда хочется большего…

В процессе работы часто возникает необходимость в правах суперпользователя, например для установки программного обеспечения и выполнения привилегированных команд. Сейчас хорошей практикой является не использование для выполнения повседневных задач аккаунта root. Более того, многие современные Линуксы блокируют интерактивное использование этой учетки или как минимум запрещают работу под ней по протоколу ssh.

Команда su заменяет пользователя текущей оболочки shell на указанного, например su user1 заменит на пользователя user1. Естественно, при запуске su система запросит пароль того пользователя, на shell которого вы хотите переключиться. Далее происходит запуск нового экземпляра оболочки с указанными параметрами. Команда su позволяет не выходя из системы, без лишних манипуляций повышать возможности управления операционной системой или наоборот ограничивать их.

Вызов su без аргументов приведет к запуску оболочки root. Для возврата в оболочку основного пользователя, из под которого была вызвана команда su необходимо просто ввести exit.

Одновременным достоинством и недостатком su является то, что эта команда переключает в оболочку другого пользователя и позволяет выполнять в ней любые действия, разрешенные данному пользователю. Удобно для выполнения административных задач но не слишком безопасно, когда обычному пользователю необходимо просто разрешить выполнение одной или нескольких привилегированных команд.

Альтернативой su является команда sudo.  Данная команда предоставляет временное повышение привилегий для одной команды. Предоставляя привилегии root только при необходимости, sudo снижает вероятность того, что пользователь сможет нанести существенный ущерб системе своими действиями.

 Основное отличие двух команд на примере пользователя root: su переключает вас в аккаунт root и требует пароль root. Sudo запускает с привилегиями root одну команду — она не переключает вас в аккаунт суперпользователя и не требует отдельного пароля root. Исключение sudo -i.

Кстати, о паролях. При вводе sudo команда система запрашивает пароль текущего пользователя. Хотя возможен вариант, когда sudo выполняется без пароля, но об этом чуть позже. Запрос пароля делается для того, чтобы в случае, если посторонний получил доступ к незаблокированной консоли, он не смог выполнить ничего привилегированного.

Но даже ввод корректного пароля еще не гарантирует выполнение команды под root.

Дело в том, что для разрешения запуска команд по sudo пользователь должен быть внесен в специальный файл /etc/sudoers, содержащий настройки использования sudo. Для нас в плане настройки доступа наиболее интересен раздел, связанный с разрешенными пользователям командами:

# User privilege specification

root    ALL=(ALL:ALL) ALL

user2 ALL = (root) NOPASSWD: /usr/sbin/iftop

user2 ALL = (root) NOPASSWD: /bin/more

Если мы хотим разрешить пользователю запускать под sudo любые команды, то необходимо использовать ключ ALL, например

user2 ALL=(ALL) ALL

 А если при этом мы еще и не хотим спрашивать пароль, то используем NOPASSWD

user3 ALL=(ALL) NOPASSWD: ALL

Аналогично, мы можем не спрашивать пароль при запуске конкретной команды

user2 ALL = (root) NOPASSWD: /bin/more

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

Например

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

Поэтому учитывайте это обстоятельство при настройке /etc/sudoers. Разрешать всем все явно не нужно.

 Права и SUID bit

Вернемся к приведенному ранее примеру со сменой пароля обычным пользователем с помощью команды passwd. Но для того, чтобы понять как выполняется этот трюк с доступом к привилегированному файлу мы немного поговорим о том, как в Линукс осуществляется доступ к файлам.

В стандартной модели доступа есть 3 вида прав, помимо специальных:

1) чтение

2) запись

3) исполнение (для директорий прохождение через них)

Права на файлы определяются для пользователей из трех групп:

1) для владельца

2) для группы владельца

3) для всех остальных

Для каждой операции выделены свои группы битов:

В зависимости от того, что разрешено, мы можем увидеть соответствующий набор значений:

Также права доступа можно представить в виде чисел, формируемых по следующему правилу:

Для того, чтобы пользователь не обладающий административными правами мог запускать приложения, требующие права root для своей работы (как в нашем примере с passwd) в Линукс предусмотрен специальный механизм — SUID (Set UID) bit. Данный бит позволяет выполнение программы с правами хозяина файла. Это ключевой механизм повышения прав в Unix системах. Особенности SUID программ в стандартных конфигурациях Linux:

• Работают с полномочиями пользователя root

• Используются для выполнения безопасных привилегированных операций.

• Используются для штатной смены идентификаторов пользователя: su, sudo, pkexec

• Программы учитывают идентификатор запустившего их пользователя и различные файлы конфигурации

Для того, чтобы установить SUID бит необходимо воспользоваться следующей командой:

chmod u+s выполнимый_файл

в результате в выводе в правах будет отображаться буква s

Обратите внимание на то, что владельцем файла является root. Кстати же знакомая нам команда sudo тоже имеет SUID бит.

Посмотрим, что произойдет, когда двоичный файл, имеющий SUID бит, выполнится. Созданный процесс изменит свой эффективный идентификатор пользователя (EUID) со стандартного UID на владельца этого специального двоичного исполняемого файла который в этом случае — root.

Ядро принимает решение, имеет ли этот процесс привилегию, просматривая EUID процесса. Потому что теперь EUID указывает на root — эта операция не будет отклонена ядром.

При самостоятельной установке SUID bit администраторам нужно быть особенно внимательными, так как некоторые утилиты имеют штатный функционал позволяющий запускать командную оболочку. О том как эксплуатировать подобные уязвимости мы подробно поговорим в следующей статье. Но прежде, чем разрешать какой-либо команде запускаться по sudo или устанавливать SUID bit я бы рекомендовал сначала прочитать man по данной команде, нет ли там ключика, позволяющего запускать shell.

Непутевый PATH

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

Но при не совсем корректном составлении списка путей в переменной PATH возможна ситуация, когда злоумышленник сможет разместить по одному из путей, указанных в PATH свой файл с таким же названием, как и легальное приложение. Но этот файл будет выполнен вместо легального приложения, так как путь к нему прописан в PATH раньше, чем путь к легальному приложению. Например, на скриншоте /usr/local/bin идет раньше, чем /usr/bin, поэтому если легальный файл лежит по второму пути, то злоумышленник может сохранить свой файл с таким же именем по первому и он будет выполнен вместо легального. Таким образом, злоумышленник имея доступ в каталоги прописанные в PATH может поместить свой выполнимый файл с тем же именем, и при выполнении, по сути, подменить легальный файл на свой. А учитывая, что файл могут пытаться запустить с правами суперпользователя злоумышленник в результате подмены может получить права root.

 Заключение

В этой статье мы рассмотрели основные моменты настройки прав доступа. Я сознательно не рассматривал никакие “хакерские фокусы”, то есть практические примеры эксплуатации ошибок в данных настройках, так как этому будет полностью посвящена следующая статья. Но основные рекомендации по безопасности можно дать уже сейчас. Прежде всего, необходимо использовать принцип минимизации привилегий, то есть пользователям необходимо назначать только те права, которые необходимы им для работы. Недопустимо использование прав 777 на те приложения, запуск которых может привести к получению root-shell. Также недопустимо разрешение пользователям запуска любых команд под sudo, так как это равносильно получению прав root. Аналогично, SUID бит должен устанавливаться только на безопасные приложения, в противном случае есть риск получения пользователем прав root.

Вместо заключения хочу пригласить вас на бесплатные демоуроки по Linux от OTUS. Зарегистрироваться на уроки можно по ссылкам ниже:


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/690810/

Открытый проект мультизонного термосенсора с логированием и блютусом

Цель проекта — получить устройство с возможностью простого подключения множества термосенсоров. Термосенсоры должны однозначно идентифицироваться по месту. Все температуры должны отображаться на одном экране и логироваться в файл. За основу была взята шина 1-Wire и сенсоры DS18S20+.

Проект-концепция, такой можно сделать практически на любой отладочной плате с любым микроконтроллером. Но под рукой был именно модуль K66BLEZ1.

Перечень необходимых деталей

Модуль K66BLEZ1

Открытый проект модуля находится здесь. Модуль на борту имеет энергонезависимые часы реального времени, USB high speed с двумя виртуальными COM портами, микро SD карту с поддержкой FAT32, Blertooth LE 4.0 модуль, зарядник литиевых аккумуляторов 3.7 В. Питание от USB или источника питания 5 В или от аккумулятора 3.4…4.2 В.

Макетная плата

Схема макетной платы

Дисплей

Полная информация о дисплее собрана здесь. Дисплей управляется по шине SPI. Работает только на запись. Значит при работе с ним надо применять технологию фрагментированного фреймбуфера.

Сенсоры DS18S20+

Вся конструкция в сборе выглядит вот так:

За счёт того что сенсоры просто накалываются на плоский шлейф в произвольных местах сделать вариант под собственные нужды можно буквально за минуты. Длина шлейфа может достигать сотен метров и даже километра. Это обеспечивается сравнительно низкой скорость работы с сенсорами и подводкой отдельной линии питания. Питание 3.3 В подаётся в шлейф из модуля. Все сенсоры подключены на одну информационную линию DQ, по которой осуществляется двунаправленный обмен данными.

Пример схемы подключения трех сенсоров к микроконтроллеру
Пример схемы подключения трех сенсоров к микроконтроллеру

В данном проекте реализовано считывание данных максимум с 8-и сенсоров. Считывание каждого сенсора длиться около 0.6 сек. Проект легко переделать под большее количество сенсоров. На многие десятки.

Настраивается дивайс через USB и виртуальный COM порт. На стороне PC применяется программа терминал типа TeraTerm. Можно задать разворот экрана по всем 4 направлениям, задать максимальный размер лог файлов с записью температур, просмотреть и изменить идентификаторы сенсоров.

Способ привязки сенсоров DS18S20

Все сенсоры DS18S20 имеют глобально уникальные 8-байтные идентификаторы. Это позволяет к каждому из них обращаться по отдельности. Однако если изначально все сенсоры подключить одновременно, то останется неясным какой сенсор стоит на какой позиции. Поэтому была разработана процедура привязки сенсоров к месту. Процедура выполняется следующим образом:

  1. Питание выключено. Со шлейфа все сенсоры сняты. Распределяем по своему желанию номера позициям установки сенсоров.

  2. Устанавливается один сенсор на шлейф в позицию с номером 1.

  3. Включаем питание. Ожидаем появления данных с сенсора на экране. Выключаем питание.

  4. Устанавливаем следующий сенсор в следующую по порядку позицию и переходим к пункту 3. Если все сенсоры установлены, то процедура завершена.

После выполнения всей процедуры на экране напротив каждого номера позиции будут показания сенсора находящегося в именно в этой позиции.
Выполненные настройки сенсоров сохраняются в файле на SD карте и при переносе шлейфа на другое устройство нужно будет только перенести файл с настройками.

Каналы коммуникации с дивайсом

Устройство соединяется через USB интерфейс с компьютером. При этом на компьютере появляется два виртуальных COM порта. Порт с номером интерфейса 0 служит для управления через терминал, просмотра лога и управления настройками. Порт с интерфейсом 2 работает по протоколу FreeMaster и соответственно через него ведёт обмен данными среда FreeMaster на компьютере.

Лог-файл с результатами измерений

Лог файл с результатами измерений по всем зонам сохраняется в формате CSV и может быть импортировал в Excel или другую подходящую программу для анализа. Каждая запись сопровождается датой и временем. Периодичность записи около 5.5 сек.

Управление через терминал

Главное меню в терминале показано ниже. Кроме сохранения лога с результатами измерения температуры устройство ведёт также свой внутренний лог. Его можно просмотреть через терминал. Здесь также настраивается дата и время и все параметры.

Обмен через USB со средой FreeMaster

Работа с FreeMaster была описана в предыдущих статьях. Стоит только напомнить что компонент FreeMaster обеспечивает доступ и в MATLAB в реальном времени.

Проект для среды FreeMaster находится в файле MZTS.pmp.

Наблюдение за температурой по Bluetooth LE.

Для этого используется кастомный набор характеристик и была написана простая утилита на PC под управлением Windows 10..11 , Утилита умеет сканировать все BLE устройства в эфире и из списка выбирает только те которые поддерживают кастомную характеристику термосенора. Перед этим устройство должно быть спарено с PC. Пин код и имя устройства задаются через терминал.

Исходники проекта

Статьи с предыдущими концептами на модуле K66BLEZ1


ссылка на оригинал статьи https://habr.com/ru/post/690708/

Как мы в SegmentStream интегрировали Cube

Вступление

  Всем привет. Меня зовут Андрей, я работаю в компании SegmentStream, занимаюсь вопросами архитектуры, инфраструктуры и написанием кода. Сегодня я расскажу как мы адаптировали Cube под наши нужды. В рамках статьи я не буду подробно рассказывать про сам Cube, если вы не знакомы с Cube, то лучше сначала немного почитать про него на официальном сайте cube.dev/docs. Если говорить про SegmentStream, то мы разрабатываем SaaS-платформу для оптимизации рекламных кампаний на основе машинного обучения. Звучит просто, но я рекомендую почитать, там много интересных вещей.

  Также, в статье будет достаточное количество кода на TypeScript, который можно при желании не стесняться заимствовать. Я постараюсь оставлять объяснения к нему.

Зачем нам Cube

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

  Так как мы работаем с данными, то нужно иметь возможность эти данные как-то визуализировать. До этого клиенты могли смотреть отчёты через Google Data Studio, но проблема в том, что это был полностью сторонний инструмент, и даже его интеграция на сайт была через iframe. 

  Но это ещё половина беды. Oсновная проблема была в том, что отчёты приходилось настраивать под каждого клиента, и, очевидно, с ростом количества клиентов это вылилось бы в определённые трудности.

  Оценив все плюсы и минусы, мы решили, что нужно переезжать на что-то своё, что-то, что будет стоить адекватных денег, и что будет закрывать все наши потребности. А исходные данные у нас следующие:

  1. Мы храним данные в BigQuery, при этом, по большей части, в BigQuery клиента (данные-то клиенту принадлежат) и доступ получаем через OAuth2.0;

  2. Данные, необходимые для отчётов, разбиты на 2 здоровенные таблицы по 20-30 миллионов строк с 10 различными ключами и 30 метриками каждая;

  3. Обработка данных стоит денег в BigQuery, так что нужно, по возможности, избегать запросов в BigQuery;

  4. Данные в этих двух таблицах обновляются по запросу от нас.

  Посмотрев существующие решения, мы остановились на Cube. Нам понравилось, что его можно запустить у себя и что расширять его можно на TypeScript, который является нашим основным языком разработки. Вот так Cube выглядит на архитектурном уровне (картинка позаимствована из блога Cube):

Компоненты следующие:

  • API отвечает за обработку входящих запросов;

  • Cube Refresh Worker отвечает за обновление данных по расписанию;

  • Redis используется как кеш и шина;

  • Кластер Cube Store отвечает за хранение преагрегаций и обработку запросов к ним.

  Тут имеет смысл добавить пару слов про преагрегации. Преагрегации это особый компактный формат представления данных, который хранится отдельно от основных данных и скорость чтения которых существенно выше.

  Cube Store выгружает эти самые преагрегации из таблицы в файлы формата parquet и ходит в них во время запросов.

  В общем, нам это всё очень понравилось и мы начали интегрировать Cube.

Немного теории про Cube

  Если у вас уже есть опыт работы с Cube, то этот раздел, вероятно, можно пропустить, если нет, то настоятельно рекомендую прочитать, потому что по ходу повествования будут появлятсья понятия термины, относящиеся к Cube.

  В Cube данные описываются при помощи кубов (внезапно), куб представляет из себя описание того, как нужно извлекать данные из источника, например:

cube(`Users`, {  sql: `SELECT * FROM users`,    measures: {    count: {      sql: `id`,      type: `count`,    },  },    dimensions: {    city: {      sql: `city`,      type: `string`,    },      companyName: {      sql: `company_name`,      type: `string`,    },  }, });

  В коде выше мы определили базовый sql-запрос, 1 measure и 2 dimension. Если говорить простым языком, то measure это то, что мы хотим посчитать, а dimension — то, по чему хотим группировать. То есть, если мы хотим получить колличество пользователей в каждом городе, то нужно будет отправить в Cube следующий запрос:

{   dimensions: ['Users.city'],   measures: ['Users.count'] }

  Но ходить каждый раз в источник данных не всегда удобно, иногда дорого, да и зачем, если можно заранее посчитать количество пользователей в каждом городе и сохранить. Этот механизм называется преагрегацицей и заключается в том, что Cube предварительно материализует данные в соответствии с кубом, это позволяет существенно ускорить запросы.

  Преагрегаци определяются в кубе и выглядят как-то так:

cube(`Orders`, {  sql: `SELECT * FROM users`,    ...,    preAggregations: {     usersByCity: {      dimensions: [CUBE.city],      measures: [CUBE.count],    },  }, }); 

  Так, как мы используем BigQuery, то имеет смысл немного рассказать про преагрегации в контексте работы с BigQuery. У Cube сущетсвует несколько стратегий создания преагрегаций:

  • Хранить в отдельной таблице в базе;

  • Стримом выгрузить эту отдельную таблицу из базы и отправить на хранение в Cube Store;

  • Использовать механизм exportBucket, при помощи которого BigQuery сам выгружает таблицу в файл и присылает в CubeStore ссылку на эти файлы.

  Конечно же, последний вариант является самим быстрым с точки зрения построения преагрегаци, но в следующем разделе будет дана ремарка почему мы его не смогли использовать и остановились на втором варианте.

  Как Cube понимает, что пора обновлять данные? Для этого в преагрегацию необходимо добавить параметр refreshKey:

refreshKey: {   every: `30 seconds`   sql: `SELECT MAX(created_at) FROM users`, }

  Cube раз в 30 секунд будет выполнять этот запрос и при изменении результата будет пересчитывать преагрегации.

OAuth2.0

  В Cube из коробки есть драйвер для BigQuery, вот только он умеет работать только с сервис-аккаунтами, а у нас практически все клиенты через OAuth 2.0 сидят. Что делать? Расширить драйвер, конечно же.

class SegmentStreamBigQueryDriver extends BigQueryDriver {  config: BigQueryDriverOptions;  constructor(config: BigQueryDriverOptions) {    super(config);      this.config = config;    this.pinoLogger = pinoLogger;      if (config.oauthCredentials) {      const refreshClient = new UserRefreshClient();        refreshClient.fromJSON({        type: 'authorized_user',        client_id: config.oauthCredentials.clientId,        client_secret: config.oauthCredentials.clientSecret,        refresh_token: config.oauthCredentials.refreshToken,        project_id: config.projectId,      });        this.bigquery.authClient.cachedCredential = refreshClient;    }  } }

  Кстати, exportBucket работать с OAuth 2.0 не будет. Дело в том, что для подписи ссылок GCP использует ключ сервис-аккаунта, такие вот дела.

Авторизация пользователей

  Теперь встаёт вопрос: как нам передать с фронта креды пользователя от BQ, которые мы должны хранить и не отдавать никуда? Cube умеет в авторизацию через JWT, но вот только нам нужно не просто проверить, что запрос пришёл от добросовестного пользователя, нам нужно ещё и креды от BigQuery передать.

  Мы не стали долго думать и просто засунули в JWT креды, предварительно зашифровав их. Но через некоторое время словили 431 Request Header Fields Too Large и побежали ставить костыли и пошли добавлять флаги к ноде на увеличенный размер заголовков. Думаю, что сделать вы это и сами легко сможете, а интерфейс выглядит вот так:

interface EncodedCredentials {  content: string;  iv: string; } interface SecurityContext {  projectId: string;  datasetId: string;  oauthCredentials?: EncodedCredentials;  encodedServiceAccount?: EncodedCredentials; }

  Кто прочитал код, тот может заметить, что мы ещё и сервис-аккаунты поддержали. Да, мы решили, что нужно переводить пользователей на сервис-аккаунты и заканчивать гонять терабайты данных через нашу инфраструктуру (BigQuery умеет экспортировать таблицу в файл в GCP, но как было сказано выше, умеет он это только с сервис-аккаунтом).

  Итого, вся эта красота стала выглядеть следующим образом:

  1. Фронт идёт в API и получает JWT для Cube, в JWT зашиты креды от BigQuery, у JWT, конечно же, есть подпись и срок жизни;

  2. С полученным JWT и сформированным запросом фронт идёт в Cube;

  3. Cube проверяет подпись JWT, расшифровывает креды и создаёт инстанс драйвера на основе SecurityContext;

  4. Cube формирует ответ и отдаёт его клиенту.

  В случае с Cube Refresh Worker (сервис, который следит, что все данные актуальны и обновляет преагрегаты) мы из API запрашиваем всё то же самое, но без стадии зашивания в JWT.

Преагрегации без поля updated_at

  Преагрегации это очень крутая вещь, особенно вместе с Cube Store: штукенция на Rust, которая хранит их на диске и умеет доставать оттуда данные в обход BigQuery. Cube умеет строить преагрегации с разной гранулярностью и партиционировать их, но чтобы партиционирование и обновление партиций работало корректно, нужно правильно указать ключ преагрегации, но ключика-то у нас нет.

  К счастью, ребята из команды по работе с данными вовремя подсказали нам один хак:

granularity: `day`, partitionGranularity: `week`, buildRangeStart: { sql: `SELECT DATE_SUB(CURRENT_DATETIME(), INTERVAL 92 DAY)` }, buildRangeEnd: { sql: `SELECT CURRENT_DATETIME()` }, refreshKey: {  every: `5 minutes`,  sql: `SELECT COALESCE(max(last_modified_time), '2020-01-01T00:00:00.000Z') as last_updated_at        FROM \`INFORMATION_SCHEMA.PARTITIONS\`        where table_name = 'conversions'          AND partition_id != '__NULL__'          AND ${FILTER_PARAMS.Attribution.date.filter(            "TIMESTAMP(PARSE_DATE('%Y%m%d', partition_id))",          )}`, }

  В BigQuery можно сделать запрос на метаданные и получить время модификации конкретной партиции (таблицы мы партиционируем).

  А через пару недель к нам пришли клиенты с жалобами на спам запросы… И это, честно говоря, было ожидаемо: каждые 5 минут Cube делал для каждой преагрегации и для каждой партиции запрос, после чего за сутки натекало неплохо так. А ещё BigQuery тратит 10 мегабайт на такой запрос (почему — большой вопрос), и за месяц может накапать под терабайт.

  Итак, проблему нужно решать. Фактически, нам вообще не нужны эти проверки, мы и так прекрасно знаем, когда была обновлена конкретная таблица (мы же её сами и обновляем), значит, нужно как-то ограничить походы Cube на BigQuery. Cube в бекенд с таким вопросом ходить не умеет, значит нужно пойти в Cube и подтюнить драйвер так, чтобы он умел кешировать запросы в redis, а кеш мы будем уже сами сбрасывать. Ниже приведён код того, что мы сделали. Мы заодно замокали проверки buildRangeStart и buildRangeEnd, потому что для этого в BigQuery лезть совсем не нужно.

 async runQueryJob<T = QueryRowsResponse>(    initBigQueryQuery: Query,    options: any,    withResults = true,  ): Promise<T> {    // Parenthesized expression cannot be parsed as an expression, struct constructor, or subquery    const bigQueryQuery = {      ...initBigQueryQuery,      query: this.replaceILIKE(initBigQueryQuery.query || ''), // BigQuery doesn't support ILIKE    };      if (bigQueryQuery.query === 'SELECT CURRENT_DATETIME()') {      return [        [          {            f0_: {              value: new Date().toISOString(),            },          },        ],      ] as unknown as T;    }      if (bigQueryQuery.query === 'SELECT DATE_SUB(CURRENT_DATETIME(), INTERVAL 92 DAY)') {      return [        [          {            f0_: {              value: new Date(Date.now() - 1000 * 60 * 60 * 24 * 92).toISOString(),            },          },        ],      ] as unknown as T;    }      if (this.cacheRenewewer && bigQueryQuery.query?.includes(`INFORMATION_SCHEMA.PARTITIONS`)) {      const tableName = bigQueryQuery.query.match(/table_name = '([a-zA-Z0-9]*)'/);      const { projectId, datasetId } = this.config;      if (tableName && tableName[1] && projectId && datasetId) {        const postfix = `${bigQueryQuery.params?.join('_').replace(/[-:\.]/, '_') || ''}_${          tableName[1]        }`;        const cachedResult = await this.cacheRenewewer.getRecord(projectId, datasetId, postfix);          if (cachedResult) {          return cachedResult;        }          this.pinoLogger.info(          {            projectId,            datasetId,            postfix,            tableName: tableName[1],            params: bigQueryQuery.params,          },          'Request pre-aggregation last_updatet_at from DB',        );          const [preAggregationJob] = await this.bigquery          .dataset(this.config.datasetId)          .createQueryJob(bigQueryQuery);        const result = (await this.waitForJobResult(          preAggregationJob,          options,          withResults,        )) as any as T;        await this.cacheRenewewer.setRecord(projectId, datasetId, postfix, result);        return result;      }    }      const [job] = await this.bigquery.dataset(this.config.datasetId).createQueryJob(bigQueryQuery);    return (await this.waitForJobResult(job, options, withResults)) as any as T;  }

  Код не самый тривиальный, так что лучше будет немного пояснить, что здесь вообще происходит: запросы на текущее время мы отдаём сами (странно, что в Cube нельзя это сделать без костылей), все результаты преагрегации мы кидаем в кеш и отдаём из него, а после того, как мы на стороне нашего бекенда обновили таблицы у пользователя, то мы сбрасываем кеш. Проблема решена, ура!

Утекающая память

  Думаю, что многим знакомы вот такие картинки.

   

  Мы тоже не стали исключением и получили удовольствие наблюдать такие картинки после подключения Cube к крупным клиентам. Понятно, что мы сами себе злые Буратино и нужно использовать сервис-аккаунты с exportBucket, но на тот момент у нас был только OAuth2.0. Да и, в конце концов, стримы не должны есть 15 гигабайт. Пошли делать console.log дебаг и нашли проблему, но уже не в Cube, а в драйвере BigQuery. Дело в том, что стриминг в драйвере BigQuery вообще не стриминг и ест память. Пришлось писать свою обёртку. Фактически, мы просто сделали стримы через пагинацию.

import { Readable } from 'stream'; import { BigQuery, Dataset, Job, QueryResultsOptions } from '@google-cloud/bigquery';   class BigQueryReadStream extends Readable {  nextQuery: QueryResultsOptions | null;  rowsBuffer: any[] = [];  constructor(protected job: Job, protected limitPerPage: number) {    super({      objectMode: true,    });      this.nextQuery = {      autoPaginate: false,      maxResults: limitPerPage,    };  }    protected manualPaginationCallback(    err: Error | null,    rows: any[] | null | undefined,    nextQuery: QueryResultsOptions | null | undefined,  ) {    if (err) {      return this.destroy(err);    }      this.nextQuery = nextQuery || null;    this.rowsBuffer.push(...(rows || []));    this.push(this.rowsBuffer.shift() || null);  }    protected readFromBQ() {    if (!this.nextQuery) {      return this.push(null);    }    this.job.getQueryResults(this.nextQuery, this.manualPaginationCallback.bind(this));  }    async _read() {    if (this.rowsBuffer.length === 0) {      this.readFromBQ();    } else {      this.push(this.rowsBuffer.shift());    }  } }   interface BigQueryParams {  bigquery: BigQuery;  dataset?: string; }   export const createQueryStream = async (  bigquery: BigQueryParams,  query: string,  params: unknown[],  limitPerPage = 1000, ): Promise<Readable> => {  const instance: BigQuery | Dataset = bigquery.dataset    ? bigquery.bigquery.dataset(bigquery.dataset)    : bigquery.bigquery;    // Run the query as a job  const [job] = await instance.createQueryJob({    query,    params,    parameterMode: 'positional',    useLegacySql: false,  });    // For all options, see https://cloud.google.com/bigquery/docs/reference/v2/jobs/getQueryResults  const queryResultsOptions = {    // Retrieve zero resulting rows.    maxResults: 0,  };    // Wait for the job to finish.  await job.getQueryResults(queryResultsOptions);    return new BigQueryReadStream(job, limitPerPage); };

  Ну и интегрировать написанное в сам Cube это было проще простого:

 public async stream(query: string, values: unknown[]): Promise<any> {    const streamUUID = randomUUID();    this.pinoLogger.info(      {        streamUUID,        query,        projectId: this.config.projectId,        datasetId: this.config.datasetId,      },      `Starting stream ${streamUUID}: ${query}`,    );      const stream = await createQueryStream(      {        bigquery: this.bigquery,        dataset: this.config.datasetId,      },      query,      values,      this.config.limitPerPage || 1000,    );    const rowStream = new HydrationStream({ streamUUID }, (message: string, params: any) =>      this.pinoLogger.info(params, message),    );    stream.pipe(rowStream);    return {      rowStream,    };  } }

Custom dimensions

  Как вы помните, в рамках куба можно определить набор dimensions и  преагрегаций, но что если пользователю нужно самому задавать dimensions? Схема-то для всех общая, а потребности у пользователей разные. Мы назвали это custom dimensions.

  Для решения этой задачи в Cube тоже есть решение, но придётся чуть поработать напильником. Решение называется “Dynamic Schema Creation” и оно нам подходит, но есть нюанс.

  Нюанс заключается в том, что security context используется только в момент компиляции схемы, соответственно, нужно связать security context и версию схемы:

function schemaVersion(context: Context) {  const securityContext = extractSecurityContextFromContext(context);  const customDimensions = (securityContext.customDimensions || [])    .map(      customDimension =>        `${customDimension.key}:${customDimension.sql}:${customDimension.type || ''}`,    )    .join(':');  const customDimensionsHash = crypto.createHash('sha256');  customDimensionsHash.update(customDimensions);  return `${getAppIdFromBQConfig(    securityContext.projectId,    securityContext.datasetId,  )}__${customDimensionsHash.digest('hex')}`; }

  Отлично, теперь при изменении набора пользовательских dimensions версия схемы будет меняться, осталось только эти dimensions запихнуть в схему.

  Сначала извлечём их:

 const { securityContext } = COMPILE_CONTEXT;  const customDimensions = securityContext.customDimensions || [];

  А потом добавим в схему:

  dimensions: {         ...customDimensions.reduce((customDimensionsObject, customDimension) => {        if (!customDimension.sql) {          return customDimensionsObject;        }          customDimensionsObject[customDimension.key] = {          sql: customDimension.sql.replace(/Report\.([a-zA-Z]+)/g, (_, dimension) => {            return Report[dimension];          }),          type: customDimension.type || 'string',        };          return customDimensionsObject;      }, {})   }

  Готово, теперь можно использовать custom dimensions при формировании запросов.

За границами преагрегации

  Внезапно оказалось, что за границами преагрегации (вне buildRangeStart и buildRangeEnd) ничего нет, пустота, одни нули. Почему? А вот так, Cube видит, что есть преагрегация для запроса и идёт туда, не обращая внимания на то, что там есть не все данные.

  Что делать? А ничего, если запрос выходит за границы преагрегации, то придётся как-то без преагрегации жить. Для этого нужно добавить фейковый measure:

notPreaggregatedMeasure: {   sql: `0`,   type: `number`, }

  На стороне клиента, если есть понимание, что запрос выходит за границы, то добавляем эту метрику, и запрос улетит в БД, ну или в кэш.

Сontinue wait

  Иногда при запросе данных можно получить ошибку вида “продолжайте ждать, всё будет, но чуть позже”. В большинстве случаев это означает, что Cube что-то там строит на своей стороне.

  Но проблема в том, что во-первых, это может затянуться, а во-вторых, если мы знаем, что преагрегации строятся (очень хочется попросить команду Cube дать эндпоинт со статусом в разрезе security context) мы же можем отдать пользователю запросы напрямую (через notPreaggregatetMeasure) и показать красивую крутилку о том, что мы что-то там строим.

  В общем, этот ответ тоже можно использовать с пользой.

  Кстати (вообще, некстати), к проблеме отсутствия эндпоинта со статусом. Как получить время обновления данных? Напрямую никак. Но можно чуть поковырять ответ и сделать вот так:

lastRefreshTime: new Date(   Math.max(     ...row       .serialize()       .loadResponse.results.map(result => new Date(result.lastRefreshTime).valueOf()),   ) )

Шардинг Cube Worker

  В самом начале я написал, что SegmentStream это SaaS. Значит количество SecurityContext, фактически, неограничено, либо достаточно большое. Но Cube Worker не умеет обрабатывать часть SecurityContext, соответственно, могут появиться ощутимые задержки на обновление данных (export bucket у нас есть далеко не у всех клиентов). Что делать? Шардировать. Как? Классическое консистентное хеширование.

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

         env {            name = "TOTAL_REPLICAS"            value = local.replicas          }            env {            name = "POD_GENERATED_NAME"            value = "cubestore-workers-"          }            env {            name = "POD_NAME"            value_from {              field_ref {                field_path = "metadata.name"              }            }          } 

  После чего при помощи библиоткеки hashring осуществить фильтрацию нужных контекстов:

export const podName = process.env.POD_NAME || 'cube-refresh-worker-0'; export const podGeneratedName = process.env.POD_POD_GENERATED_NAME_NAME || 'cube-refresh-worker-'; export const totalRepicas = Number(process.env.TOTAL_REPLICAS || 1);   const hashring = new HashRing(  new Array(totalRepicas).fill(0).map((_, i) => `${podGeneratedName}${i}`), );   const filterSecurityContext = (context: Context) => {  const workerForContext = hashring.get(contextToAppId(context));  return workerForContext === podName; };   const apiClient = new ApiClient(process.env.API_URL || '');   const cube = {  dbType: 'bigquery',  externalDbType: 'cubestore',  cacheAndQueueDriver: 'redis',  scheduledRefreshContexts: async () => {    const scheduledRefreshContexts = !dev      ? (await apiClient.getAllSecurityContexts()).map((bq: SecurityContext) => ({          securityContext: bq,        }))      : undefined;      if (!scheduledRefreshContexts) {      pinoLogger.info(`Unable to fetch contexts`);      return [];    }      pinoLogger.info(`Fetched ${scheduledRefreshContexts.length} contexts`);      const finalContexts = scheduledRefreshContexts.filter(filterSecurityContext);      return finalContexts;  }, }; 

  В результате, мы получаем возможность неограниченно горизонтально скейлить Cube Worker и существенно ускорить рассчёт преагрегаций.

SQL API

  А напоследок, поговорим о самом вкусном — SQL API в Cube. Cube стабильно работал в продакшене: графики строились, таблицы рисовались. Но что нам опять не понравилось? Всё описанное выше отлично работает, но мы захотели ещё больше гибкости. К сожалению, часть про формирование SQL на основе запросов тянет на отдельную статью, так что если интересно,  пишите в комментариях, а здесь расскажу про основы.

  Во-первых, внезапно, 50к строк и мегабайты данных гонять на фронт не очень нормально, нужно получать с бэкенда уже отсортированные данные с пагинацией.

  Во-вторых, хочется иметь возможность получать с бэкенда кастомные метрики, например, разницу между какими-то двумя метриками или дифф по датам, при этом бекенд должен уметь всё это сортировать ещё по любому выбранному полю.

  Через REST API делать такое нельзя. Там даже нельзя запросить разницу между двумя метриками, что, в принципе, и нормально, этот API для таких вещей и не предназначен.

  Фактически, SQL API это API, которое совместимо с драйвером PostgreSQL и при помощи которого можно строить практически любые запросы. Приведу пример, как мы формируем запрос:

const firstQuery = `SELECT      ${[     `${         hasDimension ? dimension : "'none'"     } as "${firstIntervalPrefix}dimension"`,     ...selectMeasures,     ...selectAdditionalMetrics, ].join(',\n')}      FROM Report WHERE Report.date > '{{ DATE_FROM }}' AND  Report.date < '{{ DATE_TO }}' ${     filter ? `AND ${converFilterIntoSQL(filter)}` : '' }      ${hasDimension ? `GROUP BY "${firstIntervalPrefix}dimension"` : ''}    `;  if (!secondDateRange) {     return firstQuery         .replace(/\{\{ DATE_FROM \}\}/, firstDateRange.from)         .replace(/\{\{ DATE_TO \}\}/, firstDateRange.to); }

  Сами метрики определяются подобным образом:

const reportAttributionFieldsMap = new Map([  [ReportAttributionMetric.cost, 'MEASURE(Report.cost)'],  [ReportAttributionMetric.clicks, 'MEASURE(Report.clicks)'],  [ReportAttributionMetric.impressions, 'MEASURE(Report.impressions)'],  [ReportAttributionMetric.users, 'MEASURE(Report.visitors)'],  [ReportAttributionMetric.sessions, 'MEASURE(Report.visits)'],  [ReportAttributionMetric.timeOnSite, 'MEASURE(Report.timeOnSite)'], ]);

  Что на счёт additionalMetrics, так это фактически набор переменных и действий над этими переменными, где в качестве переменных выступают дефолтные метрики.

  Не было бы лимита в 50к строк — было бы совсем хорошо.

  А вот с получением метаданных у нас возникла проблема. Как вы помните, у Cube нет эндпоинта для получения метаданных по преагрегациям, а из SQL API это не вытащишь (по крайней мере в документации этого нет). Здесь пришлось использовать уже привычный REST API и обогащать результат полученными метаданными:

const restResult = await cubeJSClient     .query(         queries.dashboard.pingQuery.query([now, oneWeekAgo], conversionsIds),     )     .catch(() => undefined);  const sqlResult = await this.cubejsSQLAPI.requestTable(     projectId,     conversionsIds,     {         ...params,         disablePreaggregation:             params.disablePreaggregation ||             restResult.buildingPreAggregations ||             false,     }, );  return {     lastRefreshTime: restResult?.lastRefreshTime || new Date(),     dimension: params.dimension,     buildingPreAggregations: restResult?.buildingPreAggregations || false,     rows: sqlResult.rows,     totalRows: sqlResult.totalRows, }; 

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

  Ниже можно увидеть схему, как в итоге всё это работает.

 Вместо заключения

  Спасибо за то, что потратили время и прочитали эту статью, я надеюсь, что это было полезно. Уверен, что многое осталось за кадром, так что не стесняйтесь задавать любые вопросы в комментариях, с удовольствием на них отвечу.

  Подводя итог можно сказать, что Cube очень удобный и полезный инструмент для построения отчётов и работы с данными. Да, в нашем случае “из коробки” он работает не так хорошо, как хотелось бы, и, ещё много чего нужно доделать, но при желании это всё реализуемо, и использование Cube снимает очень много вопросов. И кстати, всё описанное с успехом работает как в k8s, так и в Cube Cloud.

  Отдельное спасибо команде Cube за оперативные ответы, своевременные фиксы багов и ревью это статьи.

  А вот такой красивый фронт у нас получился под бекенд Cube SQL API:


ссылка на оригинал статьи https://habr.com/ru/post/690996/

Rust должен умереть, МГУ сделал замеры

В предыдущих сериях:

Медленно, но верно Раст проникает не только в умы сотрудников больших корпораций, но и в умы школьников и студентов. В этот раз мы поговорим о статье от студента МГУ: https://rustmustdie.com/.

Её репостнул Андрей Викторович Столяров, доцент кафедры алгоритмических языков факультета ВМК МГУ им. М. В. Ломоносова и по совместительству научрук студента-автора статьи.

Я бы сказал, что тут дело даже не в том, что он «неинтуитивный». Дело скорее в том, что компилятор раста сам решает, когда владение «должно» (с его, компилятора, точки зрения) перейти от одного игрока к другому. А решать это вообще-то должен программист, а не компилятор. Ну и начинается пляска вида «как заставить тупой компайлер сделать то, чего я хочу».
Бред это всё.

— А. В. Столяров

Сама статья короткая, но постулирует довольно большой список спорных утверждений, а именно:

  • Стандартная библиотека неотделима от языка
  • У него отсутствует нулевой рантайм
  • В Rust встроен сборщик мусора
  • Компилятор генерирует медленный машинный код

На самом деле набросов еще больше, но достаточно и этого списка.

К сожалению, для опровержения этих пунктов мне придется писать максимально уродские хэлло ворлды, которые только можно представить.

Содержание

Опускаемся на самый низ

Нулевой рантайм в Си

Честно говоря, до прочтения статьи я ни разу не встречал такого определения как zero runtime. Немного погуглив, я наткнулся на книгу А. В. Столярова ISBN 978-5-317-06575-7 Программирование: введение в профессию. II: Системы и сети, изданной в 2021 году. В главе «§4.12: (*) Программа на Си без стандартной библиотеки» приводится определение нулевого рантайма и пример программы.

Реализация подпрограммы _start (под Linux i386):

start.asm

global _start       ; no_libc/start.asm extern main section     .text _start:     mov ecx, [esp]  ; argc in ecx     mov eax, esp     add eax, 4      ; argv in eax     push eax     push ecx     call main     add esp, 8      ; clean the stack     mov ebx, eax    ; now call _exit     mov eax, 1     int 80h

Модуль с «обертками» для системных вызовов:

calls.asm

global sys_read     ; no_libc/calls.asm global sys_write global sys_errno  section .text  generic_syscall_3:     push ebp     mov ebp, esp     push ebx     mov ebx, [ebp+8]     mov ecx, [ebp+12]     mov edx, [ebp+16]     int 80h     mov edx, eax     and edx, 0fffff000h     cmp edx, 0fffff000h     jnz .okay     mov [sys_errno], eax     mov eax, -1 .okay:     pop ebx     mov esp, ebp     pop ebp     ret  sys_read:     mov eax, 3     jmp generic_syscall_3  sys_write:     mov eax, 4     jmp generic_syscall_3  section .bss sys_errno resd 1

Простенькая программа, которая принимает ровно один параметр командной строки, рассматривает его как имя и здоровается с человеком, чьё имя указано, фразой Hello, dear NNN (имя подставляется вместо NNN):

greet3.c

/* no_libc/greet3.c */ int sys_write(int fd, const void *buf, int size);  static const char dunno[] = "I don't know how to greet you\n"; static const char hello[] = "Hello, dear ";  static int string_length(const char *s) {   int i = 0;   while(s[i])     i++;   return i; }  int main(int argc, char **argv) {   if(argc < 2) {     sys_write(1, dunno, sizeof(dunno)-1);     return 1;   }   sys_write(1, hello, sizeof(hello)-1);   sys_write(1, argv[1], string_length(argv[1]));   sys_write(1, "\n", 1);   return 0; }

И сама сборка:

nasm -f elf start.asm nasm -f elf calls.asm gcc -m32 -Wall -c greet3.c ld -melf_i386 start.o calls.o greet3.o -o greet3

На машине автора этих строк (Столярова) размер файла составил 816 байт. На моей машине 13472 байта.

Что ж, применим clang-14, ld.lld-14, -Os и strip; и на моей машине получилось 1132 байта:

nasm -f elf start.asm nasm -f elf calls.asm clang-14 -m32 -Os -Wall -c greet3.c ld.lld-14 -melf_i386 start.o calls.o greet3.o -o greet3 strip ./greet3

В своей книге Столяров делает очень сильное утверждение, а именно:

Но дело даже не в этой экономии (размера исполняемого файла — Прим. авт.)…
Намного важнее сам принцип: язык Си позволяет полностью отказаться от возможностей стандартной библиотеки. Кроме Си, таким свойством — абсолютной независимостью от библиотечного кода, также иногда называемым zero runtime — обладают на сегодняшний день только языки ассемблеров; ни один язык высокого уровня не предоставляет такой возможности.

Что ж, давайте разберемся, обладает ли Раст таким свойством.

Из чего состоит хэлло ворлд

Рассмотрим базовый пример, приведённый на официальном сайте языка Раст:

fn main() {   println!("Hello, world!"); }

Так как println! — это макрос, а не функция, у нас есть возможность посмотреть на код после раскрытия макроса. Для этого воспользуемся утилитой cargo-expand:

#![feature(prelude_import)] #[prelude_import] use std::prelude::rust_2021::*; #[macro_use] extern crate std; fn main() {   {     ::std::io::_print(::core::fmt::Arguments::new_v1(       &["Hello, world!\n"],       &match () {         _args => [],       },     ));   }; }

Компилятор вставил импорт стандартной библиотеки extern crate std; и прелюдию use std::prelude::rust_2021::*;. Именно эти неявные вставки я и хотел показать.

Стандартная библиотека — это удобный набор функций, коллекций, структур и типажей в окружении, когда у тебя есть ос, фс, куча, сокеты и прочая хипстота. Считается, что 93.9% программистам именно такое поведение (автоматическое включение std и прелюдии) и требуется.

Весь API стандартной библиотеки подробно описан в официальной документации. Есть удобный быстропоиск: https://std.rs/QUERY, где QUERY — ваш запрос, например https://std.rs/mutex.

Отключаем std

Тем не менее, для остальных 19% программистов предусмотрен режим отключения стандартной библиотеки с помощью атрибута #![no_std].

#![no_std] #![feature(start, lang_items)]  // Говорим компилятору влинковать libc #[cfg(target_os = "linux")] #[link(name = "c")]  extern "C" {   // Объявляем внешнюю функцию из libc   fn puts(s: *const u8) -> i32; }  #[start] // Говорим, что выполнение надо начинать с этого символа fn main(_argc: isize, _argv: *const *const u8) -> isize {   unsafe {     // В Расте строки не нуль-терминированные     puts("Hello, world!\0".as_ptr());   }   return 0; }  #[panic_handler] // Удовлетворяем компилятор fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {   loop {} }  #[lang = "eh_personality"] // Удовлетворяем компилятор extern "C" fn eh_personality() {}

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

$ cat rust-toolchain.toml  [toolchain] channel = "nightly-2022-06-09"

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

А в Cargo.toml добавить отключение размотки, все равно она в коде нигде не будет использоваться:

[profile.dev] panic = "abort"  [profile.release] panic = "abort"

Для этого хэлло ворлда cargo-expand покажет следующее:

#[prelude_import] use core::prelude::rust_2021::*; #[macro_use] extern crate core; ...

То есть компилятор неявно вставил импорт библиотеки core (extern crate core;) и прелюдию core (use core::prelude::rust_2021::*;).

Ниже представлена сводная таблица, описывающая разницу между core и std.

Функциональность core std
динамическое выделение памяти нет *1 да
коллекции (Vec, HashMap и т.д.) нет *2 да
доступ к std нет да
доступ к core да да
низкоуровневая разработка да нет

  1. да, если используется крейт alloc и настроен глобальный аллокатор;
  2. да, если коллекции тоже #![no_std] и зависят от core.

Большинство структур и типажей стандартной библиотеки описываются именно в core, а не в std:

  • Методы примитивов bool, i32…;
  • Типы Range, Option, Result, Cell, RefCell, PhantomData…;
  • Типажи Hash, Drop, Debug, Iterator, Future, Unpin…;
  • Функции forget, drop, swap

Отключаем core

Мы не ищем лёгких путей, поэтому мы отключим и std, и core с помощью атрибута #![no_core]. Такая функциональность по разным оценкам требуется от 3577 до 4518 людям в мире на момент написания статьи (именно столько людей контрибутят в компилятор Раста, но github даёт одни цифры, а git log --format="%an" | sort -u | wc -l другие). Вы же не думаете, что я тут беру статистику с потолка?

#![feature(no_core)] #![feature(lang_items)]  #![no_core]  // Говорим компилятору влинковать libc #[cfg(target_os = "linux")] #[link(name = "c")] extern {}  // Функция `main` на самом деле не точка входа, а вот `start` - да. #[lang = "start"] fn start<T>(_main: fn() -> T, _argc: isize, _argv: *const *const u8) -> isize {   42 }  // Втыкаем символ, чтобы не получить ошибку undefined reference to `main' fn main() { }  // Нужно компилятору #[lang = "sized"] pub trait Sized {}

Проверить работоспособность можно только по коду возврата: echo $? должен вернуть 42.

Мы почти добрались до самого низа. У нас нет возможности складывать числа, если попробовать их сложить, будет ошибка:

error[E0369]: cannot add `{integer}` to `{integer}`   --> src/main.rs:14:8    | 14 |     40 + 2    |     -- ^ - {integer}    |     |    |     {integer}

Да ничего у нас нет, только определение примитивов i8, usize, str, но работать с ними нельзя.

Отключаем crt

Rust компилирует объектные файлы самостоятельно, но использует внешний (обычно это системный) линковщик. По умолчанию линковщик добавляет *crt*.o, в которых определяется стартовый символ (_start), но этот символ можно переопределить. Для этого отключаем сишный рантайм:

$ cargo rustc -- -C link-args=-nostartfiles

Или с помощью конфига в корне проекта можно задать флаги линковки:

$ cat .cargo/config  [build] rustflags = ["-C", "link-args=-nostartfiles"]

Тогда с .cargo/config и rust-toolchain.toml файлом сборка проекта осуществляется короткой командой cargo build. Ну или вы можете вбивать cargo +nightly-2022-06-09 rustc -- -C link-args=-nostartfiles.

Вид нашего хэлло ворлда приобретает форму:

#![feature(no_core)] #![feature(lang_items)] #![no_core] #![no_main]  #[no_mangle] extern "C" fn _start() {}  // Нужно компилятору #[lang = "sized"] pub trait Sized {}

Девственный ассемблер:

$ objdump -Cd ./target/debug/hello_world  ./target/debug/hello_world:     file format elf64-x86-64  Disassembly of section .text:  0000000000001000 <_start>:     1000:   c3                      retq

Компилируем и запускаем:

$ cargo run     Finished dev [unoptimized + debuginfo] target(s) in 0.00s      Running `target/debug/hello_world` Illegal instruction (core dumped)

Прекрасно. С этим можно начинать работать.

Пишем хэлло ворлд

Вообще до мейна происходит очень много интересного: инициализация статиков, профилировщика. Советую посмотреть доклад Мэтта Годболта:

https://www.youtube.com/watch?v=dOfucXtyEsU

Мы же напишем простой _start с прыжком в _start_main, который и будет вызывать функцию main. Подложка в виде _start_main нужна, чтобы можно было положиться на компилятор в вопросах передачи аргументов и очистки стека.

Символ _start

Его мы будем писать на ассемблере. В std/core препроцессор ассемблерных вставок включается по умолчанию, а вот нам надо включить его явно.

#![feature(decl_macro)] #![feature(rustc_attrs)] #[rustc_builtin_macro] pub macro asm("assembly template", $(operands,)* $(options($(option),*))?) {   /* compiler built-in */ }

_start — это специальная функция, которой не требуется пролог и эпилог, поэтому ее надо пометить как naked.

#![feature(naked_functions)]  #[no_mangle] #[naked] unsafe extern "C" fn _start() {   // Стырено из книги А.В. Столярова.   // А, простите, там код под 32 бита, в книге 2021 года.   // Значит, не стырено.   asm!(     "mov rdi, [rsp]", // argc     "mov rax, rsp",     "add rax, 8",     "mov rsi, rax", // argv     "call _start_main",     options(noreturn),   ) }  #[no_mangle] extern "C" fn _start_main(argc: usize, argv: *const *const u8) -> isize {   main(argc, argv);   0 }  #[no_mangle] fn main(_argc: usize, _argv: *const *const u8) -> isize {   // И вот мы добрались до мейна   return 0; }

Компилируем и запускаем: Illegal instruction (core dumped). Я чую, что мы на правильном пути!

Сисколы

Всего нам понадобится два сискола: exit и write.

«Подложки» для сисколлов я хочу реализовать в общем виде, чтобы они принимали номер сисколла и аргументы (syscall1 — 1 аргумент, syscall3 — 3 аргумента).

man 2 syscall дает нам следующую информацию:

Architecture calling conventions

Every  architecture has its own way of invoking and passing arguments to the kernel.  The details for various architectures are listed in the two tables below.  The first table lists the instruction used to transition to kernel mode (which might not be the fastest or best way to transition to the kernel, so  you might have to refer to vdso(7)), the register used to indicate the system call number, the register(s) used to return the system call result, and the register used to signal an error.  Arch/ABI    Instruction           System  Ret  Ret  Error    Notes                                   call #  val  val2 ─────────────────────────────────────────────────────────────────── i386        int $0x80             eax     eax  edx  - x86-64      syscall               rax     rax  rdx  -        5  The second table shows the registers used to pass the system call arguments.  Arch/ABI      arg1  arg2  arg3  arg4  arg5  arg6  arg7  Notes ────────────────────────────────────────────────────────────── i386          ebx   ecx   edx   esi   edi   ebp   - x86-64        rdi   rsi   rdx   r10   r8    r9    -

Завершение процесса

У данного системного вызова есть замечательное свойство — он никогда не возвращается. Этот факт можно использовать с помощью типов и интринзиков, чтобы дать понять компилятору, что любой код после данного сискола никогда не будет выполнен. Это реализуется через тип ! (never) и интринзик unreachable:

#![feature(intrinsics)] // подключаем фичу объявления интринзиков  extern "rust-intrinsic" {   // Чтобы компилятор знал, что есть некоторый код, которого не достичь.   // Например, весь код после exit()   pub fn unreachable() -> !; }  #[no_mangle] extern "C" fn _start_main(argc: usize, argv: *const *const u8) -> ! {   let status = main(argc, argv);   exit(status); }  #[inline(never)] #[no_mangle] // ! - это never type, компилятор понимает, что функция никогда не возвращается fn exit(exit_code: i64) -> ! {   unsafe {     syscall1(60, exit_code);     unreachable()   } }  #[inline(always)] unsafe fn syscall1(n: i64, a1: i64) -> i64 {   let ret: i64;   asm!(     "syscall",     in("rax") n,     in("rdi") a1,     lateout("rax") ret,   );   ret }

Если запустить получившийся бинарник, echo $? вернет ожидаемый 0.

Запись в файл

Настало время реализовать вывод «Hello, world!» в стандартный поток вывода! \<Не забыть изменить на менее глупую фразу перед публикацией>.

#[no_mangle] fn main(_argc: usize, _argv: *const *const u8) -> i64 {   let string = b"Hello, world!\n" as *const _ as *const u8;   write(1, string, 14);   return 0; }  #[inline(never)] #[no_mangle] fn write(fd: i64, data: *const u8, len: i64) -> i64 {   unsafe { syscall3(1, fd, data as i64, len) } }  #[inline(always)] unsafe fn syscall3(n: i64, a1: i64, a2: i64, a3: i64) -> i64 {   let ret: i64;   asm!(     "syscall",     in("rax") n,     in("rdi") a1,     in("rsi") a2,     in("rdx") a3,     lateout("rax") ret,   );   ret }

Хелло ворлд на Расте под Linux x86_64 целиком

#![feature(no_core)] #![feature(lang_items)] #![no_core] #![no_main] #![feature(naked_functions)] #![feature(decl_macro)] #![feature(rustc_attrs)] #![feature(intrinsics)]  // Нужно компилятору #[lang = "sized"] pub trait Sized {}  #[lang = "copy"] pub trait Copy {}  impl Copy for i64 {} // Говорим компилятору, что объект этого типа можно копировать байт за байтом impl Copy for usize {}  #[rustc_builtin_macro] pub macro asm("assembly template", $(operands,)* $(options($(option),*))?) {   /* compiler built-in */ }  extern "rust-intrinsic" {   // Чтобы компилятор знал, что есть некоторый код, которого не достичь.   // Например, весь код после exit()   pub fn unreachable() -> !; }  #[no_mangle] #[naked] unsafe extern "C" fn _start() {   // Стырено из книги А.В. Столярова.   // А, простите, там код под 32 бита, в книге 2021 года.   // Значит, не стырено.   asm!(     "mov rdi, [rsp]", // argc     "mov rax, rsp",     "add rax, 8",     "mov rsi, rax", // argv     "call _start_main",     options(noreturn),   ) }  #[no_mangle] extern "C" fn _start_main(argc: usize, argv: *const *const u8) -> ! {   let status = main(argc, argv);   exit(status); }  #[no_mangle] fn main(_argc: usize, _argv: *const *const u8) -> i64 {   let string = b"Hello, world!\n" as *const _ as *const u8;   write(1, string, 14);   return 0; }  #[inline(never)] #[no_mangle] // ! - это never type, компилятор понимает, что функция никогда не возвращается fn exit(status: i64) -> ! {   unsafe {     syscall1(60, status);     unreachable()   } }  #[inline(never)] #[no_mangle] fn write(fd: i64, data: *const u8, len: i64) -> i64 {   unsafe { syscall3(1, fd, data as i64, len) } }  #[inline(always)] unsafe fn syscall1(n: i64, a1: i64) -> i64 {   let ret: i64;   asm!(     "syscall",     in("rax") n,     in("rdi") a1,     lateout("rax") ret,   );   ret }  #[inline(always)] unsafe fn syscall3(n: i64, a1: i64, a2: i64, a3: i64) -> i64 {   let ret: i64;   asm!(     "syscall",     in("rax") n,     in("rdi") a1,     in("rsi") a2,     in("rdx") a3,     lateout("rax") ret,   );   ret }

Запускаем и проверяем:

$ cargo r     Finished dev [unoptimized + debuginfo] target(s) in 0.01s      Running `target/debug/hello_world` Hello, world! $ echo $? 0 $ strip ./target/debug/hello_world $ stat -c %s ./target/debug/hello_world 13096

Оно работает! Но размер бинарника 13096 байт. Что ж, применим ld.lld-14:

$ cat .cargo/config  [build] rustflags = ["-C", "linker=ld.lld-14"]

$ cargo r    Compiling hello_world v0.1.0 (/home/USER/rustmustdie/article/chapter_4)     Finished dev [unoptimized + debuginfo] target(s) in 0.13s      Running `target/debug/hello_world` Hello, world! $ echo $? 0 $ strip ./target/debug/hello_world $ stat -c %s ./target/debug/hello_world 1712

Уии!

То есть нет =( Получилось 1712 байт против 1132 байт сишной реализации. Не забываем, что в сишной реализации вообще другой код, он хитрый, с непростым приветствием, то есть у него больше функциональность, но меньше размер.

Приводим к общему знаменателю

Вот было бы здорово, если бы у нас был:

  • Единый компилятор (gcc),
  • Единый линковщик (ld.lld-14),
  • Одни и те же флаги компиляции -Os -masm=intel -m32 -fno-pic -fno-asynchronous-unwind-tables,
  • Одни и те же флаги линковки --no-pie --no-dynamic-linker,
  • Да и код, выполняющий одну и ту же программу, не правда ли?
  • Чтобы был _start с прыжком в _start_main, который и будет вызывать функцию main,
  • Чтобы было два сискола sys_exit и sys_write (именование из книги Столярова),
  • Чтобы они были реализованы через обобщение сисколов syscall1 и syscall3.

Жаль, что все вместе это невозможно… Or is it?


Компилируем gcc и rustc_codegen_gcc

Архитектура компилятора rustc позволяет подключить не только бекенд llvm, но и gcc. Проект, который занимается поддержкой gcc, называется rustc_codegen_gcc. Конечно же не все так просто, с ним надо провести профекалтическую работу.

$ sudo apt install flex make gawk libgmp-dev libmpfr-dev libmpc-dev gcc-multilib

Клонируем rustc_codegen_gcc, патченный gcc и собираем gcc с поддержкой i386:

# У меня версия 1724042e228c3 от Wed Sep 14 09:22:50 2022 $ git clone https://github.com/rust-lang/rustc_codegen_gcc.git --depth 1 rustc_codegen_gcc$ cd rustc_codegen_gcc  #BUILD GCC (20 mins) rustc_codegen_gcc$ git clone https://github.com/antoyo/gcc.git --depth 1 rustc_codegen_gcc$ cd gcc rustc_codegen_gcc/gcc$ mkdir build install rustc_codegen_gcc/gcc$ cd build rustc_codegen_gcc/gcc/build$ ../configure --enable-host-shared --enable-languages=jit,c --disable-bootstrap --enable-multilib --target=x86_64-pc-linux-gnu --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64 --enable-multiarch --prefix=$(pwd)/../install rustc_codegen_gcc/gcc/build$ make -j8 rustc_codegen_gcc/gcc/build$ make install # в папочку ../install rustc_codegen_gcc/gcc/build$ cd ../../ rustc_codegen_gcc$ echo $(pwd)/gcc/install/lib/ > gcc_path

Мастер ветка пока что не поддерживает i386 из коробки, но это можно исправить:

Патч rustc_codegen_gcc, чтобы заработал i386

diff --git a/config.sh b/config.sh index b25e215..18574f2 100644 --- a/config.sh +++ b/config.sh @@ -20,8 +20,9 @@ else  fi   HOST_TRIPLE=$(rustc -vV | grep host | cut -d: -f2 | tr -d " ") -TARGET_TRIPLE=$HOST_TRIPLE +#TARGET_TRIPLE=$HOST_TRIPLE  #TARGET_TRIPLE="m68k-unknown-linux-gnu" +TARGET_TRIPLE="i686-unknown-linux-gnu"   linker=''  RUN_WRAPPER='' @@ -33,6 +34,8 @@ if [[ "$HOST_TRIPLE" != "$TARGET_TRIPLE" ]]; then        # We are cross-compiling for aarch64. Use the correct linker and run tests in qemu.        linker='-Clinker=aarch64-linux-gnu-gcc'        RUN_WRAPPER='qemu-aarch64 -L /usr/aarch64-linux-gnu' +   elif [[ "$TARGET_TRIPLE" == "i686-unknown-linux-gnu" ]]; then +      : # do nothing     else        echo "Unknown non-native platform"     fi diff --git a/src/back/write.rs b/src/back/write.rs index efcf18d..e640fbe 100644 --- a/src/back/write.rs +++ b/src/back/write.rs @@ -14,6 +14,8 @@ pub(crate) unsafe fn codegen(cgcx: &CodegenContext<GccCodegenBackend>, _diag_han      let _timer = cgcx.prof.generic_activity_with_arg("LLVM_module_codegen", &*module.name);      {          let context = &module.module_llvm.context; +        context.add_command_line_option("-m32"); +        context.add_driver_option("-m32");           let module_name = module.name.clone();          let module_name = Some(&module_name[..]); diff --git a/src/base.rs b/src/base.rs index 8cc9581..fb8bd88 100644 --- a/src/base.rs +++ b/src/base.rs @@ -98,7 +98,7 @@ pub fn compile_codegen_unit<'tcx>(tcx: TyCtxt<'tcx>, cgu_name: Symbol, supports_          context.add_command_line_option("-mpclmul");          context.add_command_line_option("-mfma");          context.add_command_line_option("-mfma4"); -        context.add_command_line_option("-m64"); +        context.add_command_line_option("-m32");          context.add_command_line_option("-mbmi");          context.add_command_line_option("-mgfni");          context.add_command_line_option("-mavxvnni"); diff --git a/src/context.rs b/src/context.rs index 2699559..056352a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -161,13 +161,13 @@ impl<'gcc, 'tcx> CodegenCx<'gcc, 'tcx> {          let ulonglong_type = context.new_c_type(CType::ULongLong);          let sizet_type = context.new_c_type(CType::SizeT);  -        let isize_type = context.new_c_type(CType::LongLong); -        let usize_type = context.new_c_type(CType::ULongLong); +        let isize_type = context.new_c_type(CType::Int); +        let usize_type = context.new_c_type(CType::UInt);          let bool_type = context.new_type::<bool>();           // TODO(antoyo): only have those assertions on x86_64. -        assert_eq!(isize_type.get_size(), i64_type.get_size()); -        assert_eq!(usize_type.get_size(), u64_type.get_size()); +        assert_eq!(isize_type.get_size(), i32_type.get_size()); +        assert_eq!(usize_type.get_size(), u32_type.get_size());           let mut functions = FxHashMap::default();          let builtins = [ diff --git a/src/int.rs b/src/int.rs index 0c5dab0..5fd4925 100644 --- a/src/int.rs +++ b/src/int.rs @@ -524,7 +524,7 @@ impl<'a, 'gcc, 'tcx> Builder<'a, 'gcc, 'tcx> {          // when having proper sized integer types.          let param_type = bswap.get_param(0).to_rvalue().get_type();          if param_type != arg_type { -            arg = self.bitcast(arg, param_type); +            arg = self.cx.context.new_cast(None, arg, param_type);          }          self.cx.context.new_call(None, bswap, &[arg])      } diff --git a/src/lib.rs b/src/lib.rs index e43ee5c..8fb5823 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -104,6 +104,7 @@ impl CodegenBackend for GccCodegenBackend {          let temp_dir = TempDir::new().expect("cannot create temporary directory");          let temp_file = temp_dir.into_path().join("result.asm");          let check_context = Context::default(); +        check_context.add_command_line_option("-m32");          check_context.set_print_errors_to_stderr(false);          let _int128_ty = check_context.new_c_type(CType::UInt128t);          // NOTE: we cannot just call compile() as this would require other files than libgccjit.so.

И поверх этого патча надо применить еще один, чтобы libgccjit.so компилировал только с нужным набором флагов:

Патч rustc_codegen_gcc для унификации флагов

diff --git a/src/base.rs b/src/base.rs index fb8bd88..d5268dc 100644 --- a/src/base.rs +++ b/src/base.rs @@ -87,29 +87,11 @@ pub fn compile_codegen_unit<'tcx>(tcx: TyCtxt<'tcx>, cgu_name: Symbol, supports_          // Instantiate monomorphizations without filling out definitions yet...          //let llvm_module = ModuleLlvm::new(tcx, &cgu_name.as_str());          let context = Context::default(); -        // TODO(antoyo): only set on x86 platforms.          context.add_command_line_option("-masm=intel"); -        // TODO(antoyo): only add the following cli argument if the feature is supported. -        context.add_command_line_option("-msse2"); -        context.add_command_line_option("-mavx2"); -        // FIXME(antoyo): the following causes an illegal instruction on vmovdqu64 in std_example on my CPU. -        // Only add if the CPU supports it. -        context.add_command_line_option("-msha"); -        context.add_command_line_option("-mpclmul"); -        context.add_command_line_option("-mfma"); -        context.add_command_line_option("-mfma4");          context.add_command_line_option("-m32"); -        context.add_command_line_option("-mbmi"); -        context.add_command_line_option("-mgfni"); -        context.add_command_line_option("-mavxvnni"); -        context.add_command_line_option("-mf16c"); -        context.add_command_line_option("-maes"); -        context.add_command_line_option("-mxsavec"); -        context.add_command_line_option("-mbmi2"); -        context.add_command_line_option("-mrtm"); -        context.add_command_line_option("-mvaes"); -        context.add_command_line_option("-mvpclmulqdq"); -        context.add_command_line_option("-mavx"); +        context.add_command_line_option("-fno-pic"); +        context.add_command_line_option("-fno-asynchronous-unwind-tables"); +        context.add_command_line_option("-Os");           for arg in &tcx.sess.opts.cg.llvm_args {              context.add_command_line_option(arg);

Клонируем llvm и собираем rustc_codegen_gcc:

#BUILD RUSTC: (5 mins) rustc_codegen_gcc$ git clone https://github.com/llvm/llvm-project llvm --depth 1 --single-branch rustc_codegen_gcc$ export RUST_COMPILER_RT_ROOT="$PWD/llvm/compiler-rt" rustc_codegen_gcc$ ./prepare_build.sh # download and patch sysroot src rustc_codegen_gcc$ ./build.sh

Всё, теперь у нас есть собранный своими ручками компилятор Си (~/rustc_codegen_gcc/gcc/install/bin/gcc), libgccjit.so для компиляции Раста c захардкоженными флагами -Os -masm=intel -m32 -fno-pic -fno-asynchronous-unwind-tables и скрипт ~/rustc_codegen_gcc/cargo.sh, который подсовывает фронтенду rustc бекенд gcc.

Хэлло ворлд на Си под i386

Код

int sys_write(int fd, const void *buf, int size); void sys_exit(int status); static int main(int argc, char **argv); static int syscall1(int n, int a1); static int syscall3(int n, int a1, int a2, int a3);  static const char hello[] = "Hello, world!\n";  void _Noreturn __attribute__((naked)) _start() {   __asm volatile (     "_start:\n"     "  mov ecx, [esp]\n"     "  mov eax, esp\n"     "  add eax, 4\n"     "  push eax\n"     "  push ecx\n"     "  call _start_main\n"   ); }  void _Noreturn _start_main(int argc, char **argv) {   int status = main(argc, argv);   sys_exit(status); }  static int main(int argc, char **argv) {   sys_write(1, hello, sizeof(hello)-1);   return 0; }  void _Noreturn __attribute__ ((noinline)) sys_exit(int status) {   syscall1(1, status);   __builtin_unreachable(); }  int __attribute__ ((noinline)) sys_write(int fd, const void *buf, int size) {   return syscall3(4, fd, (int) buf, size); }  static int syscall1(int n, int a1) {   int ret;   __asm volatile (     "  int 0x80"     : "=a" (ret)     : "0" (n), "b" (a1)     : "memory"   );   return ret; }  static int syscall3(int n, int a1, int a2, int a3) {   int ret;   __asm volatile (     "  int 0x80"     : "=a" (ret)     : "0" (n), "b" (a1), "c" (a2), "d" (a3)     : "memory"   );   return ret; }

Все эти приседания с _Noreturn, static, __attribute__((naked)) прямое отражение того, что было в коде на Расте. Т.е. говорим компилятору, что из sys_exit нельзя выйти, static — для красивого инлайна (и чтобы в итоговом бинаре отсутствовал такой символ), а __attribute__((naked)) — чтобы компилятор не вставил пролог и эпилог для _start.

Сборка:

~/rustc_codegen_gcc/gcc/install/bin/gcc -Os -masm=intel -m32 -fno-pic -fno-asynchronous-unwind-tables -Wall -Wno-main -c hello_world.c ld.lld-14 --no-pie --no-dynamic-linker hello_world.o -o hello_world strip hello_world objcopy -j.text -j.rodata hello_world

Проверяем:

$ ./build.sh $ ./hello_world  Hello, world!

Хэлло ворлд на Расте под i386

Код

#![feature(no_core)] #![feature(lang_items)] #![feature(naked_functions)] #![feature(decl_macro)] #![feature(rustc_attrs)] #![feature(intrinsics)] #![no_core] #![no_main]  #[lang = "sized"] pub trait Sized {}  #[lang = "copy"] pub trait Copy {}  impl Copy for i32 {} impl Copy for usize {}  #[rustc_builtin_macro] pub macro asm("assembly template", $(operands,)* $(options($(option),*))?) {   /* compiler built-in */ }  extern "rust-intrinsic" {   pub fn unreachable() -> !; }  #[no_mangle] #[naked] unsafe extern "C" fn _start() {   asm!(     "mov ecx, [esp]",     "mov eax, esp",     "add eax, 4",     "push eax",     "push ecx",     "call _start_main",     options(noreturn),   ) }  #[no_mangle] extern "C" fn _start_main(argc: usize, argv: *const *const u8) -> ! {   let status = main(argc, argv);   sys_exit(status); }  #[no_mangle] fn main(_argc: usize, _argv: *const *const u8) -> i32 {   let string = b"Hello, world!\n" as *const _ as *const u8;   sys_write(1, string, 14);   return 0; }  #[inline(never)] #[no_mangle] fn sys_write(fd: i32, data: *const u8, len: i32) -> i32 {   unsafe { syscall3(4, fd, data as _, len) } }  #[inline(never)] #[no_mangle] fn sys_exit(status: i32) -> ! {   unsafe {     syscall1(1, status);     unreachable()   } }  #[inline(always)] unsafe extern "C" fn syscall1(n: i32, a1: i32) -> i32 {   let ret: i32;   asm!(     "int 0x80",     in("eax") n,     in("ebx") a1,     lateout("eax") ret,   );   ret }  #[inline(always)] unsafe fn syscall3(n: i32, a1: i32, a2: i32, a3: i32) -> i32 {   let ret: i32;   asm!(     "int 0x80",     in("eax") n,     in("ebx") a1,     in("ecx") a2,     in("edx") a3,     lateout("eax") ret,   );   ret }

Сборка:

# cargo.sh, предоставляемый rustc_codegen_gcc, принимает только переменную окружения CG_RUSTFLAGS # поэтому в .cargo/config эти переменные не установить. Увы.  export CG_RUSTFLAGS="-C linker=ld.lld-14 -C link-args=--no-pie -C link-args=--no-dynamic-linker" ~/rustc_codegen_gcc/cargo.sh b --target i686-unknown-linux-gnu strip ./target/i686-unknown-linux-gnu/debug/hello_world objcopy -j.text -j.rodata ./target/i686-unknown-linux-gnu/debug/hello_world

Проверяем:

$ ./build.sh  rustc_codegen_gcc is build for rustc 1.65.0-nightly (748038961 2022-08-25) but the default rustc version is rustc 1.63.0-nightly (7466d5492 2022-06-08). Using rustc 1.65.0-nightly (748038961 2022-08-25).    Compiling hello_world v0.1.0 (/home/USER/rustmustdie/article/chapter_6)     Finished dev [unoptimized + debuginfo] target(s) in 0.84s $ ./target/i686-unknown-linux-gnu/debug/hello_world  Hello, world!

Сравнение

Си Раст
 $ stat -c %s hello_world 496 $ size -A hello_world hello_world  : section   size      addr .rodata     15   4194516 .text       84   4198627 Total       99 
 $ stat -c %s ./hello_world 464 $ size -A ./hello_world ./hello_world  : section   size      addr .rodata     14   4194484 .text       82   4198594 Total       96 

Вот так, размер файла на Расте получился 464 байта, а на Си — 494 байт. Предлагаю читателю самостоятельно ответить на вопрос, обладает ли Раст свойством абсолютной независимости от библиотечного кода, также иногда называемым zero runtime.

Для интересующихся, вот вся инфа о бинарях:

Си

$ objdump -Cd hello_world  hello_world:     file format elf32-i386  Disassembly of section .text:  004010e3 <_start>:   4010e3:   8b 0c 24                mov    (%esp),%ecx   4010e6:   89 e0                   mov    %esp,%eax   4010e8:   83 c0 04                add    $0x4,%eax   4010eb:   50                      push   %eax   4010ec:   51                      push   %ecx   4010ed:   e8 27 00 00 00          call   401119 <_start_main>   4010f2:   0f 0b                   ud2      004010f4 <sys_exit>:   4010f4:   55                      push   %ebp   4010f5:   b8 01 00 00 00          mov    $0x1,%eax   4010fa:   89 e5                   mov    %esp,%ebp   4010fc:   53                      push   %ebx   4010fd:   8b 5d 08                mov    0x8(%ebp),%ebx   401100:   cd 80                   int    $0x80  00401102 <sys_write>:   401102:   55                      push   %ebp   401103:   b8 04 00 00 00          mov    $0x4,%eax   401108:   89 e5                   mov    %esp,%ebp   40110a:   53                      push   %ebx   40110b:   8b 4d 0c                mov    0xc(%ebp),%ecx   40110e:   8b 55 10                mov    0x10(%ebp),%edx   401111:   8b 5d 08                mov    0x8(%ebp),%ebx   401114:   cd 80                   int    $0x80   401116:   5b                      pop    %ebx   401117:   5d                      pop    %ebp   401118:   c3                      ret      00401119 <_start_main>:   401119:   55                      push   %ebp   40111a:   89 e5                   mov    %esp,%ebp   40111c:   83 ec 0c                sub    $0xc,%esp   40111f:   6a 0e                   push   $0xe   401121:   68 d4 00 40 00          push   $0x4000d4   401126:   6a 01                   push   $0x1   401128:   e8 d5 ff ff ff          call   401102 <sys_write>   40112d:   31 c0                   xor    %eax,%eax   40112f:   89 04 24                mov    %eax,(%esp)   401132:   e8 bd ff ff ff          call   4010f4 <sys_exit>  $ readelf -a hello_world ELF Header:   Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00    Class:                             ELF32   Data:                              2s complement, little endian   Version:                           1 (current)   OS/ABI:                            UNIX - System V   ABI Version:                       0   Type:                              EXEC (Executable file)   Machine:                           Intel 80386   Version:                           0x1   Entry point address:               0x4010e3   Start of program headers:          52 (bytes into file)   Start of section headers:          336 (bytes into file)   Flags:                             0x0   Size of this header:               52 (bytes)   Size of program headers:           32 (bytes)   Number of program headers:         4   Size of section headers:           40 (bytes)   Number of section headers:         4   Section header string table index: 3  Section Headers:   [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al   [ 0]                   NULL            00000000 000000 000000 00      0   0  0   [ 1] .rodata           PROGBITS        004000d4 0000d4 00000f 00   A  0   0  4   [ 2] .text             PROGBITS        004010e3 0000e3 000054 00  AX  0   0  1   [ 3] .shstrtab         STRTAB          00000000 000137 000019 00      0   0  1 Key to Flags:   W (write), A (alloc), X (execute), M (merge), S (strings), I (info),   L (link order), O (extra OS processing required), G (group), T (TLS),   C (compressed), x (unknown), o (OS specific), E (exclude),   p (processor specific)  There are no section groups in this file.  Program Headers:   Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align   PHDR           0x000034 0x00400034 0x00400034 0x00080 0x00080 R   0x4   LOAD           0x000000 0x00400000 0x00400000 0x000e3 0x000e3 R   0x1000   LOAD           0x0000e3 0x004010e3 0x004010e3 0x00054 0x00054 R E 0x1000   GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0   Section to Segment mapping:   Segment Sections...    00         01     .rodata     02     .text     03       $ objdump -s ./hello_world  ./hello_world:     file format elf32-i386  Contents of section .rodata:  4000d4 48656c6c 6f2c2077 6f726c64 210a00    Hello, world!..  Contents of section .text:  4010e3 8b0c2489 e083c004 5051e827 0000000f  ..$.....PQ.''....  4010f3 0b55b801 00000089 e5538b5d 08cd8055  .U.......S.]...U  401103 b8040000 0089e553 8b4d0c8b 55108b5d  .......S.M..U..]  401113 08cd805b 5dc35589 e583ec0c 6a0e68d4  ...[].U.....j.h.  401123 0040006a 01e8d5ff ffff31c0 890424e8  .@.j......1...$.  401133 bdffffff                             ....                

Раст

$ objdump -Cd ./target/i686-unknown-linux-gnu/debug/hello_world  ./target/i686-unknown-linux-gnu/debug/hello_world:     file format elf32-i386  Disassembly of section .text:  004010c2 <_start>:   4010c2:   8b 0c 24                mov    (%esp),%ecx   4010c5:   89 e0                   mov    %esp,%eax   4010c7:   83 c0 04                add    $0x4,%eax   4010ca:   50                      push   %eax   4010cb:   51                      push   %ecx   4010cc:   e8 25 00 00 00          call   4010f6 <_start_main>  004010d1 <sys_write>:   4010d1:   55                      push   %ebp   4010d2:   b8 04 00 00 00          mov    $0x4,%eax   4010d7:   89 e5                   mov    %esp,%ebp   4010d9:   53                      push   %ebx   4010da:   8b 5d 08                mov    0x8(%ebp),%ebx   4010dd:   8b 4d 0c                mov    0xc(%ebp),%ecx   4010e0:   8b 55 10                mov    0x10(%ebp),%edx   4010e3:   cd 80                   int    $0x80   4010e5:   5b                      pop    %ebx   4010e6:   5d                      pop    %ebp   4010e7:   c3                      ret      004010e8 <sys_exit>:   4010e8:   55                      push   %ebp   4010e9:   b8 01 00 00 00          mov    $0x1,%eax   4010ee:   89 e5                   mov    %esp,%ebp   4010f0:   53                      push   %ebx   4010f1:   8b 5d 08                mov    0x8(%ebp),%ebx   4010f4:   cd 80                   int    $0x80  004010f6 <_start_main>:   4010f6:   55                      push   %ebp   4010f7:   89 e5                   mov    %esp,%ebp   4010f9:   83 ec 0c                sub    $0xc,%esp   4010fc:   6a 0e                   push   $0xe   4010fe:   68 b4 00 40 00          push   $0x4000b4   401103:   6a 01                   push   $0x1   401105:   e8 c7 ff ff ff          call   4010d1 <sys_write>   40110a:   31 c0                   xor    %eax,%eax   40110c:   89 04 24                mov    %eax,(%esp)   40110f:   e8 d4 ff ff ff          call   4010e8 <sys_exit>  $ readelf -a ./target/i686-unknown-linux-gnu/debug/hello_world ELF Header:   Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00    Class:                             ELF32   Data:                              2s complement, little endian   Version:                           1 (current)   OS/ABI:                            UNIX - System V   ABI Version:                       0   Type:                              EXEC (Executable file)   Machine:                           Intel 80386   Version:                           0x1   Entry point address:               0x4010c2   Start of program headers:          52 (bytes into file)   Start of section headers:          304 (bytes into file)   Flags:                             0x0   Size of this header:               52 (bytes)   Size of program headers:           32 (bytes)   Number of program headers:         4   Size of section headers:           40 (bytes)   Number of section headers:         4   Section header string table index: 3  Section Headers:   [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al   [ 0]                   NULL            00000000 000000 000000 00      0   0  0   [ 1] .rodata           PROGBITS        004000b4 0000b4 00000e 00   A  0   0  4   [ 2] .text             PROGBITS        004010c2 0000c2 000052 00  AX  0   0  1   [ 3] .shstrtab         STRTAB          00000000 000114 000019 00      0   0  1 Key to Flags:   W (write), A (alloc), X (execute), M (merge), S (strings), I (info),   L (link order), O (extra OS processing required), G (group), T (TLS),   C (compressed), x (unknown), o (OS specific), E (exclude),   p (processor specific)  There are no section groups in this file.  Program Headers:   Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align   PHDR           0x000034 0x00400034 0x00400034 0x00080 0x00080 R   0x4   LOAD           0x000000 0x00400000 0x00400000 0x000c2 0x000c2 R   0x1000   LOAD           0x0000c2 0x004010c2 0x004010c2 0x00052 0x00052 R E 0x1000   GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0   Section to Segment mapping:   Segment Sections...    00         01     .rodata     02     .text     03       $ objdump -s ./target/i686-unknown-linux-gnu/debug/hello_world  ./target/i686-unknown-linux-gnu/debug/hello_world:     file format elf32-i386  Contents of section .rodata:  4000b4 48656c6c 6f2c2077 6f726c64 210a      Hello, world!.   Contents of section .text:  4010c2 8b0c2489 e083c004 5051e825 00000055  ..$.....PQ.%...U  4010d2 b8040000 0089e553 8b5d088b 4d0c8b55  .......S.]..M..U  4010e2 10cd805b 5dc355b8 01000000 89e5538b  ...[].U.......S.  4010f2 5d08cd80 5589e583 ec0c6a0e 68b40040  ]...U.....j.h..@  401102 006a01e8 c7ffffff 31c08904 24e8d4ff  .j......1...$...  401112 ffff                                 ..               

Сишная версия толще на одну инструкцию ud2 (занимает 2 байта) и на один нуль в конце строки. В sys_exit аргументы пушатся в разном порядке, а в бинарях в целом символы находятся по разным адресам, а так бинари абсолютно идентичны.

Полученный результат стал возможен благодаря автору rustc_codegen_gcc — Antoyo. Он ведет блог, в котором периодически репортит о прогрессе данного проекта. И прогресс действительно поражает воображение. Пользуясь моментом, я прошу вас запатреонить Antoyo или проспонсировать его на гитхабе. Он делает важное дело не только для языка Раст, но и для проекта gcc (улучшает libgccjit.so), что позволит в будущем отвязаться от llvm и, например, компилировать модули ядра Линукса под все доступные gcc платформы.

Выводы

Именно это свойство — zero runtime — делает Си единственным и безальтернативным кандидатом на роль языка для реализации ядер операционных систем и прошивок для микроконтроллеров. Тем удивительнее, насколько мало людей в мире этот момент осознают; и стократ удивительнее то, что людей, понимающих это, судя по всему, вообще нет среди членов комитетов по стандартизации (языка Си)…

— А. В. Столяров

Спасибо, буду знать.

Данная статья иллюстрирует простую мысль: если сильно упороться, можно на любом языке писать как на Си. Но надо ли? Эффективность работы программиста во многом зависит от адекватности используемых изобразительных средств по отношению к используемой задаче. А Раст предоставляет широчайшие возможности как по оптимизации кода, так и по минимизации ошибок, возникающих из-за человеческого фактора.

Вся эта история с доцентом, студентом МГУ и статьёй https://rustmustdie.com/ показывает, что где-то внутри вуза построен странный образовательный процесс, который мешает студентам получать актуальную информацию и формировать независимое мнение.

Я бы хотел, чтобы в МГУ (самом МГУ!) ученые и студенты были открыты к познанию. Ведь в этом и есть суть университетов, нет? Слишком многого хочу?..

Ссылки на код

Весь код из примеров, как и патчи, доступен в репозитории на гитхабе. Проверяйте, перепроверяйте.

Если у вас возник вопрос, а как же реализовать сложение чисел в среде без рантайма, вот ссылка на Linux x86_64 проект с минимальной реализацией арифметики, адресной арифметики, базовых операций вроде взятия размера слайса, ссылки на данные толстого указателя и т.д., благодаря чему в хэлло ворлде вычисляется размер строки, а не подставляется магическое число:

#[no_mangle] fn main() {   print("Hello world!\n"); }  fn print(string: &str) {   unsafe {     write(1, string.as_ptr(), string.len())   }; }


ссылка на оригинал статьи https://habr.com/ru/post/598219/