Прошёл(опять) примерно месяц с предыдущей статьи. В прошлой части мы добавили семантику и научили Akbura понимать, что означает написанный код. Теперь нужно создать инструменты, которые помогут понять, что происходит уже после запуска приложения.
Диагностика
Интегрируем Language Server Protocol и делаем поддержку в Visual Studio
Генерируем код

Важный момент, да я создал полноценный генератор кода, но это тотальный нейрослоп, я просто дал ИИ задание создать компилятор имея текущую документацию и Api. Результат плачевный, 33 файла компиляться 2 минуты не считая других этапов сборки, это очень плохой результат ну на то это и фуриоса
Сразу уточню: под диагностикой в этой статье я имею в виду в основном диагностику работающего приложения.
Синтаксические и семантические ошибки у нас уже существуют. Парсер и binder умеют создавать diagnostics, но полноценно показывать их в редакторе мы будем в следующей части, когда доберёмся до LSP.
Сейчас задача другая. Я хочу ответить на следующие вопросы:
какие компоненты Akbura сейчас существуют;
как они связаны друг с другом;
какие у них параметры, состояния и зависимости;
сколько времени занимает их инициализация и обновление;
почему компонент обновился несколько раз подряд;
где именно в
.akbura-файле произошло исключение.
Зачем нужно отдельное дерево компонентов
Akbura работает поверх Avalonia, поэтому у нас уже есть visual tree и logical tree.
Казалось бы, можно просто открыть Avalonia Developer Tools и посмотреть всё там. Но дерево контролов Avalonia и дерево компонентов Akbura отвечают на разные вопросы.
Представим такой компонент:
<CounterPage> <StackPanel> <TextBlock Text={count} /> <Button Click={() => count++}> Increment </Button> </StackPanel> </CounterPage>
С точки зрения Avalonia нас интересуют StackPanel, TextBlock, Button, их свойства, стили и layout.
С точки зрения Akbura нас больше интересует сам CounterPage: его state, параметры, injected services и дочерние компоненты.
Один компонент Akbura может создать довольно большое визуальное поддерево. Если смотреть только на visual tree, границы компонентов быстро теряются.
Поэтому я добавил отдельный интерфейс:
public interface IComponentTree { IComponentTree? ComponentParent { get; } IAvaloniaReadOnlyList<IComponentTree> ComponentChildren { get; } }
Полная версия находится в IComponentTree.cs.
Каждый AkburaControl хранит родительский компонент и список дочерних компонентов:
private readonly AvaloniaList<IComponentTree> _componentChildren = []; private IComponentTree? _componentParent;
Когда компонент присоединяется к visual tree, он ищет ближайшего родителя, который тоже реализует IComponentTree:
protected override void OnAttachedToVisualTree( VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); SetComponentParent(FindComponentParent()); AkburaComponentRegistry.Attach(this); }
При отсоединении связь удаляется:
protected override void OnDetachedFromVisualTree( VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); _useHooks.StopForDetach(); SetComponentParent(null); AkburaComponentRegistry.Detach(this); }
Поиск родителя пока довольно простой:
private IComponentTree? FindComponentParent() { for (var parent = this.GetVisualParent(); parent != null; parent = parent.GetVisualParent()) { if (parent is IComponentTree componentParent) { return componentParent; } } return null; }
Мы поднимаемся по visual tree, но сохраняем только компоненты Akbura. В результате получается отдельное, намного более компактное дерево:
App ├── Navigation ├── Sidebar └── Router └── CounterPage └── CounterButton
Реестр живых компонентов
Одного отношения parent => children недостаточно. Инспектору ещё нужно откуда-то получить корневые компоненты.
Для этого я добавил глобальный AkburaComponentRegistry.
Упрощённо он выглядит так:
internal static class AkburaComponentRegistry { private static readonly object s_gate = new(); private static readonly List<WeakReference<AkburaControl>> s_components = []; internal static event EventHandler? Changed; internal static void Attach(AkburaControl component) { lock (s_gate) { s_components.Add( new WeakReference<AkburaControl>(component)); } Changed?.Invoke(null, EventArgs.Empty); } }
Здесь важно, что реестр хранит не сами компоненты, а WeakReference<AkburaControl>.
Диагностический инструмент не должен становиться причиной, по которой компоненты никогда не удаляются сборщиком мусора. Иначе попытка найти утечку памяти сама создаст утечку памяти, что было бы довольно иронично.
При чтении реестр заодно удаляет уже мёртвые ссылки:
internal static ImmutableArray<AkburaControl> GetAttachedComponents() { lock (s_gate) { var builder = ImmutableArray.CreateBuilder<AkburaControl>(); for (var index = 0; index < s_components.Count;) { if (s_components[index] .TryGetTarget(out var component)) { builder.Add(component); index++; } else { s_components.RemoveAt(index); } } return builder.ToImmutable(); } }
У реестра также есть событие Changed. Инспектор подписывается на него и перестраивает дерево, когда компонент добавляется или удаляется.
Полная реализация находится в AkburaComponentRegistry.cs.
Создаём инспектор
Сам инспектор я вынес в отдельный проект Akbura.Diagnostics.
Это важно: пользовательскому приложению не нужна диагностическая панель в Release-сборке. Поэтому инструменты разработчика можно подключать только в Debug:
using Akbura.Diagnostics; using Avalonia; public partial class App : Application { public override void Initialize() { AvaloniaXamlLoader.Load(this); #if DEBUG this.AttachAkburaDevTools(); #endif } }
По умолчанию окно открывается по F12.
Горячую клавишу можно изменить:
#if DEBUG this.AttachAkburaDevTools(options => { options.ToggleGesture = new KeyGesture( Key.F12, KeyModifiers.Control); }); #endif
Окно поддерживается пока только для classic desktop application lifetime.
Отдельно пришлось исключить само окно диагностики из реестра:
AkburaComponentRegistry.ExcludeTopLevel(this);
Без этого инспектор увидит собственный DiagnosticsRoot, добавит его в дерево, после чего начнёт диагностировать сам себя. Звучит весело, но пользы от такого немного.
Строим дерево в интерфейсе
Инспектор получает все зарегистрированные компоненты:
var components = AkburaComponentRegistry .GetAttachedComponents() .Where(IsApplicationComponent) .ToArray();
После этого находит корни. Компонент считается корневым, если его родитель отсутствует в текущем наборе:
var componentSet = new HashSet<AkburaControl>( components, ReferenceEqualityComparer.Instance); var roots = components .Where(component => ((IComponentTree)component).ComponentParent is not AkburaControl parent || !componentSet.Contains(parent)) .ToArray();
Из корней рекурсивно создаются элементы TreeView.
Для подписи я использую имя типа и Name, если он задан:
private static string GetComponentDisplayName( AkburaControl component) { var typeName = component.GetType().Name; return string.IsNullOrWhiteSpace(component.Name) ? typeName : $"{typeName} #{component.Name}"; }

Что показывает выбранный компонент
После выбора компонента инспектор показывает три раздела:
Services;States;Parameters.
Для этого AkburaControl предоставляет внутренние диагностические методы:
internal ImmutableArray<Parameter> GetDiagnosticParameters() { return GetParameters(); } internal ImmutableArray<InjectService> GetDiagnosticServices() { return GetServices(); } internal ImmutableArray<State> GetDiagnosticStates() { return GetStates(); }
Сервисы отображаются только для чтения. Для каждого injected service можно увидеть его тип, обязательность и текущее значение.
На изменения выбранного компонента инспектор подписывается напрямую:
_selectedComponent.PropertyChanged += OnSelectedPropertyChanged; foreach (var state in _selectedComponent.GetDiagnosticStates()) { state.ValueChanged += OnSelectedStateChanged; }
Поэтому значение в окне обновляется сразу после изменения state или Avalonia property.
Изменяем state и параметры на лету
Просто посмотреть значение недостаточно. Мне хотелось иметь возможность изменить state, не перезапуская приложение.
Для этого у каждого редактора есть общий контракт InputBuilder.
Встроенные builders поддерживают:
строки;
числа;
коллекции;
универсальное текстовое представление.
Универсальный редактор пытается использовать TypeConverter, статические Parse и TryParse, а для более сложных объектов — JSON.
При изменении state инспектор записывает новое значение через BoxedValue:
value => state.BoxedValue = value
Обычный параметр изменяется как Avalonia property:
component.SetCurrentValue(property, value);
Для read-only коллекций инспектор не заменяет сам объект, а очищает текущую коллекцию и копирует в неё новые элементы.
Кроме встроенных редакторов можно зарегистрировать свой:
this.AttachAkburaDevTools(options => { options.InputBuilders.Insert( 0, new RouteInputBuilder()); });
Builders проверяются по порядку. Первый подходящий выбирается автоматически, но пользователь может переключиться на другой через selector.
Например, для состояния Url можно показывать не обычный TextBox, а ComboBox со всеми известными маршрутами приложения.


Полная реализация окна находится в DiagnosticsRoot.cs.
Инспектор — это ещё не вся диагностика
Инспектор отвечает на вопрос «что сейчас находится внутри компонента».
Но остаются другие вопросы:
сколько заняла инициализация;
сколько времени выполнялся
Update;сколько обновлений прошло за один batch;
какой компонент регулярно достигает лимита обновлений;
где произошло исключение.
Для этого я решил использовать стандартные API .NET:
ActivitySourceдля трассировок;Meterдля метрик.
Это удобно ещё и потому, что Akbura не должна знать, куда пользователь отправляет telemetry. Её можно читать через ActivityListener, OpenTelemetry, dotnet-counters или другой инструмент.
Включаем диагностику
По умолчанию runtime-диагностика отключена.
Она включается через AppContext switch:
private const string IsEnabledSwitchName = "Akbura.Diagnostics.Diagnostic.IsEnabled"; private static bool InitializeIsEnabled() { return AppContext.TryGetSwitch( IsEnabledSwitchName, out var isEnabled) && isEnabled; }
В проекте приложения switch можно добавить так:
<ItemGroup Condition="'$(Configuration)' == 'Debug'"> <RuntimeHostConfigurationOption Include="Akbura.Diagnostics.Diagnostic.IsEnabled" Value="true" /> </ItemGroup>
После включения создаются ActivitySource и Meter:
static Diagnostic() { IsEnabled = InitializeIsEnabled(); if (!IsEnabled) { return; } InitActivitySource(); InitMetrics(); }
Таким образом, в обычной Release-сборке мы не создаём диагностические инструменты вообще.
Трассируем жизненный цикл компонента
Сейчас Akbura создаёт две основные activity:
Akbura.Component.Initialize Akbura.Component.UpdateBatch
Источник создаётся один раз:
private static void InitActivitySource() { s_activitySource = new ActivitySource( "Akbura.Diagnostic.Source"); }
Для инициализации компонента используется обычный using:
protected override void OnInitialized() { base.OnInitialized(); using var activity = Diagnostic.StartComponentInitialization(this); using var duration = Diagnostic.BeginComponentInitialization(this); try { InitializeAkburaComponent(); } catch (Exception exception) { Diagnostic.SetActivityError( activity, exception); throw; } }
ActivitySource.StartActivity() возвращает null, если никто не слушает этот источник. Благодаря этому instrumentation можно оставить даже в часто вызываемом коде: без listener объект Activity вообще не создаётся.
Каждая activity получает тип компонента:
activity.SetTag( "akbura.component.type", GetComponentTypeName(component));
Если произошла ошибка, activity помечается как неуспешная:
activity.SetStatus( ActivityStatusCode.Error); activity.SetTag( "error.type", exception.GetType().FullName);
Для update batch дополнительно сохраняется количество выполненных обновлений:
activity?.SetTag( Diagnostic.Tags.UpdateCount, updateCount);
Если компонент превысил лимит, сохраняется и сам лимит.
Это позволит увидеть не просто медленный Update, а конкретный тип компонента, количество повторных обновлений и завершение с ошибкой.
Добавляем метрики
Для метрик используется System.Diagnostics.Metrics.Meter:
var meter = new Meter( "Akbura.Diagnostic.Meter");
Сейчас я собираю следующие значения:
akbura.component.initialize.duration akbura.component.update.batch.duration akbura.component.update.batch.size akbura.component.update.limit_exceeded.count akbura.component.attached.count
Первые две метрики являются histogram. Они позволяют смотреть не только среднее время, но и распределение значений.
Размер update batch тоже записывается в histogram:
histogram.Record( updateCount, new KeyValuePair<string, object?>( Diagnostic.Tags.ComponentType, GetComponentTypeName(component)));
Превышение лимита записывается через Counter<long>, а количество живых компонентов через observable counter:
meter.CreateObservableUpDownCounter( Meters.AttachedComponentCountName, AkburaComponentRegistry .GetAttachedComponentCount, Meters.ComponentUnit, Meters.AttachedComponentCountDescription);
Для измерения времени используется небольшой ref struct:
internal readonly ref struct HistogramReportDisposable { private readonly long _timestamp; public HistogramReportDisposable(...) { _timestamp = Stopwatch.GetTimestamp(); } public void Dispose() { var elapsed = Stopwatch.GetElapsedTime(_timestamp) .TotalMilliseconds; // histogram.Record(...) } }
Перед созданием объекта проверяется, включён ли инструмент:
if (histogram is not { Enabled: true }) { return default; }
Если listener отсутствует, Akbura даже не вызывает Stopwatch.GetTimestamp().
Посмотреть метрики можно через dotnet-counters:
dnx dotnet-counters monitor --process-id <PID> --counters Akbura.Diagnostic.Meter
Инструмент покажет длительность инициализации, время обновлений, размер batch и количество подключённых компонентов.
Полные реализации находятся в:
Исключения должны вести в .akbura, а не в .g.cs
Осталась ещё одна неприятная проблема.
Akbura генерирует C#. Поэтому без дополнительного source mapping исключение будет выглядеть примерно так:
System.DivideByZeroException at Counter.Update() in Akbura.Component.Counter.akbura.31ac4f.g.cs:line 428
Пользователь никогда не писал строку 428 этого файла. Более того, он, скорее всего, даже не открывал сгенерированный файл.
Нам нужно, чтобы debugger показал исходный .akbura:
System.DivideByZeroException at Counter.Update() in C:\Project\Counter.akbura:line 14
Для этого C# предоставляет директиву #line.
Простейший вариант позволяет изменить номер строки и имя файла:
#line 14 "Counter.akbura" var result = 10 / divisor; #line default
Но Akbura генерирует довольно сложные выражения. Одно выражение из DSL может оказаться внутри длинного вызова:
((AvaloniaObject)__element4).SetValue( TextBlock.TextProperty, items[index].Name);
Поэтому я использую расширенную форму #line, появившуюся в C# 10:
#line (14,19)-(14,36) 78 "C:\Project\Counter.akbura" ((AvaloniaObject)__element4).SetValue(TextBlock.TextProperty, items[index].Name); #line default
Здесь:
(14,19) — начало span в исходном .akbura; (14,36) — конец span; 78 — смещение выражения внутри сгенерированной строки; path — путь к исходному файлу.
Смещение нужно потому, что items[index].Name начинается не в начале сгенерированной строки. Перед ним генератор добавил вызов SetValue, target и property.
Строим source map
Для преобразования TextSpan в строку и колонку я добавил ComponentGenerationSourceMap.
Основная часть довольно простая:
lineSpan = _syntaxTree.Text.Lines .GetLinePositionSpan(syntax.Span);
Перед этим проверяется:
принадлежит ли узел нужному syntax tree;
существует ли путь к файлу;
можно ли безопасно поместить путь в
#line;не является ли span пустым;
находится ли span внутри исходного текста.
Если mapping построить нельзя, генератор просто выводит обычный C# без директивы.
Сам генератор вызывает один вспомогательный метод:
private void AppendLineDirective( StringBuilder source, int indentation, AkburaSyntax syntax, string statement, int valueOffset) { if (_sourceMap.TryGetLineDirective( syntax, out var span, out var path)) { var generatedOffset = indentation * 4 + Math.Max(0, valueOffset); // #line (...) // statement // #line default return; } AppendIndentedLine( source, indentation, statement); }
После каждой пользовательской конструкции обязательно добавляется:
#line default
Иначе следующие строки сгенерированной инфраструктуры тоже будут считаться частью .akbura-файла.
Теперь mapping работает сразу в нескольких местах:
ошибки компиляции указывают на исходный DSL;
debugger может поставить breakpoint на пользовательское выражение;
sequence points в PDB связаны с
.akbura;stack trace исключения показывает исходный файл и строку.

Последний пункт работает при наличии PDB. Сама директива не ловит исключение и не меняет выполнение программы она только объясняет компилятору и debugger, откуда на самом деле появилась сгенерированная инструкция.
Реализация находится в:
Заключение
Теперь мы сделали работу с акбурой немного удобнее, но следующий шаг окончательно сделает наш Dsl используемым. Мы интегрируем сразу с Lsp и VisualStudio.
