Русский
Русский
English
Статистика
Реклама

C

Пишите код по-новому (тм)

15.04.2021 16:11:54 | Автор: admin


C# я не люблю, но люблю собирать все паттерны и весь сахар, который они предлагают от версии к версии.

Третьего дня посмотрел выступление Билла Вагнера на NDC Conferences, где он показывал, что нужно писать код по-новому (TM).

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

Сахаром делу не поможешь


Возьмем плохо написанный фрагмент кода, который написал любитель на коленке. Этот метод проверяет состояние экземпляра класса и возвращает true, если все хорошо и false, если не хорошо.

internal bool GetAvailability(){    if (_runspace.RunspacePoolAvailability == RunspacePoolAvailability.Available) { return true;}    if (_runspace.RunspacePoolAvailability == RunspacePoolAvailability.Busy) { return true;}    return false;}

Программист старался, даже ни одного else в методе. Но мы то опытные, давайте зарефакторим его, уберем ifы и превратим это в тернарку:

internal bool GetAvailability(){    return _runspace.RunspacePoolAvailability == RunspacePoolAvailability.Available ||           _runspace.RunspacePoolAvailability == RunspacePoolAvailability.Busy;}

Стало гораздо лучше, 2 строки кода вместо 5, но тернарку можно превратить в паттерн:

internal bool GetAvailability(){    return _runspace.RunspacePoolAvailability is RunspacePoolAvailability.Available or RunspacePoolAvailability.Busy;}

Итого мы оставили одну красивую строчку кода. Все! Рефакторинг завершен! (нет)

internal void Invoke(){if (!GetAvailability()) return;        PowerShell _powershell = PowerShell.Create();        _powershell.RunspacePool = _runspace;        _powershell.Invoke()    }

Вызов _powershellа в недоступном _runspacee вызовет исключение.

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

Код был не современен, но его смысл не поменялся.

Больше исключений!


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

Но беспокойтесь, теперь, делая свой собственный класс или библиотеку вы можете не думать о защитном коде, тайпчек за нас делает компилятор, а проверка на null никогда не была проще!

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

Тот, как я понял доклад Билла Вагнера NDC Conferences 2020

Я был настолько вдохновлен этой концепцией, да и работой .net в целом, поэтому расскажу вам правдивую историю разработки классов RunspacePool и Powershell из System.Management.Automation, с которыми я недавно столкнулся:

Курильщик 1 делает Powershell


Первым делом, конечно, чтобы отслеживать, состояние мы делаем булёвое поле, которое изменяется на true при вызове метода Dispose.

Показывать поле IsDisposed в принципе небезопасно, потому что, если CLR соберет мусор, можно словить Null reference.

class PowershellInNutshell() : IDisposable{    private static bool IsDisposed = false;    private static RunspacePoolInTheNuttshell;    public static void Invoke()    {        if (IsDisposed) throw new ObjectDisposedException();        Console.WriteLine("I was invoked");    }    public void Dispose()    {        if (IsDisposed) throw new ObjectDisposedException("Invoke","Сообщение на русском языке, если винда русская или на итальянском, если итальянская");        IsDisposed = true;        Console.WriteLine("I was invoked");        GC.SuppressFinalize(this);    }}

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

Курильщик 2 делает RunspacePooll


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

class RunspacePoolInTheNuttshell() : IDisposable{    public static bool IsDisposed = false;        public void Dispose()    {        if (IsDisposed) return;        IsDisposed = true;        GC.SuppressFinalize(this);        Console.WriteLine("I was invoked");    }}

Если метод Dispose был вызван, делаем return и дело с концом. Конечно, при повторном обращении к полю он получит nullref, потому что объект уже будет удален из памяти, но моя ли это проблема.

Здоровый человек использует библиотеку:


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

  • Неправильный пароль? InvalidRunspacePoolStateException!
  • Нет соединения? InvalidRunspacePoolStateException!

Получается, что в одном месте нужно обработать ObjectDisposedException, в другом NullReferenceException в третьем InvalidRunspacePoolStateException и все полно сюрпризов.

Исключение не решение



До причащения святых таинств я читал файл по-старому:

public static void Main(){    string txt = @"c:\temp\test.txt";        if (File.Exists(txt)) return;    string readText = File.ReadAllText(txt);    Console.WriteLine(readText);}

Но после просмотра видео я начал делать по-новому:

public static void Main(){    string txt = @"c:\temp\test.txt";    try    {        string readText = File.ReadAllText(txt);        Console.WriteLine(readText);    }    catch (System.IO.FileNotFoundException)    {        Console.WriteLine("File was not found");    }}

Или это по-новому?

public static void Main(){    string txt = @"c:\temp\test.txt";    if (!File.Exists(txt))    {        throw new NullReferenceException();    }    string readText = File.ReadAllText(txt);    Console.WriteLine(readText);}

Как именно по-новому? Где именно кончается ответственность разработчика и начинается ответственность пользователя?


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

internal class NewWay{    public static string _a;    public static string _b;    public static string _c;    public static void NewWay(string a, string b, string c)    {        string _a = a ?? throw new NullReferenceException("a is null");        string _b = b ?? throw new NullReferenceException("b is null");        string _c = c ?? throw new NullReferenceException("c is null");    }    public void Print()    {        if (String.Compare(_a, _b) != 0)        {            throw new DataException("Some Other Ex");        }        Console.WriteLine($"{_a + _b + _c}");// Логика    }}

try{    NewWay newway = new(stringThatCanBeNull, stringThatCanBeNull, stringThatCanBeNull);    newway.Print();}catch (NullReferenceException ex){    Console.WriteLine("Компенсаторная логика");}catch (DataException ex){    Console.WriteLine("Компенсаторная логика");}

Самые догадливые уже поняли, к чему я веду. Организация коррекции ошибок построенная на try catch блоках приведет только к углублению вложенности кода.

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

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

И под конец операторы



Операторы не должны ничего никуда кастить.

Пример из JS вы наверняка знаете:

console.log('2'+'2'-'2');// 20

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

Источником этого бага в JS является неявное приведение типа string к типу int посредством оператора. Так лишний сахар становится багом.

Неявным кастом типов болеет и C# тоже, хоть и гораздо реже. Взять, например, пользовательский инпут, который после обновления библиотеки начал мапиться в string вместо int, как раньше, а оператор (+) и математический оператор и оператор конкатенации.

Изменение типа с int на string код не сломало, а бизнес-логику сломало.

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

class Program{    static void Main(string[] args)    {        Console.WriteLine($"{'2' + '2' - '2' }");    }}

Подробнее..

Как мы верифицированный полетный контроллер для квадрокоптера написали. На Ada

30.03.2021 12:10:29 | Автор: admin

Однажды на новогодних каникулах, лениво листая интернет, бракоделы в нашем* R&D офисе заметили видео с испытаний прототипа роботакси. Комментатор отзывался восторженным тоном революция, как-никак. Здорово, да, но время сейчас такое кругом революции, и ИТ их возглавляет.

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

Перед глазами побежали флешбеки, где-то из глубин подсознания всплыла забытая уже информация о прошивках для Тойоты на миллионы тысяч строк Си и 2 тысячи глобальных переменных (Toyota: 81564 ошибки в коде).

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

Но ведь можно иначе? Можно, решили мы!

И решили это доказать. На Avito был куплен акробатический FPV-квадрик на базе STM32F405, для отладки - Discovery-плата для этого же контроллера, а дальше все как в тумане..

Так как же быть иначе?

После быстрого совещания возникли вот такие мысли:

  • нам нужен другой подход

  • язык и подход должны друг друга дополнять

  • академический подход не подойдет, нужны практические применения.

В качестве нового подхода решили, что лучше всего опираться на возможность возможность верификации ПО - до необходимого уровня, без злоупотреблений. Но для языка типа С доступных промышленных зрелых решений не существует, только прототипы [FC] и рекомендации.

При выборе языка мы поставили себе вот какими требования:

  • это должно быть что-то близкое к embedded

  • Нам нужен хороший богатый runtime с возможностями RTOS, но при этом брать и интегрировать RTOS не хочется

  • Он не должен заметно уступать в производительности тому, что используется сейчас.

Оказалось, что из практических инструментов в эти требования хорошо подходит один очень старый, незаслуженно забытый инструмент. Да, это Ada. А точнее, его модерновое, регулярно обновляемое ядро SPARK. В [SRM] описаны основные отличия SPARK от Ada, их не так много.

Что такое SPARK, будет ясно дальше, мы покажем, как именно оно было применено, почему Ада понравилась больше, чем С, как работает прувер, и почему мы при этом ничего не потеряли, а только приобрели. И почему мы не взяли Rust :)

Иной подход

Иной подход это верификация ПО. Обычно при этих словах люди начинают думать об абстрактных монадах, академический манускриптах, докладах на конференциях и пыльных трудах института системного программирования РАН, которые как будто бы от жизни отстают лет на 30, а то и 50. Но оказалось, что не все так плохо.

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

В случае с SPARK, верификация базово предоставляет нам гарантии:

  • отсутствия переполнения массивов и переменных

  • отсутствия выхода за границы в типах и диапазонах

  • отсутствия разыменования null-указателей

  • отсутствие выброса исключений.

  • гарантию неприменения инструментов, проверку которых выполнить нельзя.

  • гарантию выполнения всех инвариантов, которые мы опишем. А опишем мы много!

    Круто, да?

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

SPARK также учитывает ограничения на типы, которые описаны в Ada. В случае обычного исполнения ошибка несоответствия типов упадет в Runtime; SPARK же позволяет статически доказать, что ограничения на типы не могут быть нарушены никаким потоком исполнения.

Например:

Или другой пример:

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

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

Сам SPARK делит верификацию на уровни: от "каменного" (Stone level) через "Бронзовый" и "Серебряный" уровни до "Золотого" (Gold) и "Платинового". Каждый из уровней усиливает гарантии:

Stone

Мы в принципе знаем, что есть SPARK

Bronze

Stone + верификация потоков исполнения и детерминизм/отсутствие неинициализированных переменных

Silver

Bronze + доказательное отсутствие runtime-ошибок

Gold

Silver + гарантии целостности - не-нарушения инвариантов локальных и глобальных состояний

Platinum

Gold + гарантия функциональной целостности

Мы остановились на уровне Gold, потому что наш квадрокоптер все-таки не Boing 777 MAX.

Как работает верификация в SPARK: прувер собирает описание контрактов и типов, на их основе генерирует правила и ограничения, и далее передает их в солвер (SMT - Z3), который проверяет выполнимость ограничений. Результат решения прувер привязывает к конкретным строкам, в которых возникает невыполнимость.

Более подробно можно почитать в [SUG]

Иной язык

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

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

Профили

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

Мы используем профиль Ravenscar, специально для embedded-разработки. Он включает пару дюжин ограничений, которые делают вашу разработку для микроконтроллеров более удобной и верифицируемой: нельзя на ходу переназначать приоритеты задач-потоков, переключать обработчики прерываний, сложные объекты из stdlib-ы и такое.

Вот ограничения профиля Ravescar, для примера

Runtime

Когда в embedded необходимо создать более-менее сложное приложение, там всегда заводится RTOS, и ее выбор и интеграция это отдельная песня. В случае с Ada с этим больше нет сложностей - сама Ada включает в себя минимальную исполняемую среду с вытесняющим планировщиком потоков (в Ada это tasks, задачи), с интегрированными в сам язык средствами синхронизации (семафоры, рандеву, называются entries) и даже средствами избегания дедлоков и инверсии приоритетов. Это оказалось очень удобно для квадрокоптера, как станет понятно ниже.

Для embedded-разработчика доступны на выбор также разные рантаймы:

  • zero-footprint - с минимальными ограничениями и даже без многопоточности; зато минимальная программа не превышает пары килобайт, влезает даже в TO MSP430

  • small footprint - доступна большая часть функций Ada, но и требования побольше, несколько десятков килобайт RAM

  • full ravenscar - доступны все функции в рамках профиля Ravenscar/Extended Ravenscar

Вот пример описания пустой задачи

Хочется обратить внимание, что эти задачи - это по сути легковесные green threads, так как под ними нет никаких настоящих потоков не существует. Поэтому мы не страдаем от отсутствия корутин, ведь задачи не тяжелее них, зато встроены в язык.

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

Почему не rustRustRUST!

Когда мы говорим, про гарантии в языках программирования, сразу вспоминается Rust и его гарантии относительно указателей. Почему тут не он? Нам кажется, что Spark мощнее.

Ada не очень любит указатели - там они называются access types, и в большинстве случаев там они не нужны, но если нужны, то - в Spark также есть проверки владения, как в Rust. Если вы аллоцировали объект по указателю, то простое копирование указателя означает передачу владения (которую проконтролирует компилятор/верификатор), а передачу во временное владение (или доступ на чтение) верификатор также понимает и контролирует.

В общем, концепция владения объектом по указателю, и уровень доступа через этот указатель - есть не только в Rust, и его преимуществами можно пользоваться и в других инструментах, в частности, в Ada/SPARK. Подробно можно почитать в [UPS]

Вот пример кода с владением

Почему мы пишем, что в Ada/SPARK не нужны указатели? В отличие от Си, где базовым инструментом является указатель (хочешь ссылку - вот указатель, хочешь адрес в памяти - вот указатель, хочешь массив - вот указатель - ну вы поняли?), в Ada для всего этого есть строгий тип. Если не хватает встроенных операций, их допустимо переопределять (например, реализовать инлайновый автоинкремент), аналогично можно создать placement constructor, используя т.н. limited-типы - типы, которые компилятор запрещает копировать.

Если уже и этого мало, есть интероп с СИ то есть код можно компилировать совместно, и слинковать на этапе сборки. Но в этом случае гарантии поведения модуля как черного ящика остаются на разработчике. Для интеропа используются атрибуты - вот так, например, мы оборачиваем функцию на Си в доступ для Ada.

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

IDE

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

О производительности и надежности

Вполне валидным аргументом может быть вопрос с эффективностью ПО. Что касается эффективности, то в интернете доступно свежее исследование [EFF], из которого хочется привести табличку, показывающую, что старичок Ada еще огого:

Если говорить о надежности, то SPARK/Ada известен как один из языков с наименьшим количеством ошибок. В планируемом на 21 запуске кубсатов [LIC] полетное ПО планируется реализовывать на Ada, предыдущий спутник BasiLEO тоже на Ada был единственным среди 12, кому удалось приступить к планируемой миссии.

А теперь - о самом полетном контроллере

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

Структурная схема управляющего ПО показана на рисунке

Как видно из рисунка, ПО состоит из двух частей:

  • Veriflight - собственно, верифицированный полетный контроллер с алгоритмами.

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

Так как тратить много времени не хотелось, то драйвер для USB в STM32 был взят прямо нативный и при помощи Interop был слинкован с оберткой на Ada.

Плата оказалась оснащена минимальным количеством периферийных устройств:

  • STM32F405 микроконтроллер на 168 МГц (192кб RAM, 1Mб flash)

  • трансивером S.BUS на USART1

  • 6-осевым гиро-акселерометром без магнитного компаса

  • токовыми усилителями PWM

  • USB-интерфейсом, PHY-часть которого реализована на самом микроконтроллере платы.

Полетный контроллер реализован по простой схеме и крутит 2 цикла:

  1. внешний

  2. внутренний

Внешний цикл это цикл опроса периферии (CMD task на рисунке) в ожидании команд с радиопередатчика. Если команды нет, он передает признаки сохраняем высоту, держим горизонт. Если команда с пульта есть, передаем ее - целевой угол наклона, целевую мощность на пропеллеры. Частота внешнего цикла 20 Гц.

Внутренний цикл - цикл опроса гиро-акселерометра и распределения мощности на двигатели. Цикл оборудован 3 PID-регуляторами, и математикой Махони для расчета текущего положения по сигналам с гироскопов. В расчетах внутри используем кватернионы, для генерации управляющего сигнала - углы Эйлера. Частота размыкания внутреннего цикла - 200 Гц. Да, Ада без проблем успевает диспетчеризировать с такой скоростью.

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

Внутренний цикл реализует опрос PID и стабилизацию аппарата:

  • Считали затребованные пилотом углы

  • Запросили у математики расчетные углы положения

  • Нашли расхождение между желаемыми и настоящими

  • Пересчитали текущее положение на основании сигналов с гиро-акселерометров

  • Зарядили PID-регуляторы на новую коррекцию, если пришли новые затребованные углы

  • Запросили у PID-пакетов текущие импульсы коррекции

  • На основании них, а также запрошенной пилотом мощности на двигатели, сформировали необходимое распределение скоростей вращения на двигателях

Забавно, что большинство опен-сорсных реализаций Махони (для Arduino и не только) - на Cи и Wiring оказались содержащими разнообразные баги. Это мешало системе заработать. После того, как было выпито пол-ящика лимонада и съедена корзина круассанов, алгоритм воссоздали с нуля по описанию из [MHN], и система тут же заработала.

Данная схема, как и любая упрощенная модель, испытывает сложности при приближениях параметров к предельным. Здесь это 90 по крену и тангажу - при их превышении для безопасности реализовано отключение двигателей (disarm).

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

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

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

Итог на текущий момент

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

Мы в R&D продолжаем познавать верификацию ПО и с нетерпением ждем схода снега в Москве, чтобы в следующей статье поделиться результатами испытаний, подгонки и видео тестовых полётов ПО в уличных условиях, которое, кажется, статически проверено на то, что не содержит ошибок.

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

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

Если вы также считаете, что современная robotics и automotive это слишком важные вещи, чтобы позволить себе переполнения буфера и разыменование нуля и ой программисты не написали тесты, пишите, комментируйте, присоединяйтесь: пора сделать ПО надёжнее, потому что оно вокруг нас везде.

Литература для дальнейшего изучения

[SUG] SPARK user guide https://docs.adacore.com/spark2014-docs/html/ug/index.html

[SRM] SPARK reference manual (https://docs.adacore.com/live/wave/spark2014/html/spark2014_rm/index.html)

[FC] Frama-C - платформа для модульного анализа кода С https://frama-c.com/

[UPS] https://blog.adacore.com/using-pointers-in-spark

[MHN] https://nitinjsanket.github.io/tutorials/attitudeest/mahony

[EFF] https://greenlab.di.uminho.pt/wp-content/uploads/2017/10/sleFinal.pdf

[LIC] https://en.wikipedia.org/wiki/Lunar_IceCube

Подробнее..

Подводные камни в бассейне строк, или ещё один повод подумать перед интернированием экземпляров класса String в C

06.04.2021 16:12:32 | Автор: admin

Будучи разработчиками программного обеспечения, мы всегда хотим, чтобы написанное нами ПО работало быстро. Использование оптимального алгоритма, распараллеливание, применение различных техник оптимизации мы будем прибегать ко всем известным нам средствам, дабы улучшить производительность софта. К одной из таких техник оптимизации можно отнести и так называемое интернирование строк. Оно позволяет уменьшить объём потребляемой процессом памяти, а также значительно сокращает время, затрачиваемое на сравнение строк. Однако, как и везде в жизни, необходимо соблюдать меру не стоит использовать интернирование на каждом шагу. Далее в этой статье будет показано, как можно обжечься и создать своему приложению неочевидный bottleneck в виде метода String.Intern.

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

Есть несколько версий формулы для того, чтобы посчитать, сколько байт занимает строковый объект на куче: вариант от Джона Скита и вариант, показанный в статье Тимура Гуева. На картинке выше я использовал второй вариант. Даже если эта формула верна не на 100 %, мы всё равно можем приблизительно прикинуть размер строковых объектов. К примеру, для того чтобы занять 1 Гб оперативной памяти, достаточно будет, чтобы в памяти процесса было около 4,7 миллионов строк (каждая длиной в 100 символов). Если мы полагаем, что среди строк, с которыми работает наша программа, есть большое количество дубликатов, как раз и стоит воспользоваться встроенным во фреймворк функционалом интернирования. Давайте сейчас быстро вспомним, что вообще такое интернирование строк.

Интернирование строк

Идея интернирования строк состоит в том, чтобы хранить в памяти только один экземпляр типа String для идентичных строк. При старте нашего приложения виртуальная машина создаёт внутреннюю хэш-таблицу, которая называется таблицей интернирования (иногда можно встретить название String Pool). Эта таблица хранит ссылки на каждый уникальный строковый литерал, объявленный в программе. Кроме того, используя два метода, описанных ниже, мы сами можем получать и добавлять ссылки на строковые объекты в эту таблицу. Если наше приложение работает с большим количеством строк, среди которых часто встречаются одинаковые, то нет смысла каждый раз создавать новый экземпляр класса String. Вместо этого можно просто ссылаться на уже созданный на куче экземпляр типа String, получая ссылку на него путём обращения к таблице интернирования. Виртуальная машина сама интернирует все строковые литералы, встреченные в коде (более подробно про многие хитрости интернирования можно прочитать в этой статье). А нам для работы предоставляются два метода: String.Intern и String.IsInterned.

Первый на вход принимает строку и, если идентичная строка уже имеется в таблице интернирования, возвращает ссылку на уже имеющийся на куче объект типа String. Если же такой строки ещё нет в таблице, то ссылка на этот строковый объект добавляется в таблицу интернирования и затем возвращается из метода. Метод IsInterned также на вход принимает строку и возвращает ссылку из таблицы интернирования на уже имеющийся объект. Если такого объекта нет, то возвращается null (про неинтуитивно возвращаемое значение этого метода не упомянул только ленивый).

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

Интернирование строк может дать прирост производительности при сравнении этих самых строк. Взглянем на реализацию метода String.Equals:

public bool Equals(String value){  if (this == null)    throw new NullReferenceException();   if (value == null)    return false;   if (Object.ReferenceEquals(this, value))    return true;    if (this.Length != value.Length)    return false;   return EqualsHelper(this, value);}

До вызова метода EqualsHelper, где производится посимвольное сравнение строк, метод Object.ReferenceEquals выполняет проверку на равенство ссылок. Если строки интернированы, то уже на этапе проверки ссылок на равенство метод Object.ReferenceEquals вернёт значение true при условии равенства строк (без необходимости сравнения самих строк посимвольно). Конечно, если ссылки не равны, то в итоге произойдёт вызов метода EqualsHelper и последующее посимвольное сравнение. Ведь методу Equals неизвестно, что мы работаем с интернированными строками, и возвращаемое значение false методом ReferenceEquals уже свидетельствует о различии сравниваемых строк.

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

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

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

Краткая история того, как всё начиналось

В корпоративном bug tracker'е уже какое-то время значилась задача, в которой требовалось провести исследование: как распараллеливание процесса анализа С++ кода может сократить продолжительность анализа. Хотелось, чтобы анализатор PVS-Studio параллельно работал на нескольких машинах при анализе одного проекта. В качестве программного обеспечения, позволяющего проводить подобное распараллеливание, был выбран IncrediBuild. IncrediBuild позволяет параллельно запускать на машинах, находящихся в одной сети, различные процессы. Например, такой процесс, как компиляция исходных файлов, можно распараллелить на разные машины, доступные в компании (или в облаке), и таким образом добиться существенного сокращения времени сборки проекта. Данное программное обеспечение является довольно популярным решением среди разработчиков игр.

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

Был выбран open source проект Unreal Tournament. Удалось успешно уговорить программистов посодействовать в решении задачи и установить IncrediBuild на их машины. Полученный объединённый кластер включал около 145 ядер.

Так как я анализировал проект Unreal Tournament с помощью применения системы мониторинга компиляции в PVS-Studio, то мой сценарий работы был следующим. Я запускал программу CLMonitor.exe в режиме монитора, выполнял полную сборку проекта Unreal Tournament в Visual Studio. Затем, после прохождения сборки, опять запускал CLMonitor.exe, но уже в режиме запуска анализа. В зависимости от указанного в настройках PVS-Studio значения для параметра ThreadCount, CLMonitor.exe параллельно одновременно запускает соответствующее количество дочерних процессов PVS-Studio.exe, которые и занимаются анализом каждого отдельного исходного С++ файла. Один дочерний процесс PVS-Studio.exe анализирует один исходный файл, а после завершения анализа передаёт полученные результаты обратно в CLMonitor.exe.

Всё просто: выставил параметр ThreadCount в настройках PVS-Studio, равный количеству доступных ядер (145), запустил анализ и сел в ожидании того, что сейчас увижу, как 145 процессов PVS-Studio.exe будут исполняться параллельно на удалённых машинах. У приложения IncrediBuild есть удобная система мониторинга распараллеливания Build Monitor, через которую можно наблюдать, как процессы запускаются на удалённых машинах. Что-то подобное я и наблюдал в процессе анализа:

Казалось, что нет ничего проще: сиди и смотри, как проходит анализ, а после просто зафиксируй время его проведения с использованием IncrediBuild и без. Реальность оказалась несколько интересней...

Обнаружение проблемы, её локализация и устранение

Пока проходил анализ, можно было переключиться на выполнение других задач или просто медитативно поглядывать на бегущие полосы процессов PVS-Studio.exe в окне Build Monitor. После завершения анализа с использованием IncrediBuild я сравнил временные показатели продолжительности анализа с результатами замеров без применения IncrediBuild. Разница ощущалась, но общий результат, как мне показалось, мог бы быть и лучше: 182 минуты на одной машине с 8 потоками и 50 минут с использованием IncrediBuild и 145 потоками. Получалось, что количество потоков возросло в 18 раз, но при этом время анализа уменьшилось всего в 3,5 раза. Напоследок я опять решил взглянуть уже на итоговый результат окна Build Monitor. Прокручивая нижний ползунок, я заметил следующие аномалии на графике:

Было видно, что процессы PVS-Studio.exe запускались и успешно завершались, а затем по какой-то причине происходила некая пауза между запуском следующей партии процессов PVS-Studio.exe. И так повторялось несколько раз. Пауза за паузой. И эти простои между запусками складывались в ощутимую задержку и вносили свою лепту в общее время анализа. Первое, что пришло в голову, это вина IncrediBuild. Наверняка он выполняет какую-то свою внутреннюю синхронизацию между процессами и тормозит их запуск.

Я показал эти аномалии на графиках своему старшему коллеге. Он, в отличие от меня, не был так поспешен в выводах. Он предложил посмотреть на то, что происходит внутри нашего приложения CLMonitor.exe в момент того, как на графике начинают появляться видимые простои. Запустив анализ повторно и дождавшись первого очевидного "провала" на графике, я подключился к процессу CLMonitor.exe с помощью отладчика Visual Studio и поставил его на паузу. Открыв вкладку Threads, мы с коллегой увидели около 145 приостановленных потоков. Перемещаясь по местам в коде, где остановили своё исполнение данные потоки, мы увидели строки кода подобного содержания:

....return String.Intern(settings == null ? path                                 : settings                                 .TransformToRelative(path.Replace("/", "\\"),                                                      solutionDirectory));....analyzedSourceFiles.Add( String.Intern(settings                        .TransformPathToRelative(analyzedSourceFilePath,                                                  solutionDirectory))                       );....

Что общего мы видим в этих строках кода? В каждой из них используется метод String.Intern. Причём его применение кажется оправданным. Дело в том, что это те места, где CLMonitor.exe обрабатывает данные, приходящие от процессов PVS-Studio.exe. Обработка происходит путём записи данных в объекты типа ErrorInfo, инкапсулирующего в себе информацию о найденной анализатором потенциальной ошибке в проверяемом коде. Интернируем мы тоже вполне разумные вещи, а именно пути до исходных файлов. Один исходный файл может содержать множество ошибок, поэтому нет смысла, чтобы объекты типа ErrorInfo содержали в себе разные строковые объекты с одинаковым содержимым. Логично просто ссылаться на один объект из кучи.

После недолгих раздумий стало понятно, что интернирование здесь применено в очень неподходящий момент. Итак, вот какую ситуацию мы наблюдали в отладчике. Пока по какой-то причине 145 потоков висели на выполнении метода String.Intern, кастомный планировщик задач LimitedConcurrencyLevelTaskScheduler внутри CLMonitor.exe не мог запустить новый поток, который в дальнейшем бы породил новый процесс PVS-Studio.exe, а IncrediBuild уже запустил бы этот процесс на удалённой машине. Ведь, с точки зрения планировщика, поток ещё не завершил своё исполнение он выполняет преобразование полученных данных от PVS-Studio.exe в ErrorInfo с последующим интернированием. Ему всё равно, что сам процесс PVS-Studio.exe уже давно завершился и удалённые машины попросту простаивают без дела. Поток ещё активен, и установленный нами лимит в 145 потоков не даёт планировщику породить новый.

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

Убирать интернирование совсем нам не хотелось, так как это привело бы к увеличению объёма потребляемой оперативной памяти процессом CLMonitor.exe. В конечном счёте было найдено довольно простое и элегантное решение: перенести интернирование из потока, выполняющего запуск процесса PVS-Studio.exe в чуть более позднее место исполнения кода (в поток, который занимается непосредственно формированием отчёта об ошибках).

Как сказал мой коллега, обошлись хирургической правкой буквально двух строк и решили проблему с простаиванием удалённых машин. При повторных запусках анализа окно Build Monitor уже не показывало каких-либо значительных промежутков времени между запусками процессов PVS-Studio.exe. А время проведения анализа снизилось с 50 минут до 26, то есть почти в два раза. Теперь если смотреть на общий результат, который мы получили при использовании IncrediBuild и 145 доступных ядер, то общее время анализа уменьшилось в 7 раз. Это впечатляло несколько больше, нежели цифра в 3,5 раза.

String.Intern почему так медленно, или изучаем код CoreCLR

Стоит отметить, что как только мы увидели зависания потоков на местах вызова метода String.Intern, то почти сразу подумали, что под капотом у этого метода присутствует критическая секция с каким-нибудь lock'ом. Раз каждый поток может писать в таблицу интернирования, то внутри метода String.Intern должен быть какой-нибудь механизм синхронизации, чтобы сразу несколько потоков, выполняющих запись в таблицу, не перезаписали данные друг друга. Хотелось подтвердить свои предположения, и мы решили посмотреть имплементацию метода String.Intern на ресурсе reference source. Там мы увидели, что внутри нашего метода интернирования есть вызов другого метода *Thread.GetDomain().GetOrInternString(str)*. Что ж, давайте посмотрим его реализацию:

internal extern String GetOrInternString(String str);

Уже интересней. Этот метод импортируется из какой-то другой сборки. Из какой? Так как интернированием строк всё-таки занимается сама виртуальная машина CLR, то мой коллега направил меня прямиком в репозиторий среды исполнения .NET. Выкачав репозиторий, мы отправились к солюшену с названием CoreCLR. Открыв его и выполнив поиск по всему решению, мы нашли метод GetOrInternString с подходящей сигнатурой:

STRINGREF *BaseDomain::GetOrInternString(STRINGREF *pString)

В нём увидели вызов метода GetInternedString. Перейдя в тело этого метода, заметили код следующего вида:

....if (m_StringToEntryHashTable->GetValue(&StringData, &Data, dwHash)){  STRINGREF *pStrObj = NULL;  pStrObj = ((StringLiteralEntry*)Data)->GetStringObject();  _ASSERTE(!bAddIfNotFound || pStrObj);  return pStrObj;}else{  CrstHolder gch(&(SystemDomain::GetGlobalStringLiteralMap()                                   ->m_HashTableCrstGlobal));  ....  // Make sure some other thread has not already added it.  if (!m_StringToEntryHashTable->GetValue(&StringData, &Data))  {    // Insert the handle to the string into the hash table.    m_StringToEntryHashTable->InsertValue(&StringData, (LPVOID)pEntry, FALSE);  }  ....}....

Поток исполнения попадает в ветку else только в том случае, если метод, занимающийся поиском ссылки на объект типа *String *(метод GetValue) в таблице интернирования, вернёт false. Перейдём к рассмотрению кода, который представлен в ветке else. Интерес тут вызывает строка создания объекта типа CrstHolder с именем gch. Переходим в конструктор CrstHolder и видим код следующего вида:

inline CrstHolder(CrstBase * pCrst)    : m_pCrst(pCrst){    WRAPPER_NO_CONTRACT;    AcquireLock(pCrst);}

Видим вызов метода AcquireLock. Уже хорошо. Код метода AcquireLock:

DEBUG_NOINLINE static void AcquireLock(CrstBase *c){  WRAPPER_NO_CONTRACT;  ANNOTATION_SPECIAL_HOLDER_CALLER_NEEDS_DYNAMIC_CONTRACT;  c->Enter();}

Вот, собственно, мы и видим точку входа в критическую секцию вызов метода Enter. Оставшиеся сомнения в том, что это именно тот метод, который занимается блокировкой, у меня пропали после того, когда я прочитал оставленный к этому методу комментарий: "Acquire the lock". В дальнейшем погружении в код CoreCLR я не видел особого смысла. Получается, версия с тем, что при занесении новой записи в таблицу интернирования поток заходит в критическую секцию, вынуждая все другие потоки ожидать, когда блокировка спадёт, подтвердилась. Создание объекта типа CrstHolder, а следовательно, и заход в критическую секцию происходят сразу перед вызовом метода m_StringToEntryHashTable->InsertValue.

Блокировка пропадает сразу после того, как мы выходим из ветки else. Так как в этом случае для объекта gch вызывается его деструктор, который и вызывает метод ReleaseLock:

inline ~CrstHolder(){  WRAPPER_NO_CONTRACT;  ReleaseLock(m_pCrst);}

Когда потоков немного, то и простой в работе может быть небольшим. Но когда их количество возрастает, например до 145 (как в случае использования IncrediBuild), то каждый поток, пытающийся добавить новую запись в таблицу интернирования, временно блокирует остальные 144 потока, также пытающихся внести в неё новую запись. Результаты этих блокировок мы и наблюдали в окне Build Monitor.

Заключение

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

Благодарю за прочтение.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Ilya Gainulin. Pitfalls in String Pool, or Another Reason to Think Twice Before Interning Instances of String Class in C#.

Подробнее..

Простое сложное программирование

15.04.2021 16:11:54 | Автор: admin


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

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

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

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

Выбор попугаев для измерения


Я не стал придумывать свои или вычислять эмпирические метрики программного кода, и в качестве попугая решил взять самую простую метрику SLOC (англ.Source Lines of Code) количество строк исходного текста компилятора, которая очень легко вычисляется.

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

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

Но для численной оценки сложности кода в рамках одного проекта, метрика SLOC подходит хорошо.

Методика подсчета SLOC


Изначально попробовал использовать простой bash скрипт с поиском по маске и подсчетом числа строк в файлах исходника через wc -l. Но через некоторое время стало понятно, что приходится изобретать очередной велосипед.
Ну вы поняли:



Поэтому решил взять уже готовый. После быстрого поиска остановился на утилите SLOCCount, которая умеет анализировать почти три десятка типов исходников.
Список типов файлов для автоматического анализа
1. Ada (.ada, .ads, .adb)
2. Assembly (.s, .S, .asm)
3. awk (.awk)
4. Bourne shell and variants (.sh)
5. C (.c)
6. C++ (.C, .cpp, .cxx, .cc)
7. C shell (.csh)
8. COBOL (.cob, .cbl) as of version 2.10
9. C# (.cs) as of version 2.11
10. Expect (.exp)
11. Fortran (.f)
12. Haskell (.hs) as of version 2.11
13. Java (.java)
14. lex/flex (.l)
15. LISP/Scheme (.el, .scm, .lsp, .jl)
16. Makefile (makefile) not normally shown.
17. Modula-3 (.m3, .i3) as of version 2.07
18. Objective-C (.m)
19. Pascal (.p, .pas)
20. Perl (.pl, .pm, .perl)
21. PHP (.php, .php[3456], .inc) as of version 2.05
22. Python (.py)
23. Ruby (.rb) as of version 2.09
24. sed (.sed)
25. SQL (.sql) not normally shown.
26. TCL (.tcl, .tk, .itk)
27. Yacc/Bison (.y)



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

Меня изначально интересовал объем исходников на С/С++ и может быть еще на Ассемблере, если таких файлов окажется достаточно для много. Но после начала работы очень обрадовался, что не стал изобретать велосипед, а взял готовую тулзу, т.к. она отдельно считала статистику исходных файлов синтаксического анализатора Yacc/Bison (.y), который и определяет фактическую сложность парсера (читай сложность синтаксиса языка программирования).

Старые исходники gcc брал с gcc.gnu.org/mirrors.html, но перед запуском анализатора удалили каталоги других компиляторов (ada, fortran, java и т.д.), чтобы они не попадали в итоговую статистику.

Результаты в попугаях.


Таблица




Объем кода синтаксического анализатора Yacc/Bison


Объем общей которой базы GCC (только для языков C и C++)

Выводы


К сожалению синтаксический анализатор Yacc/Bison использовался только до 3 версии, а после его использование свелось на нет. Поэтому оценить сложность синтаксиса С/С++ с помощью объема кода парсера можно лишь примерно до 1996-98 года, после чего его стали постепенно выпиливать, т.е. чуть менее, чем за десять лет. Но даже за этот период объем кодовой базы синтаксического анализатора вырос двукратно, что примерно соответствует по времени реализации стандарта C99.

Но даже если не учитывать код синтаксического анализатора, то объем общей кодовой базы так же коррелирует с внедрением новых стандартов C++: C99, С11 и C14.

На графике не видно выраженного пика для С+17 и следующих версий, но могу предположить, что при текущем объеме кода (более 4 миллионов строк только С и С++ кода), несколько тысяч строк, необходимых для поддержки синтаксических конструкций новых стандартов просто незаметно.

Вывод первый очевидный. Рост сложности инструментов разработки


Фактически на примере проекта GCC можно видеть постоянный и неотвратимый рост сложности рабочих инструментов.

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

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

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

Вывод второй порог входа


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

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


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

Итоговый вывод не утешительный


Если рассматривать увеличение сложности только самого ПО, то это одно дело. Вот к примеру
Статистика ядра Linux с вики
17 сентября 1991: Linux версии 0.01 (10 239 строк кода).
14 марта 1994: Linux версии 1.0.0 (176 250 строк кода).
Март 1995: Linux версии 1.2.0 (310 950 строк кода).
9 июня 1996: Linux версии 2.0.0 (777 956 строк кода).
25 января 1999: Linux версии 2.2.0, изначально довольно недоработанный (1 800 847 строк кода).
4 января 2001: Linux версии 2.4.0 (3 377 902 строки кода).
18 декабря 2003: Linux версии 2.6.0 (5 929 913 строк кода).
23 марта 2009: Linux версии 2.6.29, временный символ Linux тасманский дьявол Tuz (11 010 647 строк кода).
22 июля 2011: релиз Linux 3.0 (14,6 млн строк кода).
24 октября 2011: релиз Linux 3.1.
15 января 2012: релиз Linux 3.3 преодолел отметку в 15 млн строк кода.
23 февраля 2015: первый релиз-кандидат Linux 4.0 (более 19 млн строк кода).
7 января 2019: первый релиз-кандидат Linux 5.0 (более 26 млн строк кода).

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

Понятно, что чем дальше, тем проще уже не будет. Ведь область IT, это среда с самой высокой конкуренцией. И как уж тут не вспомнить Льюиса Кэррола, что его крылатое выражение
Нужно бежать со всех ног, чтобы только оставаться на месте, а чтобы куда-то попасть, надо бежать как минимум вдвое быстрее!
Ведь это относится не только к Алисе в стране чудес, но и к всем информационным технологиям в целом!
Подробнее..

Cоздание переиспользуемых Linq фильтров (построителей предикатов для Where), которые можно применять для разных типов

15.04.2021 22:17:49 | Автор: admin

Способ создания переиспользуемых Linq фильтров (построителей предикатов для условия Where), которые можно применять для разных типов объектов. Поля объектов для фильтрации указываются с помощью MemberExpression.

Способ подходит для Entity Framework, включая Async операции.

Основная идея. Что такое переиспользуемый фильтр?

Например есть приказы:

class Order { public DateTime Start { get; set; }public DateTime? End { get; set; }}

Пусть нужно найти все приказы которые будут действовать в ближайшие 7 дней.

С помощью переиспользуемого построителя фильтра (если бы он был реализован) найти приказы можно так:

var ordersFiltred = orders.WhereOverlap(// с помощью MemberExpressions// указываем по каким полям производить поискfromField: oo => oo.Start,toField: oo => oo.End,// указываем период поискаfrom: DateTime.Now,to: DateTime.Now.AddDays(7)).ToList();

Этот же WhereOverlap можно переиспользовать и применить к другому типу. Например, для поиска командировок:

class Trip { public DateTime? From { get; set; }public DateTime? To { get; set; }}
var tripsFiltred = trips.WhereOverlap(// с помощью MemberExpressions// указываем по каким полям производить поискfromField: oo => oo.From,toField: oo => oo.To,from: DateTime.Now,to: DateTime.Now.AddDays(7)).ToList();

Приказы и командировки - это разные типы объектов, у них нет общего интерфейса, поля для поиска называются по-разному. И все таки для обоих типов (и приказов и командировок) применяется один переиспользуемый фильтр WhereOverlap.

Ниже описано как можно делать такие переиспользуемые построители предикатов.

Как сделать переиспользуемый фильтр

Выше было описано применение WhereOverlap, было бы логично показать его реализацию. Но для того, чтобы сделать WhereOverlap, нужна реализация операторов И, ИЛИ. Поэтому начнем с более простого примера.

Пусть есть выплаты и премии:

class Payout { public decimal Total { get; set; }public bool UnderControl { get; set; }}class Premium {public decimal Sum { get; set; }public bool RequiresConfirmation { get; set; }}

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

class UnderControlPayFilter {readonly decimal Limit;public UnderControlPayFilter(decimal limit) {Limit = limit;}public Expression<Func<TEnt, bool>> Create<TEnt>(Expression<Func<TEnt, decimal>> sumField) {// GreaterOrEqual - нужно реализовать// GreaterOrEqual - это extension, который принимает//  - указание на поле (Expression sumField)//  - и значение с которым нужно сравнивать (Limit)return sumField.GreaterOrEqual(Limit);}}

Пример использования UnderControlPayFilter фильтра:

// фильтр поиска платежей требующих дополнительного контроля//// конкретный предел (здесь 1000) можно вынести в настройки,// а UnderControlPayFilter зарегистрировать в IoC-контейнере.// Тогда можно централизовано (через найстройки приложения)// управлять максимальным пределомvar underControlPayFilter = new UnderControlPayFilter(1000);//// Применение переиспользуемого фильтра для выплатvar payoutPredicate =underControlPayFilter.Create<Payout>(pp => pp.Total);// здесь, для упрощения, payouts - это массив,// в реальном приложении это может быть Entity Framework DbSet var payouts = new[] {new Payout{ Total = 100 },new Payout{ Total = 50, UnderControl = true },new Payout{ Total = 25.5m },new Payout{ Total = 1050.67m }}.AsQueryable().Where(payoutPredicate).ToList();//// Применение переиспользуемого фильтра для премийvar premiumPredicate =underControlPayFilter.Create<Premium>(pp => pp.Sum);// здесь, для упрощения, premiums - это массив,// в реальном приложении это может быть Entity Framework DbSet var premiums = new[] {new Premium{ Sum = 2000 },new Premium{ Sum = 50.08m },new Premium{ Sum = 25.5m, RequiresConfirmation = true },new Premium{ Sum = 1070.07m }}.AsQueryable().Where(premiumPredicate).ToList();

Все готово, осталось только реализовать GreaterOrEqual extension:

public static class MemberExpressionExtensions {    public static Expression<Func<TEnt, bool>> GreaterOrEqual<TEnt, TProp>(        this Expression<Func<TEnt, TProp>> field, TProp val)            => Expression.Lambda<Func<TEnt, bool>>(                Expression.GreaterThanOrEqual(field.Body, Expression.Constant(val, typeof(TProp))),                 field.Parameters);}

По аналогии можно реализовать extension-ы LessOrEqual, Equal, HasNoVal и другие.

Более сложные переиспользуемые фильтры с операторами И и ИЛИ

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

Дополним UnderControlPayFilter:

class UnderControlPayFilter {readonly decimal Limit;public UnderControlPayFilter(decimal limit) {Limit = limit;}public Expression<Func<TEnt, bool>> Create<TEnt>(Expression<Func<TEnt, decimal>> sumField,Expression<Func<TEnt, bool>> controlMarkField) {// PredicateBuilder нужно реализовать (см. ниже)return PredicateBuilder.Or(sumField.GreaterOrEqual(Limit),controlMarkField.Equal(true));}}

Пример использования:

// для выплатvar payoutPredicate =underControlPayFilter.Create<Payout>(sumField: pp => pp.Total,controlMarkField: pp => pp.UnderControl);// для премийvar premiumPredicate = underControlPayFilter.Create<Premium>(sumField: pp => pp.Sum,controlMarkField: pp => pp.RequiresConfirmation);

PredicateBuilder это A universal PredicateBuilder сделанный Pete Montgomery.

Заключение

Чтобы делать свои переиспользуемые фильтры, нужен только PredicateBuilder и MemberExpressionExtensions. Просто скопируйте их в свой проект. Переиспользуемые фильтры можно оформить как extension (как WhereOverlap), как статический хелпер или класс (как UnderControlPayFilter).

Я сделал парочку переиспользуемых фильтров - GitHub, NuGet (включает PredicateBuilder и MemberExpressionExtensions).

Подробнее..

Учим ASP.NET Core новым трюкам на примере Json Rpc 2.0

06.04.2021 04:10:16 | Автор: admin

Хотите добиться нестандартного поведения от aspnet core? Мне вот понадобилось добавить прозрачную поддержку Json Rpc. Расскажу о том, как я искал решения для всех хотелок, чтобы вышло красиво и удобно. Может быть, вам пригодятся знания о разных точках расширения фреймворка. Или о тонкостях поддержки Json Rpc, даже на другом стеке/языке.


В результате получилась библиотека, которая позволяет работать с Json Rpc, вообще не задумываясь, что он спрятан под капотом. При этом пользователю не нужно уметь ничего нового, только привычный aspnet mvc.


Введение


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


В тексте под Aspnet подразумевается ASP.Net Core MVC, в частности все писалось на 2.2, с прицелом на то, что выйдет 5.x и допилим под него.

И Json Rpc протокол JSON RPC 2.0 поверх HTTP.

Еще для чтения стоит ознакомиться с терминами протокола: method, params, request, notification...


Зачем все это?


Я .NET техлид в банке Точка и работаю над инфраструктурой для шарповых сервисов. Стараюсь сделать так, чтобы разработчикам было удобно, а бизнесу быстро и без ошибок. Добрался до причесывания обменов под корпоративные стандарты, и тут началось...


У нас для синхронного общения по HTTP принят Json Rpc 2.0. А у шарпистов основной фреймворк ASP.NET Core MVC, и он заточен под REST. И на нем уже написано некоторое количество сервисов. Если немного абстрагироваться, то REST, JSON RPC, и любой RPC вообще об одном и том же: мы хотим, чтобы на удаленной стороне что-то произошло, передаем параметры, ожидаем результат. А еще транспорт совпадает, все по HTTP. Почему бы не воспользоваться привычным aspnet для работы с новым протоколом? Хочется при этом поддержать стандарт полностью: в компании много разных стеков, и у всех Json Rpc клиенты работают немного по-разному. Будет неприятно нарваться на ситуацию, когда запросы например от питонистов не заходят, и нужно что-то костылить.


Со стороны aspnet-а можно делать типовые вещи разными способами, лишь бы разработчику было удобно. Довольно много ресурсов командой уже потрачено на то, чтобы разобраться, какой из способов больше нам подходит. А ведь еще нужно поддерживать единообразие. Чтобы никто не сходил с ума, читая код сервиса, который написан коллегой полгода назад. То есть вы нарабатываете best practices, поддерживаете какие-то небольшие библиотечки вокруг этого, избавляетесь от бойлерплейта. Не хочется это терять.


Еще немаловажный момент: желательно, чтобы опыт у разработчиков не терял актуальность, то есть не затачивался на внутренние костыли и самописные фреймворки. Если ты три года пишешь веб-сервисы на шарпе, претендуешь на мидлосеньора, а потом не можешь сделать, например, авторизацию общепринятыми способами в пустом проекте, потому что у вас было принято писать в коде контроллера if(cookie.Contains(userName)) это беда.


Конечно, протокол уже реализован на C#, и не раз. Ищем готовые библиотеки. Выясняется, что они либо тащат свои концепции, то есть придется долго вникать, как это готовить. Либо делают почти то, что нужно, но тяжело кастомизируются и переизобретают то, что в aspnet уже есть.

Собираем хотелки и пишем код


Чего хочется добиться? Чтобы как обычно писать контроллеры, накидывать фильтры и мидлвари, разбирать запрос на параметры. Чтобы наработанные best practices и библиотеки для aspnet подходили as-is. И при этом не мешать работать существующему MVC коду. Так давайте научимся обрабатывать Json Rpc теми средствами, что нам предоставляет фреймворк!


Request Routing


Казалось бы, HTTP уже есть, и нам от него надо только обрабатывать POST и возвращать всегда 200 OK. Контент всегда в JSON. Все прекрасно, сейчас напишем middleware и заживем.
Но не тут-то было! Мидлварь написать можно, только потом будем получать все запросы в один action, а в нем придется switch(request.Method) или запускать хендлеры из DI каким-нибудь костылем. А кто будет авторизацию и прочие фильтры прогонять в зависимости от метода? Переизобретать все это заново ящик Пандоры: делаешь свой аналог пайплайна, а потом придется поддерживать общий код и для aspnet, и для своего пайплайна. Ну, чтобы не было внезапных различий между тем, как вы проверяете роли или наличие какого-то заголовка.
Значит, придется влезть в роутинг и заставить его выбирать controller и action, глядя на тело HTTP запроса, а не только на url.


ActionMethodSelectorAttribute


К сожалению, все часто используемые инструменты для роутинга не позволяют парсить запрос или выполнять произвольный код. Есть стандартный роутер, но его нельзя без лишних проблем расширить или что-то в нем перегрузить. IRouter целиком писать, конечно же, не надо, если мы хотим гарантировать что обычный MVC не сломается. Казалось бы, есть Endpoint Routing, но в 2.2 сделана его начальная и неполная реализация, и чего-то полезного с ним напрямую не сделаешь. Костыли типа переписывания еndpoint на свой (после того, как Endpoint Routing отработал) почему-то не взлетели. Можно, конечно, прямо в middleware сделать редирект на нужный url, и это будет работать, только url испортится.


После долгих поисков был найден ActionMethodSelectorAttribute, который делает как раз то, что нужно: позволяет вернуть true/false в момент выбора controller и action! У него есть контекст с именем текущего метода-кандидата и его контроллера. Очень удобно.


Остается две проблемы: как повесить атрибут на все нужные нам контроллеры, и как не парсить тело каждый раз, когда выполняется проверка на "пригодность" метода?


Conventions


Атрибуты на контроллеры можно расставлять кодогенерацией, но это слишком сложно. У фреймворка и на этот случай есть решение: IControllerModelConvention, IActionModelConvention. Это что-то вроде знакомых многим Startup Filters: запускаются один раз на старте приложения и позволяют сделать все что угодно с метаданными контроллеров и методов, например переопределить роутинг, атрибуты, фильтры.


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


public abstract class JsonRpcController : ControllerBase {}

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


полезный код в ControllerConvention
public void Apply(ControllerModel controllerModel){    if (!typeof(JsonRpcController).IsAssignableFrom(controllerModel.ControllerType))    {        return;    }    controllerModel.Selectors.Clear();    controllerModel.Filters.Insert(0, new ServiceFilterAttribute(typeof(JsonRpcFilter)));}

Selectors отвечают за роутинг, и я честно не смог найти, почему это коллекция. В любом случае, нам не нужен стандартный роутинг по правилам MVC, поэтому удаляем все, что есть. Забегая вперед, применяем JsonRpcFilter, который будет отвечать за оборачивание ActionResult.


А вот ActionConvention
public void Apply(ActionModel actionModel){    if (!typeof(JsonRpcController).IsAssignableFrom(actionModel.Controller.ControllerType))    {        return;    }    actionModel.Selectors.Clear();    actionModel.Selectors.Add(new SelectorModel()    {        AttributeRouteModel = new AttributeRouteModel() {Template = "/api/jsonrpc"},        ActionConstraints = {new JsonRpcAttribute()}    });}

Здесь на каждый метод в наших контроллерах мы повесим один и тот же route, который потом вынесем в настройки.


И добавим тот самый атрибут
class JsonRpcAttribute : ActionMethodSelectorAttribute{    public override bool IsValidForRequest(RouteContext routeContext, ActionDescriptor action)    {        var request = GetRequest();  // пока не понятно как        // return true если action подходит под запрос, например:        return request.Method == action.DisplayName;    }}

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


Middleware


Пишем middleware, которая будет проверять, что запрос похож на Json Rpc: правильный Content-Type, обязательно POST, и тело содержит подходящий JSON. Запрос можно десериализовать в объект и сложить в HttpContext.Items. После этого его можно будет достать в любой момент.


Есть одна загвоздка: у middleware еще нет информации о типе, в который нужно десериализовать params, поэтому мы пока оставим их в виде JToken.


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


Parameter Binding


Мы научились подбирать контроллер и метод под запрос. Теперь нужно что-то делать с аргументами. Можно достать то, что десериализовала middleware из HttpContext.Items, и десериализовать JToken вручную в нужный тип, но это бойлерплейт и ухудшение читаемости методов. Можно взять JSON целиком из тела запроса с помощью [FromBody], но тогда всегда будет присутствовать шапка протокола: id, версия, метод. Придется каждую модель оборачивать этой шапкой: Request<MyModel> или class MyModel: RequestBase, и снова получим бойлерплейт.


Эти решения были бы еще терпимы, если бы протокол не вставлял палок в колеса.


Разные params


Json Rpc считает, что параметры, переданные массивом [] это одно и то же, что и параметры, переданные объектом {}! То есть, если нам прислали массив, нужно подставлять их в свой метод по порядку. А если прислали объект, то разбирать их по именам. Но вообще, оба сценария должны работать для одного и того же метода. Например, вот такие params эквивалентны и должны одинаково биндиться:


{"flag": true, "data": "value", "user_id": 1}[1, "value", true]

public void DoSomething(int userId, string data, bool flag)

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


Реализация


Посмотрим, что нам доступно для управления биндингом. Есть IModelBinder и IModelBinderProvider, но они смотрят на тип объекта. Заранее мы не знаем, какой тип пользователь захочет биндить. Может быть, int или DateTime. Мы не хотим конфликтовать с aspnet, поэтому просто добавить свой биндер для всех типов нельзя. Есть IValueProvider, но он возвращает только строки. Наконец, есть атрибуты FromBody, FromQuery и так далее. Смотрим в реализацию, находим интерфейс IBinderTypeProviderMetadata. Он нужен, чтобы возвращать нужный binder для параметра. Как раз то, что нужно!


Пишем свой FromParamsAttribute
 [AttributeUsage(AttributeTargets.Parameter)]public class FromParamsAttribute : Attribute, IBindingSourceMetadata, IBinderTypeProviderMetadata{    public BindingSource BindingSource => BindingSource.Custom;    public Type BinderType => typeof(JsonRpcModelBinder);}

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


`BindingInfo` с той же информацией
public void Apply(ParameterModel parameterModel){    if (!typeof(JsonRpcController).IsAssignableFrom(parameterModel.Action.Controller.ControllerType))    {        return;    }    if (parameterModel.BindingInfo == null)    {        parameterModel.BindingInfo = new BindingInfo()        {            BinderType = typeof(JsonRpcModelBinder),            BindingSource = BindingSource.Custom        };    }}

Проверка на BindingInfo == null позволяет использовать другие атрибуты, если нужно. То есть можно смешивать FromParams и штатные FromQuery, FromServices. Ну а по умолчанию, если ничего не указано, convention применит BindingInfo, аналогичный FromParams.


Удобства


Стоит учесть сценарий, когда неудобно разбирать params на отдельные аргументы. Что, если клиент просто прислал свой объект "как есть", а в нем очень много полей? Нужно уметь биндить params целиком в один объект:


{"flag": true, "data": "value", "user_id": 1}

public void DoSomething(MyModel model)

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


BindingStyle
public enum BindingStyle { Default, Object, Array }...public FromParamsAttribute(BindingStyle bindingStyle){    BindingStyle = bindingStyle;}

Default поведение по умолчанию, когда содержимое params биндится в аргументы. Object когда пришел json-объект, и мы биндим его в один параметр целиком. Array когда пришел json-массив и мы биндим его в коллекцию. Например:


// это успешно сбиндится: {"flag": true, "data": "value", "user_id": 1}// а это будет ошибкой: [1, "value", true]public void DoSomething1([FromParams(BindingStyle.Object)] MyModel model)// это успешно сбиндится: [1, "value", true]// а это будет ошибкой: {"flag": true, "data": "value", "user_id": 1}public void DoSomething2([FromParams(BindingStyle.Array)] List<object> data)

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


Как сопоставить имя аргумента и ключ в json-объекте?


JsonSerizlizer не позволяет "в лоб" десериализовать ключ json объекта как шарповое имя property или аргумента. Зато позволяет сериализовать имя в ключ.


// вот так не получится{"user_id": 1} => int userId//  зато можно наоборот и запомнить это в метаданныхint userId => "user_id"

То есть нужно для каждого аргумента узнать его "json-имя" и сохранить в метаданных. У нас уже есть conventions, там и допишем нужный код.


Учимся у aspnet


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


Регистрация в DI-контейнере


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


резолвить зависимости вручную
public Task BindModelAsync(ModelBindingContext context){    var service = context.HttpContext.RequestServices.GetServices<IService>();    // ...}

Error handling


Все ошибки протокол предлагает возвращать в виде специального ответа, в котором могут быть любые детали. Еще там описаны некоторые крайние случаи и ошибки для них. Придется перехватывать все exception-ы, заворачивать их в Json Rpc ответ, уметь прятать stack trace в зависимости от настроек (мы же не хотим высыпать все подробности на проде?). А еще нужно дать пользователю возможность вернуть свою Json Rpc ошибку, вдруг у кого-то на этом логика построена. В общем, ошибки придется перехватывать на разных уровнях. После написания десятого try/catch внутри catch поневоле начинаешь задумываться, что неплохо бы иметь возможность писать код с гарантией отсутствия exception-ов, или хотя бы с проверкой, что ты перехватил все, что можно...


Action вернул плохой ActionResult или Json Rpc ошибку


Возьмем IActionFilter.OnResultExecuting и будем проверять, что вернулось из метода: нормальный объект завернем в Json Rpc ответ, плохой ответ, например 404, завернем в Json Rpc ошибку. Ну или метод уже вернул ошибку по протоколу.


Binding failed


Нам пригодится IAlwaysRunResultFilter.OnActionExecuting: можно проверить context.ModelState.IsValid и понять, что биндинг упал. В таком случае вернем ошибку с сообщением, что не получилось у биндера. Если ничего не делать, то в action попадут кривые данные, и придется проверять каждый параметр на null или default.


Схожим образом работает стандартный ApiControllerAttribute: он возвращает 400, если биндинг не справился.


Что-то сломалось в pipeline


Если action или что-нибудь в pipeline выбросит exception, или решит записать HttpResponse, то единственное место, где мы еще можем что-то сделать с ответом, это middleware. Придется и там проверять, что получилось после обработки запроса: если HTTP статус не 200 или тело не подходит под протокол, придется заворачивать это в ошибку. Кстати, если писать ответ прямо в HttpResponse.Body, то сделать с ним уже ничего не получится, но для этого тоже будет решение чуть ниже.


Ошибки это сложно


Для удобства пригодится класс, который будет отвечать за проверку требований протокола (нельзя использовать зарезервированные коды), позволит использовать ошибки, описанные в протоколе, маскировать exception-ы, и подобные штуки.


class JsonRpcErrorFactory{    IError NotFound(object errorData){...}    IError InvalidRequest(object errorData){...}    IError Error(int code, string message, object errorData){...}    IError Exception(Exception e){...}    // и так далее}

Batch


Batch-запросы aspnet не поддерживает никак. А они требуются стандартом. И хочется, чтобы на каждый запрос из батча был свой пайплайн, чтобы в тех же фильтрах не городить огород. Можно, конечно, сделать прокси, который будет разбирать батч на отдельные запросы, отправлять их на localhost, потом собирать ответ. Но это кажется безумным оверхедом из-за сериализации HTTP body в байты, установления соединения После долгих путешествий по issues в Github, находим грустный тред о том, что батчи когда-то были, но пока нет и неизвестно когда вернутся. А еще они есть в OData, но это целый отдельный мир, фреймворк поверх фреймворка погодите-ка, мы же тоже пишем что-то такое!. Там же находим идею и репозиторий с реализацией: можно скопировать HttpContext и в middleware позвать next() со своим контекстом, а потом собрать результат и отправить все вместе уже в настоящий HttpContext. Это поначалу немного ломает мозг, потому что мы привыкли к мантре: нужно передавать управление вызовом next(context), и по-другому никто эту штуку не использует.


Таким образом, middleware будет парсить Json Rpc запрос, создавать копию контекста, и вызывать пайплайн дальше. Это же пригодится для перехвата ошибок, если кто-то решит писать прямо в HttpResponse.Body: мы вместо настоящего body подсунем MemoryStream и проверим, что там валидный JSON.


У этого подхода есть минус: мы ломаем стриминг для больших запросов/ответов. Но что поделать, JSON не подразумевает потоковую обработку. Для этого, конечно, есть разные решения, но они гораздо менее удобны, чем Json.NET.


ID


Протокол требует поле id в запросе, при чем там могут быть число, строка или null. В ответе должен содержаться такой же id. Чтобы одно и то же поле десериализовалось как число или строка, пришлось написать классы-обертки, интерфейс IRpcId и JsonConverter, который проверяет тип поля и десериализует в соответствующий класс. В момент, когда мы сериализуем ответ, из HttpContext.Items достаем IRpcId и прописываем его JToken-значение. Таким образом, пользователю не надо самому заморачиваться с проставлением id и нет возможности забыть об этом. А если нужно значение id, можно достать из контекста.


Notification


Если id отсутствует, то это не запрос, а уведомление (notification). На notification не должен уходить ответ: ни успешный, ни с ошибкой, вообще никакой. Ну, то есть по HTTP-то мы вернем 200, но без тела. Чтобы все работало одинаково для запросов и нотификаций, пришлось выделить абстракцию над ними, и в некоторых местах проверять, запрос ли это и нужно ли сериализовать ответ.


Сериализация


Aspnet умеет сериализовать JSON. Только у него свои настройки, а у нас свои. Сериализация настраивается с помощью Formatters, но они смотрят только на Content-Type. У Json Rpc он совпадает с обычным JSON, поэтому просто так добавить свой форматтер нельзя. Вложенные форматтеры или своя реализация плохая идея из-за сложности.


Решение оказалось простым: мы уже оборачиваем ActionResult в фильтре, там же можно


подставить нужный форматтер
...var result = new ObjectResult(response){    StatusCode = 200,};result.Formatters.Add(JsonRpcFormatter);result.ContentTypes.Add(JsonRpcConstants.ContentType);...

Здесь JsonRpcFormatter это наследник JsonOutputFormatter, которому переданы нужные настройки.


Configuration


Нужно дать пользователю разные "ручки". При чем удобно дать настройку по умолчанию (например дефолтный route) и возможность умолчание обойти, например, атрибутом: когда вот для этого контроллера нужен свой особенный route.


Имя метода


У Json Rpc запросов есть поле method, которым определяется, что должно быть вызвано на сервере. И это поле просто строка. Ей пользуются как угодно. Придется научить сервер понимать распространенные варианты.


public enum MethodStyle {ControllerAndAction, ActionOnly}

ControllerAndAction будет интерпретировать method как class_name.method_name.


ActionOnly просто method_name.


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


Сериализация


Еще встает вопрос с JSON-сериализацией. Формат "шапки" строго обозначен в протоколе, то есть переопределять его нужно примерно никогда. А вот формат полей params, result и error.data оставлен свободным. Пользователь может захотеть сериализацию со своими особыми настройками. Нужно дать такую возможность, при этом не позволяя сломать сериализацию шапки и не накладывая особых требований на пользователя. Например, для работы с шапкой используются хитрые JsonConverterы, и не хотелось бы чтобы они как-то торчали наружу. Для этого сделана минимальная обертка поверх JsonSeralizer, чтобы пользователь мог зарегистрировать в DI свой вариант и не сломать REST/MVC.


Нестандартные ответы


Бывает, что нужно вернуть бинарный файл по HTTP, или Redirect для авторизации. Это явно идет в разрез с Json Rpc, но очень удобно. Такое поведение нужно разрешать при необходимости.


Объединяем все вместе


Добавим классы с опциями, чтобы рулить умолчаниями
public class JsonRpcOptions{    public bool AllowRawResponses { get; set; }  // разрешить ответы не по протоколу?    public bool DetailedResponseExceptions { get; set; }  // маскировать StackTrace у ошибок?    public JsonRpcMethodOptions DefaultMethodOptions { get; set; }  // см. ниже    public BatchHandling BatchHandling { get; set; }  // задел на параллельную обработку батчей в будущем}public class JsonRpcMethodOptions{        public Type RequestSerializer { get; set; }  // пользовательский сериалайзер    public PathString Route { get; set; }  // маршрут по умолчанию, например /api/jsonrpc    public MethodStyle MethodStyle { get; set; }  // см. выше}

И атрибуты, чтобы умолчания переопределять:


  • FromParams про который было выше
  • JsonRpcMethodStyle чтобы переопределить MethodStyle
  • JsonRpcSerializerAttribute чтобы использовать другой сериалайзер.

Для роутинга свой атрибут не нужен, все будет работать со стандартным [Route].


Подключаем


Пример кода, который использует разные фичи. Важно заметить, что это никак не мешает обычному коду на aspnet!


Startup.cs
services.AddMvc()    .AddJsonRpcServer()    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);// или с опциямиservices.AddMvc()    .AddJsonRpcServer(options =>    {        options.DefaultMethodOptions.Route = "/rpc";        options.AllowRawResponses = true;    })    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

Контроллер
public class MyController : JsonRpcController{    public ObjectResult Foo(object value, bool flag)    {        return Ok(flag ? value : null);    }    public void BindObject([FromParams(BindingStyle.Object)] MyModel model)    {    }    [Route("/test")]    public string Test()    {        return "test";    }    [JsonRpcMethodStyle(MethodStyle.ActionOnly)]    public void SpecialAction()    {    }    [JsonRpcSerializer(typeof(CamelCaseJsonRpcSerializer))]    public void CamelCaseAction(int myParam)    {    }}

Клиент


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


HttpClient


В .net core HttpClient научили работать с DI, типизировать и вообще все стало гораздо удобнее. Грех не воспользоваться!


Batch


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


Обработка ошибок


Снова сложности с обработкой ошибок. Дело в том, что мы не знаем, с какими правилами сериализации сервер вернул ошибку: со стандартными, как в "шапке", или с кастомными, например когда мы договорились на клиенте и на сервере использовать camelCase, но у сервера что-то сломалось в middleware и дело до нашего action не дошло вообще. Поэтому придется пробовать десериализовать и так, и так. Здесь нет очевидно хорошего решения, поэтому интерфейс response содержит


Разные методы для интерпретации ответа
T GetResponseOrThrow<T>();  // достать успешный ответ, если нет - достать ошибку и выбросить ее как исключениеT AsResponse<T>(); // только достать ответError<JToken> AsAnyError(); // достать ошибку, не десериализуя ееError<T> AsTypedError<T>(); // достать ошибку по правилам сериализации как в запросе или по дефолтным, если не получилосьError<ExceptionInfo> AsErrorWithExceptionInfo(); // достать ошибку с деталями exception-а с сервера

Для удобства в самом простом случае есть GetResponseOrThrow() или ожидаемый ответ, или исключение. Для детального разбора все остальные методы.


Developer Experience


Я считаю, что получилось решение, когда разработчик может подключить и забыть, что там какой-то Json Rpc. При этом можно полагаться на свой опыт работы с aspnet, использовать привычные подходы и без проблем подключать любые сторонние библиотеки к приложению. С другой стороны, есть возможность переопределять какое-то поведение, мало ли какие потребности возникнут. Часто используемые штуки вынесены в параметры и атрибуты, а если не хватает, можно посмотреть код и подменить что-то: все методы виртуальные, сервисы используются по интерфейсам. Можно расширить или написать полностью свою реализацию.


TODO


В планах: добавить поддержку aspnetcore 5.x, добить покрытие тестами до 100%, перевести документацию на русский и добавить параллельную обработку батчей. Ну и, конечно же, поддерживать проект как нормальный open source: любой фидбек, пуллреквесты и issues приветствуются!


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


Ссылки


Исходники


Документация


Бонус


Статья лежала в черновиках почти год, за это время к библиотеке была добавлена поддержка автодокументации Swagger и OpenRpc. А еще сейчас в разработке поддержка OpenTelemetry. Кому-нибудь интересны подробности? Там местами такая жуть...

Подробнее..

Альтернативное собеседование на позицию разработчика ПО

07.04.2021 12:05:29 | Автор: admin

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

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

Предлагалось сделать следующее:

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

  2. Провести code-review, указать на подозрительные и плохие места и предложить, как можно их улучшить или переделать. Можно задавать любые вопросы и гуглить все что угодно.

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

class SomeServiceClient{ public:  SomeServiceClient();  virtual ~SomeServiceClient();  bool CallAsync(const std::string& uri,                 const std::string& param,                 const misc::BusServiceClient::ResponseCB& callback);  bool CallSync(const std::string& uri,                const std::string& param,                const misc::BusServiceClient::ResponseCB& callback); private:  misc::BusServiceClient ss_client_;  static const int kSleepMs = 100;  static const int kSleepCountMax = 50;};class SpecificUrlFetcher : public UrlFetcher { public:  SpecificUrlFetcher();  virtual ~SpecificUrlFetcher();  SomeData FetchData(const URL& url, const UrlFetcher::ResponseCB& callback); private:  bool SsResponse_returnValue{false};  char SsResponse_url[1024];  void SsResponseCallback(const std::string& response);  SomeServiceClient* ss_client_;};...static const char ss_getlocalfile_uri[] =    "bus://url_replace_service";namespace net {pthread_mutex_t g_url_change_callback_lock = PTHREAD_MUTEX_INITIALIZER;SomeBusServiceClient::SomeBusServiceClient()    : ss_client_(misc::BusServiceClient::PrivateBus) {}SomeBusServiceClient::~SomeBusServiceClient() {}bool SomeBusServiceClient::CallAsync(    const std::string& uri,    const std::string& param,    const misc::BusServiceClient::ResponseCB& callback) {  bool bRet;  bRet = ss_client_.callASync(uri, param, callback);  return bRet;}bool SomeBusServiceClient::CallSync(    const std::string& uri,    const std::string& param,    const misc::BusServiceClient::ResponseCB& callback) {  boold bRet  bRet = false;  int counter;  pthread_mutex_lock(&g_url_change_callback_lock);   ss_client_.callASync(uri, param, callback);  counter = 0;  for (;;) {    int r = pthread_mutex_trylock(&g_url_change_callback_lock);    if (r == 0) {      bRet = true;      pthread_mutex_unlock(&g_url_change_callback_lock);    } else if (r == EBUSY) {      usleep(kSleepMs);      counter++;      if (counter >= kSleepCountMax) {        pthread_mutex_unlock(&g_url_change_callback_lock);        break;      } else        continue;    }    break;  }  return bRet;}/**************************************************************************/SpecificUrlFetcher::SpecificUrlFetcher() {}SpecificUrlFetcher::~SpecificUrlFetcher() {}void SpecificUrlFetcher::SsResponseCallback(const std::string& response) {  std::unique_ptr<lib::Value> value(lib::JSONReader::Read(response));  if (!value.get() || !value->is_dict()) {    pthread_mutex_unlock(&g_url_change_callback_lock);    return;  }  lib::DictionaryValue* response_data =      static_cast<lib::DictionaryValue*>(value.get());  bool returnValue;  if (!response_data->GetBoolean("returnValue", &returnValue) || !returnValue) {    pthread_mutex_unlock(&g_url_change_callback_lock);    return;  }  std::string url;  if (!response_data->GetString("url", &url)) {    pthread_mutex_unlock(&g_url_change_callback_lock);    return;  }  SsResponse_returnValue = true;  size_t array_sz = arraysize(SsResponse_url);  strncpy(SsResponse_url, url.c_str(), array_sz);  SsResponse_url[array_sz - 1] = 0;  pthread_mutex_unlock(&g_url_change_callback_lock);}SomeData SpecificUrlFetcher::FetchData(const URL& url, const UrlFetcher::ResponseCB& callback) {lib::DictionaryValue dictionary;std::string ss_request_payload;misc::BusServiceClient::ResponseCB response_cb =lib::Bind(&SpecificUrlFetcher::SsResponseCallback, this);SomeBusServiceClient* ss_client_ =new SomeBusServiceClient();dictionary.SetString("url", url.to_string());lib::JSONWriter::Write(dictionary, &ss_request_payload);SsResponse_returnValue = false;SsResponse_url[0] = 0x00;ss_client_->CallSync(ss_getlocalfile_uri, ss_request_payload, response_cb);URL new_url;if (SsResponse_returnValue) {  new_url = URL::from_string(SsResponse_url);}delete ss_client_;return UrlFetcher::FetchData(new_url, callback);}}  // namespace net

Ответы будут под спойлером, нажимайте на него осознанно, пути назад уже нет.

Итак, ответы.
  1. У нас есть какой-то класс UrlFetcher, задача которого, судя по всему -- получать какие-то данные по какому-то URL'у. Унаследованный у него класс делает то же самое, только перед запросом обращается по какой-то шине сообщений к какому-то внешнему сервису, отправляя ему запрошенный URL, и вместо него получает от этого сервиса некий другой URL, который и используется дальше. Этакий паттерн Decorator.

  2. Сначала по мелочам:

    1. ss_getlocalfile_uri - глобальная переменная. Зачем? Можно было объявить ее внутри одного из классов.

    2. В некоторых местах объявляется переменная, а в следущей же строке ей присваеивается значение. Можно совместить.

    3. Странный стиль именования переменных и полей, например SsResponse_returnValue Далее по-серьезнее:

    4. Используется pthread-функции, при том что есть стандартные std::thread, которых в данном случае более чем достаточно.

    5. Используются Си-строки с методами типа strncpy(); по факту тут можно использовать std::string без каких-либо проблем.

    6. ss_client_ хранится в сыром указателе и удаляется вручную. Лучше использовать std::unique_ptr.

    7. Вместо usleep() лучше все-таки использовать std::this_thread::sleep()

    Еще серьезнее:

    8. В цикле в SomeBusServiceClient::CallSync если колбэк с ответом придет менее чем за kSleepMs до kSleepCountMax, то мы откинем ответ и не выполним задачу. Это плохо.

    А теперь еще серьезнее:

    9. Мы отправляем асинхронный запрос в message bus и ждем. Отправленный запрос по истечении таймаута не отменяется. Неизвестно, как работает этот message bus, но если вдруг у класса работы с ним есть какой-то таймаут по умолчанию, то стоит использовать его как kSleepCountMax*kSleepMs, а если ничего такого нет, то нужно как-то отменять уже отправленный запрос когда он нам стал не нужен (возможно callASync возвращает какой-нибудь id запроса?). Потому что если вдруг по какой-то причине ответ придет сильно позже, когда мы уже не ждем, а начали получать следущий URL, то случится полный бардак.

    9. В функции FetchData нет проверки на ошибку, new_url в любом случае передается в метод базового класса, даже если он пустой.

    10. Метод FetchUrl, судя по сигнатуре, изначально асинхронный. В наследуемом классе же по факту из асинхронного метода делается синхронный, потом блокируется до получения ответа, а уже потом вызывает действительно асинхронный метод родительского класса -- WTF? Почему нельзя было сразу сделать все асинхронно?

    11. Судя по логике работы (вызов FetchUrl синхронный и блокирует тред), SsResponseCallback должен выполниться в другом треде. При этом получается, что мы разблокируем мьютекст не в том потоке, где мы его блокировали. Для pthread это явный undefined behavior.

Ответы и замечания от кандидата позволяли составить представление о его уровне владения современными стандартами C++ и хорошими практиками, понимании асинхронности и многопоточности, дотошности на ревью и умении "отлаживать" код в голове. Ну и задать темы для дальнейшего разговора по душам.

Подробнее..

Трансляция кода с C на C работа портера

14.04.2021 14:22:42 | Автор: admin

Привет, Хабр. Некоторое время назад ярассказывало том, как нам удалось наладить ежемесячный выпуск релизов для платформы C++ (на Windows и Linux) библиотек, исходный код которых получается путём автоматической трансляции кода оригинальных продуктов, написанных на C#. Также яписало том, как мы заставили такой транспилированный код выполняться в рамках нативного C++ без сборки мусора.

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


Поколения фреймворка

Прежде чем начать говорить о деталях реализации, мне придётся сказать несколько слов об истории проекта, чтобы стали ясны некоторые отсылки. Как я уже рассказывал в первой статье, наша компания занимается, в основном, разработкой библиотек для платформы .Net на C#. Наша команда разрабатывает и поддерживает решения, предназначенные для выпуска данного кода под другие платформы. При нашем объёме кодовой базы автоматическая трансляция полного кода продукта на целевой язык оказывается существенно дешевле, чем параллельная разработка на нескольких языках. Запуск библиотеки .Net внутри плюсовой оболочки - другой возможный подход, но он также является трудоёмким из-за необходимости корректного проброса всех API, да и работало это не на всех целевых платформах, когда мы начинали.

На сегодняшний день наш стек технологий включает в себя следующие продукты:

  1. Самый первый портер с C# на Java на основе текстового процессора - устарел, более не используется.

  2. Портер с C# на Java на основе синтаксического анализатора Metaspec - актуален.

  3. Портер с C# на C++ на основе синтаксического анализатора NRefactory - актуален.

  4. Построенный на Roslyn и рефлексии генератор модулей Python, являющихся обёртками над машиной .Net, в которой выполняются оригинальные продукты на C# - актуален.

  5. Портеры с C# на Java и C++ на основе общего фреймворка, построенного на Roslyn - в разработке.

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

  1. Подготовка кода C# к портированию существующими портерами - например, понижение версии языка до той, которая поддерживается синтаксическими анализаторами Metaspec (3.0) и/или NRefactory (5.0).

  2. Анализ кода C# на удовлетворение требованиям, накладываемым процедурами портирования.

  3. Трансляция аспектов кода C#, плохо покрываемых существующими портерами (документация, примеры использования и т. д.).

Архитектура

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

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

Когда такого подхода перестало хватать, была начата разработка первого портера на базе синтаксического анализатора Metaspec. В этой реализации код исходного проекта на C# загружается анализатором и преобразуется в AST-представление (отдельное дерево для каждого файла с исходным кодом). Кроме того, Metaspec строит семантическую модель, позволяющую разрешать имена типов и обращения к членам. Используя эти две модели, портер выполняет две стадии: анализ кода C# и генерацию кода Java. Стадия анализа нужна для поиска нетранслируемых конструкций и вывода соответствующих ошибок и предупреждений, а также для сбора дополнительной информации, влияющей на кодогенерацию. На стадии кодогенерации происходит непосредственное формирование выходного кода.

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

Архитектура портера с C# на C++, построенного на базе синтаксического анализатора NRefactory несколькими годами позже, во многом подобна описанной выше. После загрузки кода в AST-представление и построения семантической модели по дереву совершается несколько проходов посетителями для сбора предварительной информации, после чего генерируется код C++ - опять же, в один проход. Дерево кода C# остаётся неизменным и в этой модели. Отличия касаются, прежде всего, декомпозиции кода и разделения обязанностей на этапе кодогенерации, хотя полностью изолировать алгоритмы и избавиться от божественных объектов не удалось и на этой итерации.

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

  1. Был ли данный класс исключён из портирования атрибутом или указанием его имени в соответствующем разделе конфигурационного файла.

  2. Является ли данный класс обобщённым типом.

  3. Если да, существуют ли другие классы с тем же полным именем, но другим набором параметров типа (перегрузка по числу аргументов шаблона в C++ не поддерживается, в отличие от C#).

  4. Заданы ли для класса или его членов атрибуты, влияющие на порядок членов класса в генерируемом коде.

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

  6. Является ли хоть один из классов, внешних по отношению к текущему, обобщённым.

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

  8. Является ли класс коллекцией тестов (TestFixture или Theory).

  9. Является ли класс абстрактным.

  10. Заданы ли для класса атрибуты, влекущие его переименование в выходном коде.

  11. Какие базовые типы есть у класса, какие из них удалены или добавлены атрибутами, влияющими на поведение портера.

  12. Заданы ли для обобщённых параметров класса ограничения.

  13. Является ли класс наследником System.Exception.

  14. Удовлетворены ли условия для добавления к классу конструкторов или деструктора, отсутствующих в исходном коде.

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

  16. Относится ли класс к цепочкам наследования, для которых в коде присутствуют вызовы Clone() или MemberwiseClone(), которые нужно эмулировать отдельно.

  17. Существуют ли условия для добавления к методам выходного класса перегрузок, отсутствующих в исходном классе.

  18. Зависят ли инициализаторы констант класса друг от друга.

  19. Включена ли для данного класса (или для всех классов) поддержка рефлексии.

  20. Есть ли у класса комментарий, является ли он описанием в формате XML, и какие опции заданы для его обработки.

  21. Прочие условия.

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

Архитектура общего движка, на базе которого в настоящее время пишутся портеры с C# на Java и на C++, была пересмотрена с учётом данной проблемы, а также дополнительных возможностей, которые даёт Roslyn. Дерево кода в этой структуре более не является неизменным и последовательно модифицируется различными алгоритмами на новой стадии - стадии трансформации. Данные алгоритмы пишутся в соответствии с правилом единственной обязанности. На стадии трансформации выполняется столько работы, сколько возможно: синтаксический сахар C# заменяется конструкциями, доступными в C++ и Java, производится переименование типов и членов, удаляются сущности, исключённые из портирования, изменяются области видимости, модифицируются списки базовых типов, и так далее. В итоге логика кодогенерации существенно упрощается. С другой стороны, появляются дополнительные накладные расходы по управлению доступными алгоритмами модификации дерева и очерёдностью их выполнения.

Стадия анализа слилась со стадией трансформации. Алгоритмы стадии кодогенерации подверглись дополнительной декомпозиции: теперь за обработку отдельного типа узла отвечает, как правило, отдельный класс. Кроме того, было сделано большое количество других полезных изменений: улучшена подсистема конфигурирования, пересмотрен механизм замены типов C# типами целевых языков, поддержана работа не только в виде приложения (командной строки или оконного), но и в виде плагина для Visual Studio, работающего непосредственно с загруженным решением и встроенными средствами диагностики, и так далее.

Операции над исходным кодом

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

  1. Анализ кода C# на портируемость.
    Продукты разрабатываются программистами, редко знающими в подробностях процедуру портирования кода на другие языки и связанные с ней ограничения. В результате возникают ситуации, когда корректные с точки зрения C# изменения, сделанные продуктовыми разработчиками, ломают процедуру выпуска релизов для других языков. Например, на сегодняшний день ни один из наших портеров не имеет поддержки оператора yield, и его использование в коде C# приведёт к генерации некорректного кода Java или C++.
    За время развития проекта нами были испробованы несколько способов автоматизации обнаружения таких проблем.

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

    2. Проблема может быть обнаружена в среде CI (мы используем Jenkins и SonarQube). Таким образом, о проблеме узнают разработчики C# перед слиянием в общую ветку или после такого слияния, в зависимости от принятых конкретной командой практик. Это увеличивает оперативность исправления проблем, но требует программирования дополнительных проверок в инфраструктуре портера или в сторонних утилитах.

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

    4. Проблема может быть обнаружена локально при работе в IDE. Установка плагина к Visual Studio позволяет разработчику C# обнаруживать проблемы в реальном времени. Это по-прежнему требует дополнительных затрат на разработку экосистемы, зато предоставляет наиболее оперативный способ обнаружения проблем. В этом смысле интеграция Roslyn в современные версии Visual Studio особенно удобна, так как позволяет использовать одни и те же анализаторы как в контексте загруженного в данный момент решения, так и в ином окружении - например, в среде CI.

  2. Понижение версии языка C#.
    Как уже говорилось выше, мы ограничены в использовании версий языка C#: 3.0 для портирования на Java и 5.0 для портирования на C++. Это требует дисциплины от программистов C# и во многих случаях неудобно. Чтобы обойти эти ограничения, портирование можно провести в два этапа: сначала заменить конструкции современных версий языка C# поддерживаемыми аналогами из прошлых стандартов, затем приступить непосредственно к портированию.
    При использовании портеров, основанных на устаревших синтаксических анализаторах, понижение может быть выполнено только путём использования внешних инструментов (например, утилит, написанных на базе Roslyn). С другой стороны, портеры, основанные на Roslyn, выполняют оба этапа последовательно, что позволяет использовать один и тот же код как при портировании кода ими, так и при подготовке кода к портированию более старыми инструментами.

  3. Подготовка примеров использования портированных библиотек.
    Это похоже на портирование кода продуктов, однако подразумевает несколько иные требования. При портировании библиотеки на десятки миллионов строк важно, прежде всего, максимально строгое следование поведению оригинального кода даже в ущерб читаемости: более простой, но отличающийся по эффектам код отлаживать придётся дольше. С другой стороны, примеры использования нашего портированного кода должны выглядеть максимально просто, давая понять, как пользоваться нашим кодом в C++, даже если это не соответствует поведению оригинальных примеров, написанных на C#.
    Так, при создании временных объектов программисты C# часто пользуютсяusing statement, чтобы избежать утечки ресурсов и строго задать момент их высвобождения, не полагаясь на GC. Строгое портирование using даёт достаточно сложный код C++ (см. ниже) из-за множества нюансов вида "если в блоке using statement вылетает исключение и из Dispose тоже вылетает исключение, какое из них попадёт в перехватывающий контекст?". Такой код лишь введёт в заблуждение программиста C++, создав впечатление, что использовать библиотеку сложно, однако на самом деле умного указателя на стеке, в нужный момент удаляющего объект и высвобождающего ресурсы, вполне достаточно.

  4. Подготовка документации к коду.
    Наши библиотеки предоставляют богатый API, задокументированный через XML-комментарии в соответствии с практиками C#. Перенос комментариев в C++ (мы используем Doxygen) - задача отнюдь не тривиальная: помимо разметки, необходимо заменить ссылки на типы (в C# полные имена записываются через точку, в C++ - через пару двоеточий) и их члены (а в случае использования свойств - ещё и понять, идёт ли речь о геттере или сеттере), а также оттранслировать фрагменты кода (которые лишены семантики и могут быть неполными).
    Эта задача решается как средствами самого портера, так и внешними утилитами - например, анализирующими сгенерированную XML-документацию и дополнительно подготовленные фрагменты вроде примеров использования методов.

Правила трансляции кода с C# на C++

Поговорим о том, каким образом синтаксические конструкции языка C# отображаются на C++. Эти правила не зависят от поколения продукта, поскольку иное существенно затруднило бы процесс миграции. Данный список не будет исчерпывающим ввиду ограниченного объёма статьи, но я постараюсь уделить внимание хотя бы основным моментам.

Проекты и единицы компиляции

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

Один проект C# преобразуется в один или два проекта C++. Первый проект (приложение или библиотека) аналогичен проекту C#, второй представляет собой googletest-приложение для запуска тестов (если они присутствуют в исходном проекте). Тип выходной библиотеки (статическая или динамическая) задаётся опциями портера. Для каждого входного проекта портер генерирует файл CMakeLists.txt, который позволяет создавать проекты для большинства сборочных систем. Зависимости между оттранслированными проектами настраиваются вручную в конфигурации портера или скриптах Cmake.

В большинстве случаев одному файлу .cs соответствует один файл .h и один файл .cpp. Имена файлов по возможности сохраняются (хотя из-за особенностей некоторых сборочных систем для C++ портер старается не допускать присутствия файлов с одинаковыми именами, пусть и в разных каталогах). Обычно определения типов попадают в заголовочный файл, а определения методов - в файл исходного кода, но это не так для шаблонных типов, весь код которых остаётся в заголовочных файлах. Файлы .cpp, в которые не попадает никакого кода, опускаются за ненадобностью.

Заголовочные файлы, в которых присутствует, по крайней мере, одно публичное определение, попадают в каталог включаемых файлов, доступных зависимым проектам (и конечным пользователям). Заголовочные файлы, включающие лишь непубличные (internal) определения, попадают в каталог с исходниками. Публичные заголовки зачастую генерируются в двух модификациях: при сборке проекта используется полная версия, тогда как внешним пользователям достаются файлы, в которых скрыта ненужная им информация: имена и типы закрытых полей, непубличные методы (кроме виртуальных), и так далее. Помимо чистоты кода, такая обфускация преследует своей целью затруднить реверс-инжиниринг.

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

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

Общая структура исходного кода

Пространства имён C# отображаются в пространства имён C++. Операторы использования пространств имён превращаются в аналоги из C++, по умолчанию попадая лишь в файлы .cpp (если опциями портирования не задано иное). Комментарии переносятся как есть, кроме документации к типам и методам, обрабатываемой отдельно. Форматирование сохраняется частично. Директивы препроцессора не переносятся (максимум - добавляются соответствующие комментарии), поскольку при построении синтаксического дерева необходимо уже задать все константы.

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

Метаданные к типам генерируются портером в виде специальных структур данных, доступных во время выполнения. Поскольку безусловная генерация метаданных существенно увеличивает объём скомпилированных библиотек, она обычно включается вручную для отдельных типов по мере необходимости.

Определения типов

Псевдонимы типов транслируются с использованием синтаксиса "using <typename> = ...". Перечисления C# транслируются в перечисления C++14 (синтаксис enum class).

Делегаты преобразуются в псевдонимы для специализаций класса System::MulticastDelegate:

public delegate int IntIntDlg(int n);
using IntIntDlg = System::MulticastDelegate<int32_t(int32_t)>;

Классы и структуры C# отображаются на классы C++. Интерфейсы превращаются в абстрактные классы. Структура наследования соответствует таковой в C# (неявное наследование от System.Object становится явным), если атрибутами не задано иное (например, для создания компактной структуры данных без лишних наследований и виртуальных функций). Свойства и индексаторы разбиваются на геттеры и сеттеры, представленные отдельными методами.

Виртуальные функции C# отображаются на виртуальные функции C++. Реализация интерфейсов также производится с использованием механизма виртуальных функций. Обобщённые (generic) типы и методы превращаются в шаблоны C++. Финализаторы переходят в деструкторы. Всё это вместе задаёт несколько ограничений:

  1. Трансляция виртуальных обобщённых методов не поддерживается.

  2. Реализация интерфейсных методов виртуальна, даже если в исходном коде это не так.

  3. Введение новых (new) методов с именами и сигнатурами, повторяющими имена и сигнатуры существующих виртуальных и/или интерфейсных методов, невозможно (но портер позволяет переименовывать такие методы).

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

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

Понятно, что строгая имитация поведения C# требовала бы несколько иного подхода, и, если бы речь шла о трансляции приложений, это было бы оправдано. Тем не менее, мы предпочли следовать именно такой логике, поскольку в этом случае API портированных библиотек в наиболее полной мере соответствует парадигмам C++. Приведённый ниже пример демонстрирует эти особенности.

Код C#:

using System;public class Base{    public virtual void Foo1()    { }    public void Bar()    { }}public interface IFoo{    void Foo1();    void Foo2();    void Foo3();}public interface IBar{    void Bar();}public class Child : Base, IFoo, IBar{    public void Foo2()    { }    public virtual void Foo3()    { }    public T Bazz<T>(object o) where T : class    {        if (o is T)            return (T)o;        else            return default(T);    }}

Заголовочный файл C++:

#pragma once#include <system/object_ext.h>#include <system/exceptions.h>#include <system/default.h>#include <system/constraints.h>class Base : public virtual System::Object{    typedef Base ThisType;    typedef System::Object BaseType;        typedef ::System::BaseTypesInfo<BaseType> ThisTypeBaseTypesInfo;    RTTI_INFO_DECL();    public:    virtual void Foo1();    void Bar();    };class IFoo : public virtual System::Object{    typedef IFoo ThisType;    typedef System::Object BaseType;        typedef ::System::BaseTypesInfo<BaseType> ThisTypeBaseTypesInfo;    RTTI_INFO_DECL();    public:    virtual void Foo1() = 0;    virtual void Foo2() = 0;    virtual void Foo3() = 0;    };class IBar : public virtual System::Object{    typedef IBar ThisType;    typedef System::Object BaseType;        typedef ::System::BaseTypesInfo<BaseType> ThisTypeBaseTypesInfo;    RTTI_INFO_DECL();    public:    virtual void Bar() = 0;    };class Child : public Base, public IFoo, public IBar{    typedef Child ThisType;    typedef Base BaseType;    typedef IFoo BaseType1;    typedef IBar BaseType2;        typedef ::System::BaseTypesInfo<BaseType, BaseType1, BaseType2> ThisTypeBaseTypesInfo;    RTTI_INFO_DECL();    public:    void Foo1() override;    void Bar() override;    void Foo2() override;    void Foo3() override;    template <typename T>    T Bazz(System::SharedPtr<System::Object> o)    {        assert_is_cs_class(T);                if (System::ObjectExt::Is<T>(o))        {            return System::StaticCast<typename T::Pointee_>(o);        }        else        {            return System::Default<T>();        }    }    };

Исходный код C++:

#include "Class1.h"RTTI_INFO_IMPL_HASH(788057553u, ::Base, ThisTypeBaseTypesInfo);void Base::Foo1(){}void Base::Bar(){}RTTI_INFO_IMPL_HASH(1733877629u, ::IFoo, ThisTypeBaseTypesInfo);RTTI_INFO_IMPL_HASH(1699913226u, ::IBar, ThisTypeBaseTypesInfo);RTTI_INFO_IMPL_HASH(3787596220u, ::Child, ThisTypeBaseTypesInfo);void Child::Foo1(){    Base::Foo1();}void Child::Bar(){    Base::Bar();}void Child::Foo2(){}void Child::Foo3(){}

Серия псевдонимов и макросов в начале каждого портированного класса нужна для эмуляции некоторых механизмов C# (прежде всего, GetType, typeof и is). Хэш-коды из файла .cpp используются для быстрого сравнения типов. Все функции, реализующие интерфейсы, виртуальны, хотя в C# это не так.

Члены классов

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

Экземплярные поля C# становятся экземплярными полями C++. Статические поля также остаются без изменений (кроме случаев, когда важен порядок инициализации - это исправляется портированием таких полей в виде синглтонов).

Свойства разбиваются на метод-геттер и метод-сеттер (или что-то одно, если второй метод отсутствует). Для автоматических свойств к ним добавляется также закрытое поле. Статические свойства распадаются на статические геттер и сеттер. Индексаторы обрабатываются по той же логике.

События транслируются в поля (экземплярные или статические), тип которых соответствует нужной специализации System::Event. Трансляция в виде трёх методов (add, remove и invoke) была бы более правильной и, к тому же, позволила бы поддержать абстрактные и виртуальные события. Возможно, в будущем мы придём к такой модели, однако на данный момент вариант с классом Event полностью покрывает потребности нашего кода.

Методы расширения и операторы транслируются в статические методы и вызываются явно. Финализаторы становятся деструкторами.

Следующий пример иллюстрирует описанные выше правила. Как обычно, я удалил незначащую часть кода C++.

public abstract class Generic<T>{    private T m_value;    public Generic(T value)    {        m_value = value;    }    ~Generic()    {        m_value = default(T);    }    public string Property { get; set; }    public abstract int Property2 { get; }    public T this[int index]    {        get        {            return index == 0 ? m_value : default(T);        }        set        {            if (index == 0)                m_value = value;            else                throw new ArgumentException();        }    }    public event Action<int, int> IntIntEvent;}
template<typename T>class Generic : public System::Object{public:    System::String get_Property()    {        return pr_Property;    }    void set_Property(System::String value)    {        pr_Property = value;    }        virtual int32_t get_Property2() = 0;        Generic(T value) : m_value(T())    {        m_value = value;    }        T idx_get(int32_t index)    {        return index == 0 ? m_value : System::Default<T>();    }    void idx_set(int32_t index, T value)    {        if (index == 0)        {            m_value = value;        }        else        {            throw System::ArgumentException();        }    }        System::Event<void(int32_t, int32_t)> IntIntEvent;        virtual ~Generic()    {        m_value = System::Default<T>();    }private:    T m_value;    System::String pr_Property;};

Переменные и поля

Константные и статические поля транслируются в статические поля, статические константы (в некоторых случаях - constexpr) либо в статические методы (дающие доступ к синглтону). Экземплярные поля C# преобразуются в экземплярные поля C++, при этом все сколько-нибудь сложные инициализаторы переносятся в конструкторы (иногда для этого приходится явно добавлять конструкторы по умолчанию там, где их не было в C#). Переменные на стеке переносятся как есть. Аргументы методов - тоже, за исключением того, что и ref-, и out-аргументы становятся ссылочными (благо, IL их всё равно не различает, и потому перегрузка по ним запрещена).

Типы полей и переменных заменяются их аналогами из C++. В большинстве случаев такие аналоги генерируются самим портером. Библиотечные (дотнетовские и некоторые другие) типы написаны нами на C++ в составе библиотеки, поставляемой вместе с портированными продуктами. var портируется в auto, кроме случаев, когда явное указание типа нужно, чтобы сгладить разницу в поведении.

Кроме того, ссылочные типы оборачиваются в SmartPtr (ранее яписало том, что он по большей части следует семантике intrusive_ptr, но позволяет переключать режим ссылки - слабая или сильная - во время выполнения). Значимые типы подставляются как есть. Поскольку аргументы-типы могут быть как значимыми, так и ссылочными, они также подставляются как есть, но при инстанциировании ссылочные аргументы оборачиваются в SharedPtr (таким образом,List<int>транслируется какList<int32_t>, ноList<Object>становитсяList<SmartPtr<Object>>. В некоторых исключительных случаях ссылочные типы портируются как значимые (например, наша реализация System::String написана на базе типа UnicodeString из ICU и оптимизирована для хранения на стеке).

Для примера портируем следующий класс:

public class Variables{    public int m_int;    private string m_string = new StringBuilder().Append("foobazz").ToString();    private Regex m_regex = new Regex("foo|bar");    public object Foo(int a, out int b)    {        b = a + m_int;        return m_regex.Match(m_string);    }}

После портирования он принимает следующий вид (я удалил код, не относящийся к делу):

class Variables : public System::Object{public:    int32_t m_int;    System::SharedPtr<System::Object> Foo(int32_t a, int32_t& b);    Variables();private:    System::String m_string;    System::SharedPtr<System::Text::RegularExpressions::Regex> m_regex;};System::SharedPtr<System::Object> Variables::Foo(int32_t a, int32_t& b){    b = a + m_int;    return m_regex->Match(m_string);}Variables::Variables()    : m_int(0)    , m_regex(System::MakeObject<System::Text::RegularExpressions::Regex>(u"foo|bar")){    this->m_string = System::MakeObject<System::Text::StringBuilder>()->        Append(u"foobazz")->ToString();}

Управляющие структуры

Подобие основных управляющих структур сыграло нам на руку. Такие операторы, как if, else, switch, while, do-while, for, try-catch, return, break и continue в большинстве случаев переносятся как есть. Исключением в данном списке является разве что switch, требующий пары специальных обработок. Во-первых, C# допускает его использование со строковым типом - в C++ мы в этом случае генерируем последовательность if-else if. Во-вторых, относительно недавно добавилась возможность сопоставлять проверяемое выражение шаблону типа - что, впрочем, также легко разворачивается в последовательность ifов.

Интерес представляют конструкции, которых нет в C++. Так, оператор using даёт гарантию вызова метода Dispose() при выходе из контекста - в C++ мы эмулируем это поведение, создавая объект-часового на стеке, который вызывает нужный метод в своём деструкторе. Перед этим, правда, нужно перехватить исключение, вылетевшее из кода, бывшего телом using, и сохранить exception_ptr в поле часового - если Dispose() не бросит своё исключение, будет переброшено то, которое мы сохранили. Это как раз тот редкий случай, когда вылет исключения из деструктора оправдан и не является ошибкой. Блок finally транслируется по похожей схеме, только вместо метода Dispose() вызывается лямбда-функция, в которую портер обернул его тело.

Ещё один оператор, которого нет в C# и который мы вынуждены эмулировать, - это foreach. Изначально мы портировали его в эквивалентный while(), вызывающий метод MoveNext() у перечислителя, что универсально, но довольно медленно. Поскольку в большинстве своём плюсовые реализации контейнеров из .Net используют структуры данных STL, мы пришли к тому, чтобы там, где это возможно, использовать их оригинальные итераторы, конвертируя foreach в range-based for. В тех случаях, когда оригинальные итераторы недоступны (например, контейнер реализован на чистом C#), используются итераторы-обёртки, внутри себя работающие с перечислителями. Раньше за выбор нужного способа итерации отвечала внешняя функция, написанная с использованием техники SFINAE, сейчас мы близки к тому, чтобы иметь правильные версии методов begin-end во всех контейнерах (в т. ч. портированных).

Операторы unsafe и unchecked в нашем коде практически не используются, так что их портер попросту игнорирует.

Операторы

Как и в случае с управляющими структурами, большинство операторов (по крайней мере, арифметических, логических и присваивания) не требуют особой обработки. Тут, правда, есть тонкий момент: в C# порядок вычисления частей выражения детерминирован, тогда как в C++ в некоторых случаях возникает неопределённое поведение. Например, следующий портированный код ведёт себя неодинаково после компиляции разными инструментами:

auto offset32 = block[i++] + block[i++] * 256 + block[i++] * 256 * 256 +    block[i++] * 256 * 256 * 256;

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

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

obj1.Property = obj2.Property;string s = GetObj().Property += "suffix";
obj1->set_Property(obj2->get_Property());System::String s = System::setter_add_wrap(static_cast<MyClass*>(GetObj().GetPointer()),    &MyClass::get_Property, &MyClass::set_Property, u"suffix")

В первой строке замена оказалась тривиальной. Во второй пришлось использовать обёртку setter_add_wrap, гарантирующую, что функция GetObj() будет вызвана всего один раз, а результат конкатенации вызова get_Property() и строкового литерала будет передан не только в метод set_Property() (который возвращает void), но и далее для использования в выражении. Тот же подход применяются при обращении к индексаторам.

Операторы C#, которых нет в C++ (as, is, typeof, default, ??, ?., и так далее), эмулируются при помощи библиотечных функций. В тех случаях, когда требуется избежать двойного вычисления аргументов (например, чтобы не разворачивать "GetObj()?.Invoke()" в "GetObj() ? GetObj().Invoke() : nullptr)", используется подход, подобный показанному выше.

Оператор доступа к члену (.) в зависимости от контекста может заменяться на аналог из C++: на оператор разрешения области видимости (::) или на "стрелку" (->). При доступе к членам структур такая замена не требуется.

Исключения

Эмуляция поведения C# в аспекте работы с исключениями является весьма нетривиальной. Дело в том, что в C# и в C++ исключения ведут себя по-разному:

  • В C# исключения создаются на куче и удаляются сборщиком мусора.

  • В C++ исключения в разные моменты копируются между стеком и выделенной для них областью памяти.

Здесь возникает противоречие. Если транслировать типы исключений C# как ссылочные, работая с ними по голым указателям (throw new ArgumentException), это приведёт к утечкам памяти (или большим проблемам с определением точек их удаления). Если транслировать их как ссылочные, но владеть ими по умному указателю (throw SharedPtr<ArgumentException>(MakeObject<ArgumentException>())), исключение будет невозможно перехватить по его базовому типу (потому что SharedPtr<ArgumentException> не наследует SharedPtr<Exception>). Если же размещать объекты исключений на стеке, они будут корректно перехватываться по базовому типу, но при сохранении в переменную базового типа информация о конечном типе будет усекаться (к сожалению, у нас есть даже код, хранящий коллекции исключений, так что это не пустая тревога).

Для решения этой проблемы мы создали специальный тип умных указателей ExceptionWrapper. Его ключевая особенность заключается в том, что, если класс ArgumentException наследуется от Exception, то и ExceptionWrapper<ArgumentException> наследуется от ExceptionWrapper<Exception>. Экземпляры ExceptionWrapper используются для управления временем жизни экземпляров классов исключений, при этом усечение типа ExceptionWrapper не приводит к усечению типа связанного Exception. За выброс исключений отвечает виртуальный метод, переопределяемый наследниками Exception, который создаёт ExceptionWrapper, параметризованный конечным типом исключения, и выбрасывает его. Виртуальность позволяет выбросить правильный тип исключения, даже если тип ExceptionWrapper был усечён ранее, а связь между объектом исключения и ExceptionWrapper предотвращает утечку памяти.

Создание объектов и инициализация

Для создания объектов ссылочных типов, кроме нескольких специальных случаев, мы используем функцию MakeObject (аналог std::make_shared), которая создаёт объект оператором new и сразу оборачивает его в SharedPtr. Кроме того, MakeObject инкапсулирует некую сервисную логику. Использование этой функции позволило избежать проблем, привносимых голыми указателями, однако породило проблему прав доступа: поскольку она находится вне всех классов, она не имела доступа к закрытым конструкторам, даже будучи вызванной из самих классов или их друзей. Объявление этой функции в качестве друга классов с непубличными конструкторами эффективно открывало эти конструкторы для всех контекстов. В результате внешняя версия этой функции была ограничена использованием с публичными конструкторами, а для непубличных конструкторов были добавлены статические методы MakeObject, имеющие тот же уровень доступа и те же аргументы, что и проксируемый конструктор.

Литералы часто приходится менять при портировании: так, @"C:\Users" превращается в u"C:\\Users", а 15L - в INT64_C(15).

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

Foo(new MyClass() { Property1 = "abc", Property2 = 1, Field1 = 3.14 });
Foo([&]{ auto tmp_0 = System::MakeObject<MyClass>();        tmp_0->set_Property1(u"abc");        tmp_0->set_Property2(1);        tmp_0->Field1 = 3.14;        return tmp_0;    }());

Вызовы, делегаты и анонимные методы

Вызовы методов переносятся как есть. При наличии перегруженных методов иногда приходится явно приводить типы аргументов, поскольку правила разрешения перегрузок в C++ отличаются от таковых в C#. Рассмотрим, например, следующий код:

class MyClass<T>{    public void Foo(string s) { }    public void Bar(string s) { }    public void Bar(bool b) { }    public void Call()    {        Foo("abc");        Bar("def");    }}

После портирования он выглядит следующим образом:

template<typename T>class MyClass : public System::Object{public:    void Foo(System::String s)    {        ASPOSE_UNUSED(s);    }    void Bar(System::String s)    {        ASPOSE_UNUSED(s);    }    void Bar(bool b)    {        ASPOSE_UNUSED(b);    }    void Call()    {        Foo(u"abc");        Bar(System::String(u"def"));    }};

Обратите внимание: вызовы методов Foo и Bar внутри метода Call записаны по-разному. Это связано с тем, что без явного вызова конструктора String была бы вызвана перегрузка Bar, принимающая bool, т. к. такое приведение типа имеет более высокий приоритет по правилам C++. В случае метода Foo такой неоднозначности нет, и портер генерирует более простой код.

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

class GenericMethods{    public void Foo<T>(T value) { }    public void Foo(string s) { }    public void Bar<T>(T value)    {        Foo(value);    }    public void Call()    {        Bar("abc");    }}
class GenericMethods : public System::Object{public:    template <typename T>    void Foo(T value)    {        ASPOSE_UNUSED(value);    }    void Foo(System::String s);    template <typename T>    void Bar(T value)    {        Foo<T>(value);    }    void Call();};void GenericMethods::Foo(System::String s){}void GenericMethods::Call(){    Bar<System::String>(u"abc");}

Здесь стоит обратить внимание на явное указание аргументов шаблона при вызове Foo и Bar. В первом случае это необходимо, потому что иначе при инстанциировании версии для T=System::String будет вызвана нешаблонная версия, что отличается от поведения C#. Во втором случае аргумент нужен, поскольку в противном случае он будет выведен на основе типа строкового литерала. Вообще, явно указывать аргументы шаблона портеру приходится почти всегда, чтобы избежать неожиданного поведения.

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

В .Net встречаются методы, которые поддерживают перегрузку по числу и типу аргументов через синтаксис params, через указание object в качестве типа аргумента, либо через то и другое сразу - например, подобные перегрузки есть у StringBuilder.Append() и у Console.WriteLine(). Прямой перенос таких конструкций показывает плохую производительность из-за боксирования и создания временных массивов. В таких случаях мы добавляем перегрузку, принимающую переменное число аргументов произвольных типов с использованием вариативных шаблонов, и заставляем портер транслировать аргументы как есть, без приведений типов и объединений в массивы. В результате удаётся поднять производительность таких вызовов.

Делегаты транслируются в специализации шаблона MulticastDelegate, который, как правило, содержит внутри себя контейнер экземпляров std::function. Их вызов, хранение и присваивание осуществляются тривиально. Анонимные методы превращаются в лямбда-функции.

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

Тесты

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

Программисты C# используют фреймворки NUnit и xUnit. Портер переводит соответствующие тестовые примеры на GoogleTest, заменяя синтаксис проверок и вызывая методы, помеченные флагом Test или Fact, из соответствующих тестовых функций. Поддерживаются как тесты без аргументов, так и входные данные вроде TestCase или TestCaseData. Пример портирования тестового класса приведён ниже.

[TestFixture]class MyTestCase{    [Test]    public void Test1()    {        Assert.AreEqual(2*2, 4);    }    [TestCase("123")]    [TestCase("abc")]    public void Test2(string s)    {        Assert.NotNull(s);    }}
class MyTestCase : public System::Object{public:    void Test1();    void Test2(System::String s);};namespace gtest_test{class MyTestCase : public ::testing::Test{protected:    static System::SharedPtr<::ClassLibrary1::MyTestCase> s_instance;    public:    static void SetUpTestCase()    {        s_instance = System::MakeObject<::ClassLibrary1::MyTestCase>();    };        static void TearDownTestCase()    {        s_instance = nullptr;    };    };System::SharedPtr<::ClassLibrary1::MyTestCase> MyTestCase::s_instance;} // namespace gtest_testvoid MyTestCase::Test1(){    ASSERT_EQ(2 * 2, 4);}namespace gtest_test{TEST_F(MyTestCase, Test1){    s_instance->Test1();}} // namespace gtest_testvoid MyTestCase::Test2(System::String s){    ASSERT_FALSE(System::TestTools::IsNull(s));}namespace gtest_test{using MyTestCase_Test2_Args = System::MethodArgumentTuple<decltype(    &ClassLibrary1::MyTestCase::Test2)>::type;struct MyTestCase_Test2 : public MyTestCase, public ClassLibrary1::MyTestCase,    public ::testing::WithParamInterface<MyTestCase_Test2_Args>{    static std::vector<ParamType> TestCases()    {        return        {            std::make_tuple(u"123"),            std::make_tuple(u"abc"),        };    }};TEST_P(MyTestCase_Test2, Test){    const auto& params = GetParam();    ASSERT_NO_FATAL_FAILURE(s_instance->Test2(std::get<0>(params)));}INSTANTIATE_TEST_SUITE_P(, MyTestCase_Test2,     ::testing::ValuesIn(MyTestCase_Test2::TestCases()));} // namespace gtest_test

Проблемы

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

  1. Синтаксис C# не имеет прямых аналогов на C++. Это относится, например, к операторам using и yeild.
    В таких случаях нам приходится писать довольно сложный код для эмуляции поведения оригинального кода - как в портере, так и в библиотеке - либо отказываться от поддержки таких конструкций.

  2. Конструкции C# не переводятся на C++ в рамках принятых нами правил портирования. Например, в исходном коде присутствуют виртуальные обобщённые методы, или конструкторы, использующие виртуальные функции.
    В подобных случаях нам не остаётся ничего, кроме как переписывать такой проблемный код в терминах, допускающих портирование на C#. К счастью, обычно подобные конструкции составляют относительно небольшой объём кода.

  3. Работа кода C# зависит от окружения, специфичного для .Net. Это включает, например, ресурсы, рефлексию, динамическое подключение сборок и импорт функций.
    В таких случаях нам, как правило, приходится эмулировать соответствующие механизмы. Это включает в себя поддержку ресурсов (которые внедряются в сборку в виде статических массивов и затем читаются через специализированные реализации потоков) и рефлексию. С другой стороны, очевидно, что напрямую подключать сборки .Net к коду C++ или импортировать функции из динамических библиотек Windows при выполнении на другой платформе мы не можем - подобный код приходится урезать либо переписывать.

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

  5. Работа библиотечного кода отличается от работы оригинальных классов из .Net.
    В каких-то случаях речь идёт о простых ошибках в имплементации - как правило, их несложно исправить. Гораздо хуже дело обстоит, когда разница в поведении лежит на уровне подсистем, используемых библиотечным кодом. Например, многие наши библиотеки активно используют классы из библиотеки System.Drawing, построенной на GDI+. Версии этих классов, разработанных нами для C++, используют Skia в качестве графического движка. Поведение Skia зачастую отличается от такового в GDI+, особенно под Linux, и на то, чтобы добиться одинаковой отрисовки, нам приходится тратить значительные ресурсы. Аналогично, libxml2, на которой построена наша реализация System::Xml, ведёт себя в иных случаях не так, и нам приходится патчить её или усложнять свои обёртки.

  6. Портированный код порой работает медленнее оригинала.
    Программисты на C# оптимизируют свой код под те условия, в которых он выполняется. В то же время, многие структуры начинают работать медленнее в необычном для себя окружении. Например, создание большого количества мелких объектов в C# обычно работает быстрее, чем в C++, из-за иной схемы работы кучи (даже с учётом сборки мусора). Динамическое приведение типов в C++ также несколько медленнее. Подсчёт ссылок при копировании указателей - ещё один источник накладных расходов, которых нет в C#. Наконец, использование вместо встроенных, оптимизированных концепций C++ (итераторы) переводных с C# (перечислители) также замедляет работу кода.
    Способ устранения бутылочных горлышек во многом зависит от ситуации. Если библиотечный код сравнительно легко оптимизировать, то сохранить поведение портированных концепций и в то же время оптимизировать их работу в чуждом окружении порой не так-то просто.

  7. Портированный код не соответствует духу C++. Например, в публичном API присутствуют методы, принимающие SharedPtr<Object>, у контейнеров отсутствуют итераторы, методы для работы с потоками принимают System::IO::Stream вместо istream, ostream или iostream, и так далее.
    Мы последовательно расширяем портер и библиотеку таким образом, чтобы нашим кодом было удобно пользоваться программистам C++. Например, портер уже умеет генерировать методы begin-end и перегрузки, работающие со стандартными потоками.

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

Планы развития проекта

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

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

Помимо решения текущих проблем и плановых улучшений, мы заняты переводом портеров с C# на Java и C++ на современный синтаксический анализатор (Roslyn). Это небыстрый процесс, ведь количество случаев, которые продукт должен обрабатывать, весьма велико. Мы начинаем с поддержки наиболее общих структур, а затем переходим ко всё более редким случаям. Для этого у нас есть большое количество тестов: тесты на вывод портера, тесты на вывод портированного приложения, тесты в портированных проектах. В какой-то момент происходит переход от специально подготовленных тестов к тестированию на реальных продуктах, содержащих сотни тысяч и даже десятки миллионов строк кода, что неизбежно вскрывает какие-то недоработки.

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

Наконец, мы думаем о том, чтобы замахнуться на расширение числа поддерживаемых языков - как целевых, так и исходных. Адаптировать решения, основанные на Roslyn, для чтения кода VB будет относительно легко - тем более, что библиотеки для C++ и Java уже готовы. С другой стороны, подход, который мы применили для поддержки Python, во многом проще, и по аналогии можно поддержать иные скриптовые языки - например, PHP.

Подробнее..

Новый sd-bus API от systemd

09.04.2021 00:16:01 | Автор: admin

В новом выпуске systemd v221 мы представляем API sd-bus, поставляемый со стабильной версией systemd. sd-bus - это наша минимальная библиотека D-Bus IPC на языке программирования Си, поддерживающая в качестве бэкэндов как классическую D-Bus на основе сокетов, так и kdbus. Библиотека была частью systemd в течение некоторого времени, но использовалась только внутри проекта, поскольку мы хотели свободно вносить изменения в API, не затрагивая внешних пользователей. Однако теперь, начиная с v221, мы уверены, что сделали стабильный API.

В этом посте я предоставляю обзор библиотеки sd-bus, краткое повторение основ D-Bus и его концепций, а также несколько простых примеров того, как писать клиенты и сервисы D-Bus с её помощью.

Что такое D-Bus?

Давайте начнем с быстрого напоминания, что на самом деле представляет собой D-Bus. Это мощная универсальная система IPC для Linux и других операционных систем. Он определяет такие понятия, как шины, объекты, интерфейсы, методы, сигналы, свойства. Она предоставляет вам детальный контроль доступа, богатую систему типов, лёгкое обнаружение, самодиагностику, мониторинг, надежную многоадресную рассылку, запуск служб, передачу файловых дескрипторов и многое другое. Есть привязки для многих языков программирования, которые используются в Linux.

D-Bus является основным компонентом систем Linux более 10 лет. Это, безусловно, наиболее широко распространенная локальная система IPC высокого уровня в Linux. С момента создания systemd - это была система IPC, в которой она предоставляла свои интерфейсы. И даже до systemd это была система IPC, которую Upstart использовал для своих интерфейсов. Она используется GNOME, KDE и множеством системных компонентов.

К D-Bus относится как спецификация, так и эталонной реализация. Эталонная реализация предоставляет как компонент сервера шины, так и клиентскую библиотеку. Хотя существует множество других популярных последующих реализаций клиентской библиотеки, как для Си, так и для других языков программирования, единственная широко используемая серверная сторона - это та, которая указана в эталонной реализации. (Однако проект kdbus работает над предоставлением альтернативы этой реализации сервера в качестве компонента ядра.)

D-Bus в основном используется как локальный IPC поверх сокетов AF_UNIX. Однако протокол также можно использовать поверх TCP/IP. Он изначально не поддерживает шифрование, поэтому использование D-Bus напрямую через TCP обычно не является хорошей идеей. Можно объединить D-Bus с транспортом, таким как ssh, чтобы защитить его. systemd использует это, чтобы сделать многие из своих API доступными удаленно.

Часто задаваемый вопрос о D-Bus: почему он вообще существует, учитывая, что сокеты AF_UNIX и FIFO уже есть в UNIX и долгое время успешно используются. Чтобы ответить на этот вопрос, давайте сравним D-Bus с популярными сегодня веб-технологиями: AF_UNIX/FIFO для D-Bus тоже самое, что TCP для HTTP/REST. В то время, как сокеты AF_UNIX/FIFO только перекладывают необработанные байты между процессами, D-Bus определяет фактическую кодировку сообщений и добавляет такие концепции, как транзакция вызова методов, система объектов, механизмы безопасности, многоадресная передача сообщений и многое другое.

Из нашего более чем 10-летнего опыта работы с D-Bus сегодня мы знаем, что, хотя есть некоторые области, в которых мы можем что-то улучшить (и мы работаем над этим, как с kdbus, так и с sd-bus), в целом это очень хорошо спроектированная система, которая выдержала испытание временем, выдержала хорошо и получила широкое признание. Если бы сегодня мы сели и разработали совершенно новую систему IPC, включающую весь опыт и знания, полученные с помощью D-Bus, я уверен, что результат был бы очень близок к тому, что уже есть в D-Bus.

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

Для чего подходит sd-bus?

Давайте обсудим, для чего написана библиотека sd-bus, как она соотносится с другими библиотеками D-Bus и почему она может стать библиотекой для вашего проекта.

Для языка программирования Си существуют две популярные библиотеки D-Bus: libdbus, поставляемая в эталонной реализации D-Bus, а также GDBus, компонент GLib, низкоуровневой инструментальной библиотеки GNOME.

Из этих двух библиотек libdbus намного старше, так как она была написана во время составления спецификации. Она была написана с упором на то, чтобы быть переносимой и полезной в качестве серверной части для привязок языков более высокого уровня. Обе эти цели требовали, чтобы API был очень универсальным, в результате чего получился относительно сложный в использовании API в котором отсутствуют элементы, которые делают его легким и интересным для использования из Си. Он предоставляет строительные блоки, но в нём мало инструментов, чтобы упростить строительство дома из них. С другой стороны, библиотека подходит для большинства случаев использования (например, она OOM безопасна, что делает ее подходящей для написания системного программного обеспечения самого низкого уровня) и переносима в операционные системы, такие как Windows или более экзотические UNIX.

GDBus - это гораздо более новая реализация. Она была написана после значительного опыта использования оболочки GLib/GObject вокруг libdbus. GDBus реализована с нуля, не имеет общего кода с libdbus. Её дизайн существенно отличается от libdbus, он содержит генераторы кода, чтобы упростить размещение объектов GObject на шине или взаимодействие с объектами D-Bus как с объектами GObject. Она переводит типы данных D-Bus в GVariant, который является мощным форматом сериализации данных GLib. Если вы привыкли к программированию в стиле GLib, тогда вы почувствуете себя как дома, использовать сервисы и клиенты D-Bus с её помощью намного проще, чем с libdbus.

С sd-bus мы теперь предоставляем третью реализацию, не разделяющую кода ни с libdbus, ни с GDBus. Для нас основное внимание было уделено обеспечению своего рода промежуточного звена между libdbus и GDBus: низкоуровневой библиотекой Си, с которой действительно интересно работать, которая имеет достаточно синтаксического сахара, чтобы упростить создание клиентов и сервисов, но, с другой стороны, более низкоуровневой, чем GDBus/GLib/GObject/GVariant. Чтобы использовать её в различных компонентах системного уровня systemd, она должен быть компактной и безопасной для OOM. Еще одним важным моментом, на котором мы хотели сосредоточиться, была поддержка бэкэнда kdbus с самого начала в дополнение к транспорту сокетов исходной спецификации D-Bus (dbus1). Фактически, мы хотели спроектировать библиотеку ближе к семантике kdbus, чем к dbus1, где-то были бы отличия, но при этом чтобы хорошо охватывались оба транспорта. В отличие от libdbus или GDBus, переносимость не является приоритетом для sd-bus, вместо этого мы стараемся максимально использовать платформу Linux и раскрывать конкретные концепции Linux везде, где это выгодно. Наконец, производительность также была проблемой (хотя и второстепенной): ни libdbus, ни GDBus не побьют рекорды скорости. Мы хотели улучшить производительность (пропускную способность и задержку), но для нас важнее простота и правильность. Мы считаем, что результат нашей работы вполне соответствует нашим целям: библиотеку приятно использовать, она поддерживает kdbus и сокеты в качестве серверной части, относительно минимальна, а производительность существенно выше, чем у libdbus и GDBus.

Вот краткие рекомендации, чтобы решить, какой из трех API использовать для вашего проекта на Си:

  • Если вы разрабатываете проект GLib/GObject, GDBus определенно будет вашим лучшим выбором.

  • Если для вас важна переносимость на ядра, отличные от Linux, включая Windows, Mac OS и другие UNIX, используйте либо GDBus (что более или менее означает использование GLib/GObject), либо libdbus (что требует большого количества ручной работы).

  • В противном случае я бы рекомендовал использовать sd-bus.

(Я не рассматриваю здесь C++, речь идет только о простом Си. Но обратите внимание: если вы используете Qt, то QtDBus является предпочтительным API D-Bus, являясь оболочкой для libdbus.)

Введение в концепции D-Bus

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

  • Шина - это то место, где вы ищете услуги IPC. Обычно существует два типа шин: системная шина, одна на систему, на которой располагаются системные службы; и пользовательская шина, одна на каждого пользователя, на которой располагаются пользовательские службы, такие как адресная книга или почтовый клиент. (Первоначально пользовательская шина была на самом деле сеансовой это значит, что вы получаете несколько шин, если входите в систему много раз как один и тот же пользователь, и в большинстве настроек так и остается, но мы работаем над истинной пользовательской шиной, которая существует в единственном экземпляре для каждого пользователя в системе, независимо от того, сколько раз этот пользователь входит в систему.)

  • Сервис - это программа, которая предлагает некоторый IPC API на шине. Служба идентифицируется именем в обратной нотации доменного имени. Таким образом, служба org.freedesktop.NetworkManager на системной шине - это то место, где доступны API-интерфейсы NetworkManager, а org.freedesktop.login1 на системной шине - это место, где доступны API-интерфейсы systemd-logind.

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

  • Путь к объекту - это идентификатор объекта в определенной службе. В некотором смысле это сравнимо с указателем Си, поскольку именно так вы обычно ссылаетесь на объект Си, если пишите объектно-ориентированные программы на Си. Однако указатели Си - это просто адреса памяти, и передача адресов памяти другим процессам не имеет смысла, поскольку они относятся к адресному пространству сервиса и клиент не может получить достап к ним. Таким образом, разработчики D-Bus придумали концепцию пути к объекту, который представляет собой просто строку, которая выглядит как путь в файловой системе. Пример: /org/freedesktop/login1 - это путь к объекту менеджер сервиса org.freedesktop.login1 (который, как мы помним из вышеизложенного, является сервисом systemd-logind). Поскольку пути к объектам структурированы как пути файловой системы, их можно аккуратно упорядочить в виде дерева и получить полное дерево объектов. Например, вы найдете все пользовательские сеансы, которыми управляет systemd-logind в ветке /org/freedesktop/login1/session, к примеру: /org/freedesktop/login1/session/_7, /org/freedesktop/login1/session./_55 и так далее. Как сервисы именуют свои объекты и размещают их в дереве, полностью зависит от их разработчиков.

  • Каждый объект, который определяется путем, имеет один или несколько интерфейсов. Интерфейс - это набор сигналов, методов и свойств (вместе называемых членами), которые связаны друг с другом. Концепция интерфейсов D-Bus на самом деле в значительной степени идентична тому, что вы знаете из языков программирования, таких как Java, которые её поддерживают. Какие интерфейсы реализует объект, определяют разработчики сервиса. Имена интерфейсов имеют обратную нотацию доменных имен, как и имена сервисов. (Да, это, по общему признанию, сбивает с толку, поскольку для простых сервисов довольно часто встречается использование строки имени сервиса также в качестве имени интерфейса.) Тем не менее, несколько интерфейсов стандартизированы, и вы найдете их доступными для многих объектов, реализуемых различными сервисами. В частности, это org.freedesktop.DBus.Introspectable, org.freedesktop.DBus.Peer и org.freedesktop.DBus.Properties.

  • Интерфейс может содержать методы. Слово метод более или менее просто причудливое определение для функции, и этот термин используется почти так же в объектно-ориентированных языках, таких как Java. Наиболее распространенное взаимодействие между узлами D-Bus заключается в том, что один узел вызывает метод на другом узле и получает ответ. Метод D-Bus принимает и возвращает несколько параметров. Параметры передаются безопасным для типов способом, а информация о типе включается в данные интроспекции, которые вы можете запросить у каждого объекта. Обычно имена методов (и других типов членов) следуют синтаксису CamelCase. Например, systemd-logind предоставляет метод ActivateSession в интерфейсе org.freedesktop.login1.Manager, который доступен в объекте /org/freedesktop/login1 сервиса org.freedesktop.login1.

  • Сигнатура описывает набор параметров, которые принимает или возвращает функция (или сигнал, свойство, см. ниже). Это последовательность символов, каждый из которых определяет тип соответствующего параметра. Набор доступных типов довольно мощный. Например, есть более простые типы, такие как s для строки или u для 32-битного целого числа, но также и сложные типы, такие как as для массива строк или a(sb) для массива структур, состоящих из одной строки и одного логического значения. См. Спецификацию D-Bus, где приводится полное описание системы типов. Упомянутый выше метод ActivateSession принимает одну строку в качестве параметра (сигнатура параметра, следовательно, равна s) и ничего не возвращает (сигнатура возврата, следовательно, является пустой строкой). Конечно, сигнатура может быть намного сложнее, другие примеры см. ниже.

  • Сигнал - это еще один тип элемента, определяемый в объектной системе D-Bus. Как и у метода, у него есть сигнатура. Однако они служат разным целям. В то время как в вызове метода один клиент отправляет запрос к одному сервису, и этот сервис возвращает ответ клиенту, сигналы предназначены для общего уведомления узлов. Сервисы отправляют их, когда хотят сообщить одному или нескольким узлам на шине, что что-то произошло или изменилось. В отличие от вызовов методов и их ответов, они обычно транслируются по всей шине. В то время как вызовы/ответы методов используются для дуплексной связи один-к-одному, сигналы обычно используются для симплексной связи один-ко-многим (обратите внимание, что это не является обязательным требованием, их также можно использовать один-к-одному). Пример: systemd-logind передает сигнал SessionNew от своего объекта-менеджера каждый раз, когда пользователь входит в систему, и сигнал SessionRemoved каждый раз, когда пользователь выходит из системы.

  • Свойство - это третий тип элементов, определяемый в объектной системе D-Bus. Это похоже на концепцию свойств, известную в таких языках, как C#. Свойства также имеют сигнатуру. Они представляют собой переменные, предоставляемые объектом, которые могут быть прочитаны или изменены клиентами. Пример: systemd-logind предоставляет свойство Docked с сигнатурой b (логическое значение). Оно отражает, считает ли systemd-logind, что система в настоящее время находится в док-станции (применимо только к ноутбукам).

D-Bus определяет много различных концепций. Конечно, все эти новые концепции могут быть непонятными. Давайте посмотрим на них с другой точки зрения. Я предполагаю, что многие из читателей имеют представление о сегодняшних веб-технологиях, в частности о HTTP и REST. Попробуем сравнить концепцию HTTP-запроса с концепцией вызова метода D-Bus:

  • HTTP-запрос, который вы отправляете в определенной сети. Это может быть Интернет, ваша локальная сеть или корпоративный VPN. В зависимости от того, в какой сети вы отправляете запрос, вы сможете общаться с определённым набором серверов. Это мало чем отличается от шинной концепции D-Bus.

  • Затем в сети вы выбираете конкретный HTTP-сервер для общения. Это примерно сопоставимо с выбором сервиса на конкретной шине.

  • Затем на HTTP-сервере вы запрашиваете конкретный URL-адрес. Часть URL-адреса, определяющая путь (под которой я подразумеваю все после имени хоста сервера, вплоть до последнего /) очень похожа на путь к объекту D-Bus.

  • Файловая часть URL-адреса (под которой я подразумеваю все, что находится после последней косой черты, следующее за путём, который описан выше), определяет фактический вызов, который нужно сделать. В D-Bus это может быть сопоставлено с именем интерфейса и метода.

  • Наконец, параметры HTTP-вызова следуют в пути после знака ?, Они отображаются на сигнатуру вызова D-Bus.

Конечно, сравнение HTTP-запроса с вызовом метода D-Bus похоже на сравнение яблок и апельсинов. Тем не менее, я думаю, что полезно получить представление о том, что чему соответствует.

Из оболочки

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

Некоторое время назад в systemd был включен инструмент busctl, который полезен для изучения и взаимодействия с объектной системой D-Bus. При вызове без параметров он покажет вам список всех узлов, подключенных к системной шине. (Вместо этого используйте --user, чтобы увидеть узлы вашей пользовательской шины):

$ busctlNAME                                       PID PROCESS         USER             CONNECTION    UNIT                      SESSION    DESCRIPTION:1.1                                         1 systemd         root             :1.1          -                         -          -:1.11                                      705 NetworkManager  root             :1.11         NetworkManager.service    -          -:1.14                                      744 gdm             root             :1.14         gdm.service               -          -:1.4                                       708 systemd-logind  root             :1.4          systemd-logind.service    -          -:1.7200                                  17563 busctl          lennart          :1.7200       session-1.scope           1          -[]org.freedesktop.NetworkManager             705 NetworkManager  root             :1.11         NetworkManager.service    -          -org.freedesktop.login1                     708 systemd-logind  root             :1.4          systemd-logind.service    -          -org.freedesktop.systemd1                     1 systemd         root             :1.1          -                         -          -org.gnome.DisplayManager                   744 gdm             root             :1.14         gdm.service               -          -[]

(Я немного сократил вывод, чтобы быть кратким).

Список начинается с узлов, подключенных в данный момент к шине. Они идентифицируются по именам, например ":1.11". В номенклатуре D-Bus они называются уникальными именами. По сути, каждый узел имеет уникальное имя, и они назначаются автоматически, когда узел подключается к шине. Если хотите, они очень похожи на IP-адрес. Вы заметите, что несколько узлов уже подключены, включая сам наш небольшой инструмент busctl, а также ряд системных сервисов. Затем в списке отображаются все текущие сервисы на шине, идентифицируемые по именам сервисов (как обсуждалось выше; чтобы отличить их от уникальных имен, они также называются хорошо известными именами). Во многих отношениях хорошо известные имена похожи на имена хостов DNS, то есть они являются более удобным способом ссылки на узел, но на нижнем уровне они просто сопоставляются с IP-адресом или, в этом сравнении, с уникальным именем. Подобно тому, как вы можете подключиться к хосту в Интернете либо по его имени, либо по его IP-адресу, вы также можете подключиться к узлу шины либо по его уникальному, либо по его общеизвестному имени. (Обратите внимание, что каждый узел может иметь сколько угодно хорошо известных имен, подобно тому, как IP-адрес может иметь несколько имен хостов, ссылающихся на него).

Ладно, это уже круто. Попробуйте сами, на своем локальном компьютере (все, что вам нужно, это современный дистрибутив на основе systemd).

Теперь перейдем к следующему шагу. Посмотрим, какие объекты на самом деле предлагает сервис org.freedesktop.login1:

$ busctl tree org.freedesktop.login1/org/freedesktop/login1  /org/freedesktop/login1/seat   /org/freedesktop/login1/seat/seat0   /org/freedesktop/login1/seat/self  /org/freedesktop/login1/session   /org/freedesktop/login1/session/_31   /org/freedesktop/login1/session/self  /org/freedesktop/login1/user    /org/freedesktop/login1/user/_1000    /org/freedesktop/login1/user/self

Красиво, не правда ли? Что на самом деле еще приятнее и чего не видно в выводе, так это то, что доступно полное автозавершение слов из командной строки: когда вы нажимаете TAB, оболочка автоматически заполняет имена служб за вас. Это замечательный инструмент для исследования объектов D-Bus!

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

$ busctl introspect org.freedesktop.login1 /org/freedesktop/login1/session/_31NAME                                TYPE      SIGNATURE RESULT/VALUE                             FLAGSorg.freedesktop.DBus.Introspectable interface -         -                                        -.Introspect                         method    -         s                                        -org.freedesktop.DBus.Peer           interface -         -                                        -.GetMachineId                       method    -         s                                        -.Ping                               method    -         -                                        -org.freedesktop.DBus.Properties     interface -         -                                        -.Get                                method    ss        v                                        -.GetAll                             method    s         a{sv}                                    -.Set                                method    ssv       -                                        -.PropertiesChanged                  signal    sa{sv}as  -                                        -org.freedesktop.login1.Session      interface -         -                                        -.Activate                           method    -         -                                        -.Kill                               method    si        -                                        -.Lock                               method    -         -                                        -.PauseDeviceComplete                method    uu        -                                        -.ReleaseControl                     method    -         -                                        -.ReleaseDevice                      method    uu        -                                        -.SetIdleHint                        method    b         -                                        -.TakeControl                        method    b         -                                        -.TakeDevice                         method    uu        hb                                       -.Terminate                          method    -         -                                        -.Unlock                             method    -         -                                        -.Active                             property  b         true                                     emits-change.Audit                              property  u         1                                        const.Class                              property  s         "user"                                   const.Desktop                            property  s         ""                                       const.Display                            property  s         ""                                       const.Id                                 property  s         "1"                                      const.IdleHint                           property  b         true                                     emits-change.IdleSinceHint                      property  t         1434494624206001                         emits-change.IdleSinceHintMonotonic             property  t         0                                        emits-change.Leader                             property  u         762                                      const.Name                               property  s         "lennart"                                const.Remote                             property  b         false                                    const.RemoteHost                         property  s         ""                                       const.RemoteUser                         property  s         ""                                       const.Scope                              property  s         "session-1.scope"                        const.Seat                               property  (so)      "seat0" "/org/freedesktop/login1/seat... const.Service                            property  s         "gdm-autologin"                          const.State                              property  s         "active"                                 -.TTY                                property  s         "/dev/tty1"                              const.Timestamp                          property  t         1434494630344367                         const.TimestampMonotonic                 property  t         34814579                                 const.Type                               property  s         "x11"                                    const.User                               property  (uo)      1000 "/org/freedesktop/login1/user/_1... const.VTNr                               property  u         1                                        const.Lock                               signal    -         -                                        -.PauseDevice                        signal    uus       -                                        -.ResumeDevice                       signal    uuh       -                                        -.Unlock                             signal    -         -                                        -

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

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

# busctl call org.freedesktop.login1 /org/freedesktop/login1/session/_31 org.freedesktop.login1.Session Lock

Я не думаю, что мне нужно больше об этом упоминать, но в любом случае: снова доступно полное автозавершение командной строки. Третий аргумент - это имя интерфейса, четвертый - имя метода, оба могут быть легко заполнены нажатием TAB. В этом случае мы выбрали метод Lock, который активирует блокировку экрана для определенного сеанса. И оп, в тот момент, когда я нажал Enter в этой строке, у меня включилась блокировка экрана (это работает только на оконных менеджерах, которые правильно подключаются к systemd-logind. GNOME работает нормально, и KDE тоже должен работать).

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

# busctl call org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager StartUnit ss "cups.service" "replace"o "/org/freedesktop/systemd1/job/42684"

Этот вызов принимает две строки в качестве входных параметров, как описано в сигнатуре, которая следует за именем метода (как обычно, автозавершение командной строки помогает вам понять, как ввести параметры правильно). Следующие за сигнатурой два параметра - это просто две передаваемые строки. Таким образом, сигнатура указывает, какие параметры будут дальше. Вызов метода StartUnit systemd принимает имя модуля для запуска в качестве первого параметра и режим, в котором он запускается, в качестве второго. Вызов возвращает значение пути к объекту. Он кодируется так же, как входной параметр: сигнатура (только o для пути к объекту), за которой следует фактическое значение.

Конечно, некоторые параметры вызова методов могут быть намного сложнее, но с busctl их все относительно легко закодировать. См. подробности на странице руководства.

busctl поддерживает ряд других операций. Например, вы можете использовать его для мониторинга трафика D-Bus по мере его возникновения (включая создание файла .cap для использования с Wireshark!) Или вы можете установить или получить определенные свойства. Тем не менее, этот пост должен быть о sd-bus, а не busctl, поэтому давайте кратко остановимся здесь и позвольте мне направить вас на страницу руководства на случай, если вы хотите узнать больше об этом инструменте.

busctl (как и остальная часть системы) реализован с использованием API sd-bus. Таким образом, он раскрывает многие особенности самой sd-bus. Например, вы можете использовать его для подключения к удаленным или контейнерным шинам. Он поддерживает как kdbus, так и классический D-Bus, и многое другое!

sd-bus

Но хватит! Вернемся к теме, поговорим о самой sd-bus.

Набор API sd-bus в основном содержится в заголовочном файле sd-bus.h.

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

  • Поддерживает как kdbus, так и dbus1 в качестве серверной части.

  • Имеет высокоуровневую поддержку подключения к удаленным шинам по ssh и шинам локальных контейнеров ОС.

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

  • Поддержка отслеживания жизненного цикла узлов для автоматического освобождения локальных объектов, когда все узлы, ссылающиеся на них, отключены.

  • Клиент строит эффективное дерево решений, чтобы определить, каким обработчикам доставить входящее сообщение шины.

  • Автоматически переводит ошибки D-Bus в ошибки стиля UNIX и обратно (хотя с потерями), чтобы обеспечить лучшую интеграцию D-Bus в низкоуровневые программы Linux.

  • Мощная, но легкая объектная модель для отображения локальных объектов на шине. При необходимости автоматически генерирует самоанализ.

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

Вызов метода из Си с помощью sd-bus

Так много о библиотеке в целом. Вот пример подключения к шине и выполнения вызова метода:

#include <stdio.h>#include <stdlib.h>#include <systemd/sd-bus.h>int main(int argc, char *argv[]) {  sd_bus_error error = SD_BUS_ERROR_NULL;  sd_bus_message *m = NULL;  sd_bus *bus = NULL;  const char *path;  int r;  /* Connect to the system bus */  r = sd_bus_open_system(&bus);  if (r < 0) {    fprintf(stderr, "Failed to connect to system bus: %s\n", strerror(-r));    goto finish;  }  /* Issue the method call and store the respons message in m */  r = sd_bus_call_method(bus,                         "org.freedesktop.systemd1",           /* service to contact */                         "/org/freedesktop/systemd1",          /* object path */                         "org.freedesktop.systemd1.Manager",   /* interface name */                         "StartUnit",                          /* method name */                         &error,                               /* object to return error in */                         &m,                                   /* return message on success */                         "ss",                                 /* input signature */                         "cups.service",                       /* first argument */                         "replace");                           /* second argument */  if (r < 0) {    fprintf(stderr, "Failed to issue method call: %s\n", error.message);    goto finish;  }  /* Parse the response message */  r = sd_bus_message_read(m, "o", &path);  if (r < 0) {    fprintf(stderr, "Failed to parse response message: %s\n", strerror(-r));    goto finish;  }  printf("Queued service job as %s.\n", path);finish:  sd_bus_error_free(&error);  sd_bus_message_unref(m);  sd_bus_unref(bus);  return r < 0 ? EXIT_FAILURE : EXIT_SUCCESS;}

Сохраните этот пример как bus-client.c, а затем соберите его с помощью:

$ gcc bus-client.c -o bus-client `pkg-config --cflags --libs libsystemd`

Будет сгенерирован исполняемый файл bus-client, который вы теперь можете запустить. Обязательно запускайте его как root, поскольку доступ к методу StartUnit является привилегированным:

# ./bus-clientQueued service job as /org/freedesktop/systemd1/job/3586.

И это уже наш первый пример. Он показал, как мы вызывали метод на шине. Фактически вызов метода очень близок к инструменту командной строки busctl, который мы использовали ранее. Я надеюсь, что отрывок из кода не требует дополнительных пояснений. Он должен дать вам представление о том, как писать клиентов D-Bus с помощью sd-bus. Для получения дополнительной информации, пожалуйста, просмотрите заголовочный файл, страницу руководства или даже исходники sd-bus.

Реализация сервиса на Си с помощью sd-bus

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

#include <stdio.h>#include <stdlib.h>#include <errno.h>#include <systemd/sd-bus.h>static int method_multiply(sd_bus_message *m, void *userdata, sd_bus_error *ret_error) {  int64_t x, y;  int r;  /* Read the parameters */  r = sd_bus_message_read(m, "xx", &x, &y);  if (r < 0) {    fprintf(stderr, "Failed to parse parameters: %s\n", strerror(-r));    return r;  }  /* Reply with the response */  return sd_bus_reply_method_return(m, "x", x * y);}static int method_divide(sd_bus_message *m, void *userdata, sd_bus_error *ret_error) {  int64_t x, y;  int r;  /* Read the parameters */  r = sd_bus_message_read(m, "xx", &x, &y);  if (r < 0) {    fprintf(stderr, "Failed to parse parameters: %s\n", strerror(-r));    return r;  }  /* Return an error on division by zero */  if (y == 0) {    sd_bus_error_set_const(ret_error, "net.poettering.DivisionByZero", "Sorry, can't allow division by zero.");    return -EINVAL;  }  return sd_bus_reply_method_return(m, "x", x / y);}/* The vtable of our little object, implements the net.poettering.Calculator interface */static const sd_bus_vtable calculator_vtable[] = {  SD_BUS_VTABLE_START(0),  SD_BUS_METHOD("Multiply", "xx", "x", method_multiply, SD_BUS_VTABLE_UNPRIVILEGED),  SD_BUS_METHOD("Divide",   "xx", "x", method_divide,   SD_BUS_VTABLE_UNPRIVILEGED),  SD_BUS_VTABLE_END};int main(int argc, char *argv[]) {  sd_bus_slot *slot = NULL;  sd_bus *bus = NULL;  int r;  /* Connect to the user bus this time */  r = sd_bus_open_user(&bus);  if (r < 0) {    fprintf(stderr, "Failed to connect to system bus: %s\n", strerror(-r));    goto finish;  }  /* Install the object */  r = sd_bus_add_object_vtable(bus,                               &slot,                               "/net/poettering/Calculator",  /* object path */                               "net.poettering.Calculator",   /* interface name */                               calculator_vtable,                               NULL);  if (r < 0) {    fprintf(stderr, "Failed to issue method call: %s\n", strerror(-r));    goto finish;  }  /* Take a well-known service name so that clients can find us */  r = sd_bus_request_name(bus, "net.poettering.Calculator", 0);  if (r < 0) {    fprintf(stderr, "Failed to acquire service name: %s\n", strerror(-r));    goto finish;  }  for (;;) {    /* Process requests */    r = sd_bus_process(bus, NULL);    if (r < 0) {      fprintf(stderr, "Failed to process bus: %s\n", strerror(-r));      goto finish;    }    if (r > 0) /* we processed a request, try to process another one, right-away */      continue;    /* Wait for the next request to process */    r = sd_bus_wait(bus, (uint64_t) -1);    if (r < 0) {      fprintf(stderr, "Failed to wait on bus: %s\n", strerror(-r));      goto finish;    }  }finish:  sd_bus_slot_unref(slot);  sd_bus_unref(bus);  return r < 0 ? EXIT_FAILURE : EXIT_SUCCESS;}

Сохраните этот пример как bus-service.c, а затем соберите его с помощью:

$ gcc bus-service.c -o bus-service `pkg-config --cflags --libs libsystemd`

А теперь запустим:

$ ./bus-service

В другом терминале попробуем с ним поговорить. Обратите внимание, что эта служба теперь находится на пользовательской шине, а не на системной шине, как раньше. Мы делаем это для простоты: доступ к сервисам на системной шине строго контролируется, поэтому непривилегированные клиенты не могут запрашивать привилегированные операции. Однако на пользовательской шине все проще: поскольку только процессы пользователя, владеющего шиной, могут подключиться, дальнейшее применение политики не усложнит этот пример. Поскольку служба находится на пользовательской шине, мы должны передать параметр --user в командной строке busctl. Начнем с просмотра дерева объектов сервиса.

$ busctl --user tree net.poettering.Calculator/net/poettering/Calculator

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

$ busctl --user introspect net.poettering.Calculator /net/poettering/CalculatorNAME                                TYPE      SIGNATURE RESULT/VALUE FLAGSnet.poettering.Calculator           interface -         -            -.Divide                             method    xx        x            -.Multiply                           method    xx        x            -org.freedesktop.DBus.Introspectable interface -         -            -.Introspect                         method    -         s            -org.freedesktop.DBus.Peer           interface -         -            -.GetMachineId                       method    -         s            -.Ping                               method    -         -            -org.freedesktop.DBus.Properties     interface -         -            -.Get                                method    ss        v            -.GetAll                             method    s         a{sv}        -.Set                                method    ssv       -            -.PropertiesChanged                  signal    sa{sv}as  -            -

Как упоминалось выше, библиотека sd-bus автоматически добавила пару универсальных интерфейсов. Но первый интерфейс, который мы видим, на самом деле тот, который мы добавили! Он показывает наши два метода, и оба принимают xx (два 64-битных целых числа со знаком) в качестве входных параметров и возвращают один x. Отлично! Но правильно ли это работает?

$ busctl --user call net.poettering.Calculator /net/poettering/Calculator net.poettering.Calculator Multiply xx 5 7x 35

Вау! Мы передали два целых числа 5 и 7, и служба фактически умножила их для нас и вернула одно целое число 35! Попробуем другой метод:

$ busctl --user call net.poettering.Calculator /net/poettering/Calculator net.poettering.Calculator Divide xx 99 17x 5

Ух ты! Он может даже делать целочисленное деление! Фантастика! Но давайте обманем его делением на ноль:

$ busctl --user call net.poettering.Calculator /net/poettering/Calculator net.poettering.Calculator Divide xx 43 0Sorry, can't allow division by zero.

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

И это действительно всё, что у меня есть на сегодня. Конечно, примеры, которые я показал, короткие, и я не буду вдаваться в подробности того, что именно делает каждая строка. Однако этот пост должен быть кратким введением в D-Bus и sd-bus, и это уже слишком много для него...

Надеюсь, этот пост был вам полезен. Если вы заинтересованы в использовании sd-bus для своих собственных программ, я надеюсь, он поможет вам. Если у вас есть дополнительные вопросы, посмотрите (неполные) страницы руководства и спросите нас в IRC или в списке рассылки systemd. Если вам нужно больше примеров, взгляните на дерево исходных текстов systemd, все многочисленные шинные сервисы systemd широко используют sd-bus.

Подробнее..

Делаем откаты БД в msi. История про создание резервных копий и удаление БД в WixSharp

31.03.2021 14:23:38 | Автор: admin

При работе с базами данных (БД) в установщике, про который мы уже писали в прошлой статье (Пишем установщик на WixSharp. Плюшки, проблемы, возможности), в первую очередь, были реализованы проверка доступности СУБД по логину/паролю, добавление и обновление собственно БД (в нашем приложении их несколько) накатыванием миграций, a также добавление пользователей. Все это реализовано для двух СУБД Microsoft SqlServer и PostgreSql.
На первый взгляд этого достаточно, но иногда есть необходимость удалять БД и пользователей, а это влечет за собой создание резервных копий. Сразу выявили две необходимые задачи:

1.Удаление БД и пользователей при откате приложения в случае ошибки при первичной установке. При установке приложения, если возникает ошибка, происходил откат всех настроек, кроме БД. Добавленные БД и пользователи оставались. И, если при боевой эксплуатации, после серии тестирования эта ситуация непредвиденной ошибки маловероятна, то в процессе разработки и доработки установщика, ошибки возникают часто. Их, однозначно, нужно удалять.

2.Создание резервных копий (бэкапов) и удаление БД и пользователей при полном удалении приложения установщиком. Правильно ли оставлять БД после полного удаления приложения? Мы решили, что нет. Но бэкапы, конечно, сохранять нужно.

Из второго пункта возникла новая задача:

3.Создание бэкапов БД при обновлении приложения. Если мы сохраняем бэкапы при удалении, неплохо создавать их и перед обновлением, накатыванием миграций и прочими изменениями. Подстраховка еще никому не мешала. :)

Удаление БД и пользователей при откате приложения в случае ошибки при первичной установке

Если что-то пошло не так и при установке возникли ошибки, мы сразу же позаботились об удалении добавленных директорий и настроек, а также об очистке реестра. Но БД и пользователей также нужно удалять. В WixSharp для этого предусмотрен механизм роллбэка для CustomActions. Для существующего пользовательского действия нужно добавить еще один параметр - название пользовательского действия откатывающего изменения. Необходимо учесть, что данный механизм доступен только для deferred action (отложенных действий).

new ManagedAction(AddDatabaseAction, Return.check, When.After, Step.PreviousAction, Condition.NOT_Installed, DeleteAddedDatabasesAction)             {    UsesProperties = $@"{DATABASE_PROPERTIES}={database_properties}",    Execute = Execute.deferred,   ProgressText = $"Выполняется создание БД {databaseName}"              };

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

Создание бэкапов и удаление БД при полном удалении приложения установщиком

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

Пользовательское действие для создания бэкапа желательно выполнять до того, как начнут вноситься изменения установщиком, для этого предусмотрен тип immediate. В отличие от deferred, он выполняется сразу. Чтобы данное действие выполнялось только при удалении приложения, укажем условие Condition.BeingUninstalled:

new ManagedAction(BackupDatabaseAction, Return.check, When.After, Step.PreviousAction, Condition.BeingUninstalled){   Execute = Execute.immediate,   UsesProperties = DeleteAddedDatabases,   ProgressText = $"Выполняется скрипт по созданию резервных копий БД" }

Бэкапы решено было сохранять по пути, доступному текущему пользователю. Так как у нас несколько БД, группировку проводили по версии приложения. Название БД формировалось классически, с указанием имени и даты-времени создания.
\Users\{CurrentUser}\AppData\Local\{ApplicationName}\Backups\{VersionNumber}

Создаем этот путь:

Version installedVersion = session.LookupInstalledVersion();  string localUserPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); string backupPath = Path.Combine(localUserPath, "ApplicationName", "Backups", installedVersion.ToString());  Directory.CreateDirectory(backupPath);

И если для Microsoft SqlServer создание бэкапов заключалось в выполнении банального sql-скрипта:

$" USE master" +                    $" BACKUP DATABASE [{databaseName}]" +                    $" TO DISK = N'{path}'" +                    $" WITH NOFORMAT, NOINIT, " +                    $" NAME = N'{backupName}', SKIP, NOREWIND, NOUNLOAD,  STATS = 10 ";

То для PostgreSql одним скриптом не обойтись. Бэкап можно создать запуском команды через командную строку. Понадобится выполнение следующих действий:

  • Запускать pg_dump.exe из соответствующей папки PostgreSql
    C:\Program Files\PostgreSQL\{Version}\bin
    Мы не знаем какая версия установлена у заказчика (обычно в документации мы указываем версию, не ниже которой требуется), какой путь был выбран. Поэтому основной путь с указанием версии получим из реестра:

const string KEY_MASK = @"SOFTWARE\PostgreSQL\Installations\";var currentVersion = Registry.LocalMachine.OpenSubKey(KEY_MASK)?.GetSubKeyNames()[0];if (currentVersion == null){  return ActionResult.Failure;}var keyName = $@"HKEY_LOCAL_MACHINE\{KEY_MASK}{currentVersion}";var postgresPath = (string)Registry.GetValue(keyName, "Base Directory", string.Empty);
  • Проверять, добавлены ли переменные среды для PostgreSql. И в случае необходимости добавить.
    C:\Program Files\PostgreSQL\12\bin
    C:\Program Files\PostgreSQL\12\lib

    Если они отсутствуют, запуск pg_dump будет невозможен.

string binEnv = $@"{postgresPath}\bin";string path = "PATH";var scope = EnvironmentVariableTarget.User;var currentEnvironmentVariable = Environment.GetEnvironmentVariable(name, scope);if (!currentEnvironmentVariable.ToUpper().Contains(binEnv.ToUpper())){  var newEnvironmentVariable = $@"{currentEnvironmentVariable};{binEnv}";  Environment.SetEnvironmentVariable(name, newEnvironmentVariable, scope);}
  • Сформировать аргументы создания бэкапа с помощью командной строки. Здесь необходимо указать параметры подключения, имя БД и путь сохранения бэкапа. Так как ранее нам не приходилось создавать бэкапы для PostgreSql, несложный поиск в интернете показывал примерно такое решение:

    pg_dump -h {host} -p {port} -U {username} {database_name} > {backuppath}

    Если в конфиг файле pg_hba не указано для local connections безусловное подключение trust, то будет требоваться введение пароля. В данном случае, требуется добавление файла .pgpass для текущего пользователя. Тогда, можно добавить в команду атрибут -w и пароль будет считываться оттуда. Так как вновь возникает ситуация, когда мы не знаем, как это организовано у заказчика, была найдена универсальная запись, с помощью которой можно передать все параметры в рамках одной команды:

    pg_dump --dbname=postgresql://{username}:{password}@{address}:{port}
    /{databaseName}-f {backupPath}

После того, как бэкапы созданы, можно удалить БД и пользователей. Здесь будет использоваться то же пользовательское действие DeleteAddedDatabasesAction, что и для отката из пункта 1. Оно будет отложенным и будет запускаться при условии деинсталляции Condition.BeingUninstalled:

new ManagedAction(DeleteAddedDatabasesAction, Return.check, When.After, Step.PreviousAction, Condition.BeingUninstalled){  Execute = Execute.deferred,  UsesProperties = $@"{DATABASE_PROPERTIES}={database_properties}",  ProgressText = $"Выполняется удаление БД {databaseName} и роли {role}" };

Операции с БД при обновлении приложения

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

Вывод

Для PostgreSql и Microsoft SqlServer в нашем установщике удалось наладить:

  • механизм удаления БД и пользователей;

  • создание резервных копий в случае полного удаления;

  • создание резервных копий в случае обновления приложения;

  • реализацию отката добавленных БД в случае неудачной первичной установки, либо ее отмене.

    Продолжаем пилить msi ;)

Подробнее..

Tantramantra и магия проектирования

03.04.2021 22:15:26 | Автор: admin
Доброго весеннего дня!
Во время разработки различных механик и прочего интерактива для компьютерных игр, складываются различные схемы-рецепты для реализации требуемого функционала. Большая их часть не привязана к конкретному используемому движку/языку. О некоторых из них я расскажу на примере одного из своих проектов с биомашинками.



Тантрамантра



Для начала о самом проекте. Это тестовый полигон из нескольких карт-миров, где можно покататься на машинке-биотрансформере, которая умеет прыгать, кувыркаться, стрейфиться и создавать некоторые объекты. Также подконтрольное транспортное средство умеет переключаться в различные формы, в том числе и те, которые заточены под полёт (не имеют колёс).
На картах присутствуют телепорты (синие звездообразные объекты), переносящие машинку в другие миры. Всего таких миров четыре стартовый, с водой и скалами, с ровной поверхностью и ещё один водный.
В стартовом мире игрока довольно быстро начинают преследовать два терминатора, одолеть которых можно при помощи специального оружия (после чего кататься уже в более спокойной обстановке). Если же они окажутся ловчее, то происходит game over и игра начинается заново.







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



Обычно пользователь, включая любую игру, даже особо не думает, как там всё устроено внутри (даже если играет разработчик, надо ещё настроиться на определённый лад, чтобы воспринимать происходящее более аналитически). Интерактив просто происходит, всё случается само собой. Одним, словом какая-то магия, хотя вроде и относительно привычная. Сюжеты, герои, нарратив это уже всё наносное (хотя и немаловажное с другой точки зрения), так как игрок в состоянии гонять даже абстрактные кубики по экрану, просто потому, что клац, клац, поехали его уже зацепило и понесло. Игра при этом не обязана быть спиномозговой перекладывание карт, щёлкание разными включателями/переключателями и прочая размеренная деятельность точно также цепляют пользователя своей обратной реакцией, потому как она просто есть.

Вопросы вида почему же Марио разбивает головой кирпичи это совершенно не то, что волнует играющего в Supermario. Он планирует в какую трубу прыгнуть, как разбить блок и успеть схватить гриб, чем бы убить назойливое облачко. По сути это решение различных задач, представленных в виде символов и внутриигровых взаимосвязей. Символы, конечно, запускают ассоциации, поэтому попутно можно обсудить с окружающими кирпичи, водопроводчиков, трубы с мухоловками и прочий происходящий психодел (которого на самом то деле и не происходит, просто наверное это более отвлечённая и интересная тема, чем обсуждать какие-то скучные бытовые вещи).

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

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

В задачи игрового проектирования как раз и входит подбор определённой модели, которая позволит создать нужный интерактив. Пути решения этих задач могут быть довольно разными, но довольно часто можно (и нужно) искать ту модель, которая просто сформирует нужную иллюзию, без лишних переусложнений. То есть вам не нужно подключать плагин вывода текста, если требуется показать всего пару надписей, и достаточно вывести картинку с ними. Не требуется подключать физическую библиотеку, если вся физика в вашей игре это упрощённая гравитация через смещение объекта вниз в каждом кадре. И так далее, и тому подобное.

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

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

Грибы

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

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


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


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


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

Терминаторы

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

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


А вот как эти враги устроены пара сфер и пустышка-прицел, в которую спавнятся выстрелы


Весь код приводить не буду, ограничимся разбором параметров. Пара счётчиков для отслеживания пауз между выстрелами и рейкастами. Хитпоинты. Флаги состояний, для определения, когда нужно двигаться прямо (потому что рейкаст не касается земли) и когда нужно на некоторое время переключиться в состояние взлёта над землёй. В инициализации рассчитывается случайная прибавка к скорости, чтоб разные враги двигались с немного отличающимися скоростями и реже наползали друг на друга.


Оружие

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

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


Точка подбора оружия тоже использует WorldTrigger для определения столкновения


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

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


NodePet объект, изображающий пушку


Код NodePet. В качестве точки привязки, за которой он будет следовать, указана пустышка-прицел, висящая на машинке. А Rotator это другая пустышка, уже в центре самой пушки, которая копирует на себя поворот того прицела, чтобы пушка смотрела в нужную сторону (в качестве бонуса это даёт эффект вращения пушки вокруг своей оси, когда машинка двигается).
Здесь как раз реализован принцип отслеживания координат пушка начинает смещаться, если машинка удалилась от неё на определённое малое расстояние. Поначалу отслеживалось отклонение только по осям X и Y, а потом я дописал и Z для большей точности.




Выстрелы

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

Работают эти выстрелы немного по-разному.


Фрагмент скрипта вражеских выстрелов. Они появляются и начинают лететь в том направлении, куда смотрел терминатор (то есть на игрока). Производить дополнительные действия выстрел начинает не сразу, а после небольшой задержки (reactionTimer) простой способ избежать каких-то нежелательных столкновений в точке его появления.
После задержки выстрел начинает анализировать пройденные отрезки на предмет пересечений, и если там что-то нашлось, то уничтожается, проигрывая эффект.
Если у этого чего-то имелось RigidBody, то выстрел применит туда импульс.
А если это ещё и машинка, то выстрел находит на ней скрипт и вызывает там функцию потери хитпоинтов.
Так как вражеские выстрелы отслеживают пересечения с большей частью геометрии, то они часто могут попасть в какую-то визуальную часть машинки, не достигнув её ядра. Таким образом получается, что часть выстрелов словно не пробивают броню.


Демоверсия



Ниже видеонарезка игрового процесса из свежей версии прототипа 01_02



Архив с демкой весит 714Мб. Запускается она на 64-битной Windows и доступна для скачивания на страничке itch.io (используется Unigine engine, поэтому системные требования не самые малые):

https://thenonsense.itch.io/tantramantra

Подробнее..

Страсти по Serilog .NET Core Глобальный логгер

04.04.2021 12:12:21 | Автор: admin

Serilog на данный момент, пожалуй, самая популярная библиотека логирования для .NET. Зародилась эта библиотека ещё до появления платформы .NET Core, в которой разработчики платформы предложили своё видение подсистемы логирования приложения. В 2017 году Serilog создаёт библиотеку для интеграции в подсистему логирования .NET Core.

В этой серии статей мы пристально рассмотрим и проанализируем проблемы использования Serilog в .NET Core и постараемся ответить на вопрос как их решить?

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

Введение

В феврале 2013 года на github.com появился проект Opi, который уже через 6 дней получил знакомое имя Serilog. Этот проект изначально разрабатывался под .NET Framework 4.5. На момент его разработки, платформа .NET не предлагала из коробки никакого встроенного API логирования. На тот момент самыми популярными инструментами для решения этой задачи были NLog и log4net.

Статистика популярности log4net и NLog 2012-2014 гг.Статистика популярности log4net и NLog 2012-2014 гг.

График на google trends.

Ещё до официального выхода первой версии .NET Core (27.06.2016) уже шли разговоры о поддержке этой платформы (как, например, тут). Сейчас уже сложно разобраться в подробностях тех времён и как начиналась поддержка .Net Core. Ясность наступает в августе 2017, когда на github.com был создан проект serilog-aspnetcore. Он изначально был разработан под .NET Standard 2.0, т.е. уже поддерживал использование в проектах .NET Core 2.0.

Но это была не переработка под .NET Core, а интеграция инструментов основной библиотеки Serilog в подсистему логирования .NET Core. Эта интеграция не подразумевала пересмотра подсистемы логирования Serilog через призму подходов, которые предлагает платформа .NET Core, как для расширения API логирования, так и для взаимодействия компонент приложения.

Предисловие

При написании статьи эксперименты проводились на платформе .NET Core 3.1. Модульные тесты написаны под xUnit. Для анализа использовались исходники актуальных на момент написания статьи версий библиотек Serilog:

Интеграция Serilog + .NET Core

В 100% случаев, когда Я сталкивался с применением Serilog, это был код одного из его примеров.

Код примера
public static int Main(string[] args){    Log.Logger = new LoggerConfiguration()        .WriteTo.Console()        .CreateBootstrapLogger();    Log.Information("Starting up!");    try    {        CreateHostBuilder(args).Build().Run();        Log.Information("Stopped cleanly");        return 0;    }    catch (Exception ex)    {        Log.Fatal(ex, "An unhandled exception occured during bootstrapping");        return 1;    }    finally    {        Log.CloseAndFlush();    }}public static IHostBuilder CreateHostBuilder(string[] args) =>    Host.CreateDefaultBuilder(args)    .UseSerilog((context, services, configuration) => configuration                .ReadFrom.Configuration(context.Configuration)                .ReadFrom.Services(services)                .Enrich.FromLogContext()                .WriteTo.Console())    .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });

В этом примере мы видим, как разработчики Serilog предлагают его использовать:

  • метод Main:

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

    • сообщаем о запуске приложения через глобальный логгер

    • запускаем хост приложения;

    • сообщаем об успешном останове приложения через глобальный логгер;

    • при необходимости логируем ошибку запуска приложения через глобальный логгер;

    • закрываем и флашим глобальный логгер;

  • метод CreateHostBuilder:

    • интегрируем и конфигурируем Serilog, в том числе с помощью конфигурации приложения.

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

Далее в сервисах приложения, в контроллерах, если это вэб приложение, которые создаются с применением DI, можно получить логгер из подсистемы логирования .NET Core, записать туда лог и он попадёт в логгер Serilog и будет записан в соответствии с конфигурацией, определённой в методе CreateHostBuilder.

Глобальный логгер Как это работает?

Как написано в примере Program.cs перед инициализацией статического логгера:

The initial bootstrap logger is able to log errors during start-up. It's completely replaced by the logger configured in UseSerilog() below, once configuration and dependency-injection have both been set up successfully.

Т.е. инициализируется глобальный логгер, который действует до успешного вызова UseSerilog() при создании HostBuilder приложения. После чего он будет заменён на логгер, собранный в соответствии с конфигурацией приложения.

Глобальный логгер в Serilog - свойство Logger статического класса Log с get и set. Имеет значение по умолчанию объект типа SilentLogger, который ничего никуда не пишет:

public static class Log{    static ILogger _logger = SilentLogger.Instance;    /// <summary>    /// The globally-shared logger.    /// </summary>    /// <exception cref="ArgumentNullException">When <paramref name="value"/> is <code>null</code></exception>    public static ILogger Logger    {        get => _logger;        set => _logger = value ?? throw new ArgumentNullException(nameof(value));    }    ...}

Теперь исследуем UseSerilog и где назначается глобальный логгер.

Проходим в UseSerilog это где-то там назначается глобальный логгер. Что делает этот метод:

  • определяет, можно ли переконфигурировать текущий глобальный логгер. Нужно переконфигурировать (перезагрузить в терминах кода), если верны все условия:

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

    • входной параметр preserveStaticLogger (не трогать статический (читай глобальный) логгер) == false;

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

  • далее, в зависимости от необходимости сохранить глобальный логгер (вх параметр preserveStaticLogger), для интеграции в подсистему логирования .NET Core используется полученный на предыдущем этапе логгер, если глобальный логгер надо сохранить. И в противном случае заменяется глобальный логгер на полученный на предыдущем этапе, а для интеграции вместо логгера передаётся null, что потом приведёт к тому, что вместо конкретного переданного для интеграции логгера будет использоваться глобальный логгер.

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

  • в фабрику логгеров Serilog передаётся неопределённый логгер (т.е. null). Эта фабрика интегрируется во встроенную в .NET Core подсистему логирования, как фабрика логгеров;

  • в фабрике создаётся поставщик логгеров Serilog и туда передаётся логгер тоже null. Этот поставщик используется фабрикой, чтобы предоставить объект логгера, реализующий платформенный интерфейс Microsoft.Extensions.Logging.ILogger;

  • поставщик логгеров создаёт и предоставляет платформенный логгер Serilog для интеграции во встроенную систему логирования .NET Core. В этот объект передаётся логгер Serilog , т.е. опять null;

  • в платформенном логгере Serilog в конструкторе происходит выбор используемого логгера Serilog : если в качестве логгера передан null, то в дальнейшем в качестве логгера Serilog будет использоваться глобальный статический логгер из Log.Logger. И уже этот объект будет использоваться для логирования непосредственно в методе логирования этого платформенного логгера Serilog : тут и тут.

Эффект нескольких логгеров

Разработчики Serilog предлагают смешанный подход к использованию Serilog в .NET Core приложениях:

  • через стандартную встроенную .NET Core подсистему логирования;

  • с использованием глобального логгера через свойство Logger публичного статического класса Serilog.Log.

При этом встраивание Serilog в .NET Core предусматривает принципиально разные комбинации конфигурирования Serilog в зависимости от способа доступа к нему:

  • чтобы логи через глобальный логгер писались так же, как через интегрированный в .NET Core (когда используется один и тот же объект preserveStaticLogger==false) как в конфигурации приложения

  • чтобы логи через глобальный логгер писались как инициировано в самом начале (в примере в консоль), а интегрированный в .NET Core как в конфигурации приложения (preserveStaticLogger==true)

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

Но всё не так плохо. Параметр preserveStaticLoggerпо умолчанию false. А это значит, если ничего специально не делать, логгер при обоих способах логирования будет один и будет иметь одну и ту же конфигурацию.

Проблемы с тестированием

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

Проверим это! Для этого понадобится вспомогательные классы.

Тестовый логгер

Тестовый логгер, который пишет в тестовый output лог-сообщение и добавляет в него префикс:

class TestLogger : Serilog.ILogger{    private readonly string _prefix;    private readonly ITestOutputHelper _output;    public TestLogger(string prefix, ITestOutputHelper output)    {    _prefix = prefix;    _output = output;    }    public void Write(LogEvent logEvent)    {    _output.WriteLine(_prefix + " " +  logEvent.MessageTemplate.Render(logEvent.Properties));    }}

Отправитель запросов

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

class ConcurrentLoggingTestRequestSender{    private readonly WebApplicationFactory<Startup> _webAppFactory;    private readonly ITestOutputHelper _output;    private readonly string _logPrefix;    public ConcurrentLoggingTestRequestSender(WebApplicationFactory<Startup> webAppFactory, ITestOutputHelper output, string logPrefix)    {        _webAppFactory = webAppFactory;        _output = output;        _logPrefix = logPrefix;    }    public async Task<HttpResponseMessage> Send()    {        var client = _webAppFactory.WithWebHostBuilder(b => b.UseSerilog(        (context, config) => config        .WriteTo.Logger(new TestLogger(_logPrefix, _output))        )).CreateClient();        return await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/ping"));    }}

В методе Send инициируется приложение с добавлением Serilog и логированием в тестовый логгер.

Тесты

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

Тест1:

public class ConcurrentLoggingTest_1of2 : IClassFixture<WebApplicationFactory<Startup>>{    private readonly ConcurrentLoggingTestRequestSender _requestSender;    private readonly ITestOutputHelper _output;    public ConcurrentLoggingTest_1of2(WebApplicationFactory<Startup> waf, ITestOutputHelper output)    {        _output = output;    _requestSender = new ConcurrentLoggingTestRequestSender(waf, output, "==1==");    }    [Fact]    public async Task Test()    {        _output.WriteLine("Test 1 of 2");        _output.WriteLine("");        var resp = await _requestSender.Send();        Assert.True(resp.IsSuccessStatusCode);    }}

Тест2:

public class ConcurrentLoggingTest_2of2 : IClassFixture<WebApplicationFactory<Startup>>{    private readonly ConcurrentLoggingTestRequestSender _requestSender;    private readonly ITestOutputHelper _output;    public ConcurrentLoggingTest_2of2(WebApplicationFactory<Startup> waf, ITestOutputHelper output)    {        _output = output;    _requestSender = new ConcurrentLoggingTestRequestSender(waf, output, ">>2<<");    }    [Fact]    public async Task Test()    {        _output.WriteLine("Test 2 of 2");        _output.WriteLine("");        var resp = await _requestSender.Send();        Assert.True(resp.IsSuccessStatusCode);    }}

Результаты тестирования

Запуск тестов по отдельности
Test 1 of 2==1== Application started. Press Ctrl+C to shut down.==1== Hosting environment: "Development"==1== Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke"==1== Request starting HTTP/1.1 GET http://localhost/ping  ==1== Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").==1== Executing ObjectResult, writing value of type '"System.String"'.==1== Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 13.1068ms==1== Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Request finished in 76.8507ms 200 text/plain; charset=utf-8Test 2 of 2>>2<< Application started. Press Ctrl+C to shut down.>>2<< Hosting environment: "Development">>2<< Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke">>2<< Request starting HTTP/1.1 GET http://localhost/ping  >>2<< Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").>>2<< Executing ObjectResult, writing value of type '"System.String"'.>>2<< Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 15.2088ms>>2<< Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Request finished in 78.8673ms 200 text/plain; charset=utf-8
Запуск тестов вместе 1
Test 1 of 2Test 2 of 2>>2<< Application started. Press Ctrl+C to shut down.>>2<< Application started. Press Ctrl+C to shut down.>>2<< Hosting environment: "Development">>2<< Hosting environment: "Development">>2<< Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke">>2<< Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke">>2<< Request starting HTTP/1.1 GET http://localhost/ping  >>2<< Request starting HTTP/1.1 GET http://localhost/ping  >>2<< Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").>>2<< Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").>>2<< Executing ObjectResult, writing value of type '"System.String"'.>>2<< Executing ObjectResult, writing value of type '"System.String"'.>>2<< Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 13.5891ms>>2<< Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 13.5891ms>>2<< Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Request finished in 78.0903ms 200 text/plain; charset=utf-8>>2<< Request finished in 78.0958ms 200 text/plain; charset=utf-8
Запуск тестов вместе 2
Test 1 of 2==1== Application started. Press Ctrl+C to shut down.==1== Application started. Press Ctrl+C to shut down.==1== Hosting environment: "Development"==1== Hosting environment: "Development"==1== Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke"==1== Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke"==1== Request starting HTTP/1.1 GET http://localhost/ping  ==1== Request starting HTTP/1.1 GET http://localhost/ping  ==1== Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").==1== Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").==1== Executing ObjectResult, writing value of type '"System.String"'.==1== Executing ObjectResult, writing value of type '"System.String"'.==1== Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 12.7648ms==1== Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 12.7649ms==1== Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Request finished in 78.428ms 200 text/plain; charset=utf-8==1== Request finished in 78.4282ms 200 text/plain; charset=utf-8Test 2 of 2

Вывод по тестированию

Так как логгер глобальный и один на все тесты, а тесты выполняются параллельно, то после инициализации веб-приложения в каждом тесте, в качестве глобального логгера остаётся тот логгер, который был присвоен статическому полю Serilog.Log.Logger позже. Поскольку сдвиг по времени в работе тестов непредсказуем, то в разных случаях глобальным становится логгер из разных тестов.

Поэтому при выполнении кода в тестах надо всегда помнить и использовать при интеграции Serilog в .NET Core (метод UseSerilog()) параметр preserveStaticLogger = true, чтобы при инициализации в каждом тесте подсистемы логирования использовался в каждом случаи свой логгер. При этом будет возникать эффект нескольких логгеров с соответствующими последствиями.

Глобальный логгер где нужен?

При должном подходе, работа всего приложения сводится к взаимодействию объектов, создаваемых и связываемых по средствам DI платформой .NET Core, а точнее объектом хоста, который создаётся в примере при использовании метода CreateHostBuilder. И в этом случае самым подходящим способом логирования было бы использование встроенного механизма логирования.

Однако разработчики Serilogтак же предлагают нам так же использовать глобальный логгер в том месте, где нет DI платформы .NET Core в методе Main вне области работы объекта хоста:

  • Запуск!

  • Перехват необработанной ошибки

  • Успешное окончание

Запуск!

На данном этапе мы ещё не добрались до конфигурации. Работа с конфигурацией и создание логгера в соответствии с конфигурацией будет позже. Поэтому тут можно иметь дело только с хардкодом. Самые популярные логгеры, которые не требуют конфигурирование это Console и Debug. Они не требуют конфигурирования, зависящего от окружения и их можно быстро прописать прямо в коде. Эти логгеры выводят сообщения туда, где и так понятно, что приложение запускается:

  • в консоли мы в любом случае как-то понимаем, что приложение начало запускаться;

  • Debug ну, это, скорее всего IDE и тут тоже понятно.

Вот и получается, что:

  • это не даёт понимания о начале запуска приложения, так как оно уже запущено на этот момент и находится в процессе инициализации прикладных механизмов (инициализация объекта хоста, сервисов, загрузка конфигурации и прочее);

  • после успешного запуска хоста веб приложения, хост сам отправит сообщение об успешном запуске:

[01:53:06 INF] Now listening on: http://localhost:5000[01:53:06 INF] Application started. Press Ctrl+C to shut down.[01:53:06 INF] Hosting environment: Development[01:53:06 INF] Content root path: C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke

Вывод:

это лог-сообщение малоинформативное и избыточное

Перехват необработанной ошибки - что получаем?

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

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

public class Startup{    public void ConfigureServices(IServiceCollection services)    {        throw new Exception("Ololo!");        services.AddControllers();    }}

Вывод в консоли:

Вывод в консоль при отлове необработанного исключение Вывод в консоль при отлове необработанного исключение

Теперь закомментируем перехват необработанной ошибки в Main:

public static int Main(string[] args){    Log.Logger = new LoggerConfiguration()        .WriteTo.Console()        .CreateBootstrapLogger();    Log.Information("Starting up!");    try    {        CreateHostBuilder(args).Build().Run();        Log.Information("Stopped cleanly");        return 0;    }    //catch (Exception ex)    //{    //    Log.Fatal(ex, "An unhandled exception occured during bootstrapping");    //    return 1;    //}    finally    {        Log.CloseAndFlush();    }}

Вывод в консоли:

Вывод в консоль без отлова необработанной ошибкиВывод в консоль без отлова необработанной ошибки

Вывод:

Даже без логирования через Serilog, необработанное исключение было выведено в консоль.

Перехват необработанной ошибки - что может случиться?

Теперь проведём тесты, в которых обернём запуск веб-приложения в try-catch и используем для логирования глобальный логгер Serilog, который будет выводить сообщения с префиксом initial, а при запуске веб-приложения установим другой логгер с префиксом configured.

Тест1: ошибка на этапе конфигурирования сервисов. В этом тесте ошибка происходит при конфигуировании сервисов приложения.

//Arrangevar initialLogger = new TestLogger("initial: ", _output);var configuredLogger = new TestLogger("configured: ", _output);HttpClient client;Log.Logger = initialLogger;Log.Information("Starting up!");try{    client = _waf.WithWebHostBuilder(        builder => builder        .UseSerilog((context, config) => config                    .WriteTo.Logger(configuredLogger))        .ConfigureServices(collection =>                           {                               throw new Exception("Ololo!");                           })    ).CreateClient();}catch (Exception e){    Log.Fatal(e, "An unhandled exception occured during bootstrapping");    throw;}finally{    Log.CloseAndFlush();}//Actvar resp = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/ping"));//AssertAssert.True(resp.IsSuccessStatusCode);

Лог:

initial:  Starting up!configured:  An unhandled exception occured during bootstrapping

Тест2: ошибка до инициализации подсистемы логирования .NET Core. В этом тесте ошибка происходит при попытке загрузить отсутствующий конфигурационный файл.

//Arrangevar initialLogger = new TestLogger("initial: ", _output);var configuredLogger = new TestLogger("configured: ", _output);HttpClient client;Log.Logger = initialLogger;Log.Information("Starting up!");try{    client = _waf.WithWebHostBuilder(        builder => builder        .UseSerilog((context, config) =>                         config.WriteTo.Logger(configuredLogger)                       )        .ConfigureAppConfiguration((context, configurationBuilder) =>                             configurationBuilder.AddJsonFile("absent.json"))                    )            .CreateClient();}catch (Exception e){    Log.Fatal(e, "An unhandled exception occured during bootstrapping");    throw;}finally{    Log.CloseAndFlush();}//Actvar resp = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/ping"));//AssertAssert.True(resp.IsSuccessStatusCode);

Лог:

initial:  Starting up!initial:  An unhandled exception occured during bootstrapping

Вывод:

В зависимости от места ошибки при запуске приложения, перехваченная ошибка может быть записана в лог разными логгерами: стартовым (обычно в консоль) или логгером, который создан по конфигурации приложения. Поэтому отсутствие такого сообщения в логах (не в консоли) может говорить о том, что ошибка не произошла или произошла на этапе до готовности подсистемы логирования .NET Core. Т.е. то ли была ошибка, то ли нет. А для вывода необработанного сообщения в консоль не обязательно это делать через логгер.

Успешное окончание

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

public static int Main(string[] args){    ...    try    {        CreateHostBuilder(args).Build().Run();        Log.Information("Stopped cleanly");        return 0;    }    catch (Exception ex)    {        ...    }    finally    {        Log.CloseAndFlush();    }}

Вывод:

Данное сообщение не соответствует действительности.

Вывод по логированию в Main

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

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

  • нет какой-то библиотеки

  • нет конфига

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

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

Глобальный логгер Вред

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

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

Проблемы:

  • потеря лог-сообщений в модульных тестах. В зависимости от особенностей интеграции Serilog в .NET Core (параметр preserveStaticLogger в методе UseSerilog()) могут возникнуть проблемы:

  • по тем же причинам могут не задействоваться средства расширения подсистемы логирования, тестируемые в модульных тестах в составе развёрнутой подсистемы логирования .NET Core и Serilog

  • возможность в любой момент использовать Serilog.Log.Logger мимо системы DI от .NET Core, которая реализуется под самыми весомыми предлогами:

    • это временно;

    • а ну чё, и так же работает;

    • Я так привык на .NET Framework;

  • как следствие, использование в коде статического сервис-локатора - антипаттерн;

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

  • в библиотеке присутствует нехарактерное решение для .NET Core. Сильно напоминает антипаттерн Boat Anchor .

Заключение

Статистика популярности log4net, NLog и Serilog 2013-2021 гг.Статистика популярности log4net, NLog и Serilog 2013-2021 гг.

График на google trends.

Serilog был и есть отличным инструментом. Но он не был переработан и адаптирован в полной мере для .NET Core, поэтому в нём остались механизмы, которые больше характерны для решений на .NET Framework. Как показывает прогресс, .NET, в виде .NET 5, двигается в направлении архитектуры, взятой из .NET Core. Поэтому разработчикам Serilog рано или поздно придётся заняться вопросом адаптации более плотно.

К сожалению, простым запросом слияния в github проблему с глобальным логом полностью не решить. Тут требуется глобальный рефакторинг, который может, например включать такие изменения:

  • вынести из основной библиотеки Serilog все механизмы, относящиеся к .NET Framework в отдельную библиотеку на подобии serilog-aspnetcore, которая будет использовать основную библиотеку. И туда перенести в т.ч. класс Serilog.Log. тогда при подключении Serilog к проекту на .NET Core в приложении этот класс не будет доступен;

  • в serilog-aspnetcore в методе интеграции Serilog в подсистему логирования .NET Core всегда использовать алгоритм работы, соответствующий тому случаю, если указать вх. параметр preserveStaticLogger = true, т.е. вместо глобального логгера создать новый.

В данной статье мы разобрались с тем, какое место в логировании через Serilog в .NET Core занимает глобальный логгер и какие проблемы он может принести.

В следующих статьях будут разобраны другие особенности интеграции Serilog в .NET Core.

Подробнее..
Категории: C , Net , Net core , Logging , Log , Logger , Serilog

Синтезатор на Unity 3D

09.04.2021 12:18:12 | Автор: admin

Учебные материалы для школы программирования. Часть13

Предыдущие уроки можно найти здесь:

При тематическом планировании уроков, мы сталкиваемся с интересной задачкой: в нашей группе учатся и мальчишки и девчонки. У них разные вкусы, разные любимые герои, жанры. И если все занятия будут на тему гонок или Silent Hill - ряды девочек на ваших занятиях, - поредеют. Эту ситуацию мы решаем двумя способами:

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

  • чередуем и комбинируем. Мы стараемся чередовать "спокойные" и "агрессивные" проекты. Например, в этом месяце мы делаем лабиринт ужасов, но после его защиты - приступаем к созданию острова с лесом и домиком. При комбинировании, мы предлагаем всем ребятам уделить внимание визуальной составляющей (раскрасить, добавить элементов, составить композицию и пр.), и девочки с большим энтузиазмом берутся за такую задачу.

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

Рассмотрим следующие темы:

  • выставление вращения объектов в локальной системе координат посредством конверсии из углов Эйлера в кватернионы;

  • события объектов OnMouseEnter и OnMouseExit;

  • метод POW класса Mathf - возведение в степень;

  • парсинг float из имени объекта через системный метод Parse ;

  • стек постэффектов от Unity Technologies;

  • функция движка RequireComponent.

Особое внимание обратим на:

  • изменение скорости воспроизведения и высоты звука через Pitch;

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

Порядок выполнения

Создаётся новый проект, импортируется приложенный ассет, открывается сцена piano (в проекте заранее заготовлена сцена, ключевыми объектами которой являются клавиши, расположенные на сцене в "Пианино/Клавиши").
Клавиши пронумерованы в соответствии с полутонами, начиная с ноты "до" и заканчивается нотой "фа" следующей октавы, т.е. "до", "до диез", "ре", "ре диез", "ми", "фа" и т.д.

Клавиши расположены с начала октавы в той же последовательности, что и на реальном пианино.

Весь проект умещается в один скрипт. Полный листинг содержит пояснения:

using System.Collections;using System.Collections.Generic;using UnityEngine;using System;[RequireComponent(typeof(AudioSource))] // необходимо для того, чтобы скрипт требовал установленный аудиосорсpublic class Piano : MonoBehaviour {public KeyCode Key; // энумератор для выбора клавиши клавиатуры, на которую реагирует скриптAudioSource src; // Аудиосорс, приват-переменная     void Start () {src = GetComponent<AudioSource>(); // получаем аудиосорс        src.pitch = Mathf.Pow(1.059462f, float.Parse(name) - 1f); // высота звука равна 1.059462f в степени (имя_клавиши - 1).}    void Update () { // Для уменьшения отклика стоит использовать FixedUpdateif (Input.GetKeyDown(Key)) { // если нажали клавишу.playNote(); // играем        }        if (Input.GetKeyUp(Key)) { // если отпустили клавишу.stopNote(); // не играем        }}    private void OnMouseEnter() { // если мышь над коллайдером клавиши        playNote(); // играем}    private void OnMouseExit() { // если мышь вышла из коллайдера клавиши         stopNote(); // не играем}    private void playNote() { // играемtransform.localRotation = Quaternion.Euler(-3, 0, 0); // ставим локальный угол поворота на -3 градуса по Хsrc.Play(); // Включаем звук с начала}    private void stopNote(){ // не играемtransform.localRotation = Quaternion.Euler(0, 0, 0); // ставим локальный угол поворота на 0 градусов по всем осям    src.Stop(); // Останавливаем звук    }}

Особое внимание стоит уделить строке:

src.pitch = Mathf.Pow(1.059462f, float.Parse(name) - 1f);//высота звука равна 1.059462f в степени (имя_клавиши - 1).

Число1.059462высчитано математически и является простой заменой логарифмической функции, делящей одну октаву на 12 полутонов. Таким образом, каждый последующий полутон в 1.059462 раза выше предыдущего по частоте, что при количестве 12 полутонов даёт умножение частоты на 2 с ошибкой в 0.00003 Гц на октаву. С учётом того, что динамический диапазон нашего пианино не превышает полторы октавы, звук практически не искажается.

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

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

А всем аудиосорсам в качестве output установлен мастер-канал аудиомикшера.

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

Далее, немного оформляем сцену и добавляем следующие эффекты:

  • SSAO - подчеркнёт тени между клавишами, добавит глубины картинке.

  • Bloom - высветлит светлые участки ещё сильнее, сделает картинку более приятной на глаз, интенсивность нужно выбрать довольно низкую.

  • Антиалиасинг, чтобы убрать пикселизацию.

  • Винетка, чтобы затенить края, выделив основной объект.

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

Готово!

Подробнее..

Разработка своей Just Shapes amp Beats и как всё началось

12.04.2021 00:19:09 | Автор: admin

Немного о себе

Здравствуйте, мне 16 лет и я люблю играть в Just Shapes & Beats (JSAB). Одним прекрасным днём я узнал о такой игре, как JSAB. Я был очень поглощён геймплейной частью, разработчики создали больше 30 уровней из простых геометрических фигур - это же гениально! Но просто так играть мне не хотелось, мне хотелось создавать что-то своё. И так как у JSAB есть редактор уровней, но он находится в pre-alpha тестировании уже больше 2 лет, а уровни делать хочется, мною было принято решение создать свою JSAB. Теперь приступим к самому началу.

Самые начала начал

Так как я давно хотел сделать свою игру, но своих идей не было, я решил таки сделать клон JSAB, но внести туда что-то свое. Движок я выбрал конечно же Unity, так как уже давно им интересовался и имел хоть и небольшой, но хоть какой-то опыт работы с ним. JSAB я решил делать для мобильных устройств, так как и сам хотел играть везде, где бы я ни был. Изначально цветовая гамма моей игры не была похожа на оригинал, всё было монохромным.

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

Техническая часть

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

Создание объектов

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

public GameObject Obj;private void Start(){  for(int i = 0; i < 100; i++){   GameObject.Instantite(Obj);  }}

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

Также все объекты создавались просто в игровом мире, что конечно никуда не годилось. Позже я создал канвас, но об этом уже в другой части. Для создания объектов использовался JSON формат, в который я вручную записывал типы атак, время их появления и параметры. Файлик выглядел примерно так

{  attacks: [    {      "attackType": "DotCircle",      "time": "1,0828",      "dotCount": "20"    },    {      "attackType": "Beam",      "time": "3,06713",      "width": "50"    }  ]}

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

Анимации

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

Animation anim = GetComponent<Animation>();AnimationCurve curve;// create a new AnimationClipAnimationClip clip = new AnimationClip();clip.legacy = true;// create a curve to move the GameObject and assign to the clipKeyframe[] keys;keys = new Keyframe[3];keys[0] = new Keyframe(0.0f, 0.0f);keys[1] = new Keyframe(1.0f, 1.5f);keys[2] = new Keyframe(2.0f, 0.0f); curve = new AnimationCurve(keys);clip.SetCurve("", typeof(Transform), "localPosition.x", curve);// update the clip to a change the red colorcurve = AnimationCurve.Linear(0.0f, 1.0f, 2.0f, 0.0f);clip.SetCurve("", typeof(Material), "_Color.r", curve);// now animate the GameObjectanim.AddClip(clip, clip.name);anim.Play(clip.name);

И здесь всего лишь прописана трансформация объекта по оси X и изменение его цвета.

Коллайдеры

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

Лирическое отступление

Я очень долго провозился с кругом из точек, я просто перелопатил массу материала, но не мог найти ответы. В итоге оказалось, что можно просто использовать синусы и косинусы, но тут тоже были подводные камни. C# в функцию синуса и косинуса принимает значения в радианах. Я очень долго не мог понять в чём же именно проблема, так как давал значение в градусах. Мои точки никак не хотели лететь туда, куда надо, но позже узнал что и как работает. Чтобы перевести градусы в радианы нужно нашу градусную меру умножить на и разделить на 180, но ещё позже я выяснил, что в Unity уже есть готовое решение. Нужно градусную меру (AngleInDegree) умножить на переменную.

public float AngleInDegree = 90f;private void Start(){  float cos = Mathf.Cos(AngleInDegree * Mathf.Deg2Rad);  float sin = Mathf.Sin(AngleInDegree * Mathf.Deg2Rad);}

Начало "новой эпохи"

В конце концов монохромная палитра надоела глазу и было принято решение использовать оригинальную розово-голубую палитру. Также вся механика была переработана, я отказался от использования JSON в качестве файла уровня и перешёл на использование встроенного юнитевского таймлайна. На нём можно было создавать кастомные маркеры (материал, который я использовал, если вдруг понадобится). И я создал несколько маркеров, отвечающих за свои атаки. Также связал Playable (компонент таймлайна, который отвечает за проигрывание и т.д.) с AudioSource'ом (музыка) и в итоге всё это было синхронизировано.

В итоге вышла такая вот штука:

Небольшой итог

После добавления ещё нескольких атак, я создал наконец первый уровень, ну точнее сделал ремейк уровня Chronos (и то не полностью ремейк).

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

Подробнее..

Многопоточность в Photon

14.04.2021 14:22:42 | Автор: admin

О чём статья

В этой статье мы поговорим о многопоточности в серверной части.

  • как реализована

  • как используется

  • что можно сделать

  • что мы сами изобрели

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

Как в Photon решается вопрос с многопоточностью?

Серверное приложение на фотоне принимает запросы от множества клиентских соединений. Буду называть такие соединения пирами. Эти запросы образуют очереди. По одной на каждый пир. Если пиры подключены к одной комнате, их очереди объединяются в одну - очередь комнаты. Таких комнат набирается до нескольких тысяч и их очереди запросов обрабатываются тоже параллельно.

В качестве основы для реализации очередей задачи в Photon была взята библиотека retlang, которая была разработана на базе библиотеки Jetlang.

Почему не используем Task и async/await

Поэтому поводу есть следующие соображения:

  1. Photon начали разрабатывать до появления этих штук

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

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

Что такое Fiber?

Файбер это класс, который реализует очередь команд. Команды ставятся в очередь и исполняются одна за другой - FIFO. Можно сказать, что тут реализован шаблон multiple writers-single reader. Я ещё раз хочу обратить внимание на то, что команды исполняются в той последовательности, в которой поступили, т.е. одна за другой. На этом основывается безопасность доступа к данным в многопоточной среде.

Хотя в Photon мы используем только один файбер, а именно PoolFiber, библиотека предоставляет их пять. Все они реализуют интерфейс IFiber. Вот коротко о них.

  • ThreadFiber - это IFiber, опирающийся на выделенный поток. Используется для частых и чувствительных к быстродействию операций.

  • PoolFiber - это IFiber, опирающийся на пул потоков .NET. Выполнение всё равно происходит последовательно и только в одном потоке за раз. Используйте его для нечастых и менее чувствительных к производительности операций. Или когда желательно не увеличивать количество потоков (Наш случай).

  • FormFiber/DispatchFiber - это IFiber, опирающийся на механизм сообщений WinForms/WPF. FormFiber/DispatchFiber полностью удаляют необходимость в вызове Invoke или BeginInvoke чтобы коммуницировать с окном из другого потока.

  • StubFiber - очень полезен для детерминированного тестирования. Предоставляется точный контроль, чтобы сделать тестирование опережений (races) простым. Исполнение всех задач происходит в вызывающем потоке.

Про PoolFiber

Раскрою тему про выполнение задач вы PoolFiber. Хоть он и использует пул потоков, задачи в нём всё равно выполняются последовательно и используется только один поток за раз. Работает это так:

  1. мы ставим в файбер задачу и она начинает исполнятся. Для этого вызывается ThreadPool.QueueUserWorkItem. И в какой-то момент выбирается один поток из пула и он выполняет эту задачу.

  2. Если пока первая задача выполнялась мы поставили ещё несколько задач, то по окончании выполнения первой задачи, все новый забираются из очереди и снова вызывается ThreadPool.QueueUserWorkItem, чтобы все эти задачи отправились на исполнение. Для них будет выбран новый поток из пула. И когда он закончит, если в очереди есть задачи всё повторяется с начала.

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

Почему PoolFiber

В Photon повсеместно используются PoolFiber. В первую очередь как раз потому, что он не создаёт дополнительных потоков и своим файбером может обладать любой кому это нужно. Мы его, кстати, немного модифицировали и теперь его нельзя остановить. Т.е. PoolFiber.Stop не остановит исполнение текущих задач. Для нас это было важно.

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

Поставить задачу в файбер можно тремя способами:

  • поставить задачу в очередь

  • поставить задачу в очередь, которая будет выполнена через некоторый интервал

  • поставить задачу в очередь, которая будет выполняться регулярно.

На уровне кода это выглядит примерно так:

// поставили задачу в очередьfiber.Enqueue(()=>{some action code;});
// поставили задачу в очередь, чтобы выполнилась через 10 секундvar scheduledAction = fiber.Schedule(()=>{some action code;}, 10_000);...// останавливаем таймерscheduledAction.Dispose()
// поставили задачу в очередь, чтобы выполнилась через 10 секунд и каждые 5var scheduledAction = fiber.Schedule(()=>{some action code;}, 10_000, 5_000);...// останавливаем таймерscheduledAction.Dispose()

Для задач, которые выполняются через какой-то интервал важно сохранить ссылку, которую вернул fiber.Schedule. Это единственный способ остановить выполнение такой задачи.

Executors

Теперь про экзекуторы. Это классы, которые собственно выполняют задачи. Они реализуют методы Execute(Action a) и Execute(List<Action> a). PoolFiber использует второй. Т.е. задачи пачкой попадают в экзекутор. Что с ними дальше происходит зависит от экзекутора. Поначалу мы использовали класс DefaultExecutor. Всё что он делает это:

        public void Execute(List<Action> toExecute)        {            foreach (var action in toExecute)            {                Execute(action);            }        }        public void Execute(Action toExecute)        {            if (_running)            {                toExecute();            }        }

В реальной жизни этого оказалось недостаточно. Потому что в случае исключения в одном из 'action' все остальные из списка toExecute пропускались. Поэтому по умолчанию сейчас используется FailSafeBatchExecutor, который внутрь цикла добавляет ещё try/catch. Мы рекомендуем использовать именно этот экзекутор, если не нужно ничего особенного. Этот экзекутор мы добавили сами, поэтому его нет в тех версиях, которые можно найти например на github.

Что ещё мы сами изобрели

BeforeAfterExecutor

Позднее мы добавили ещё один экзекутор, чтобы решить наши задачи с логгированием. Называется он BeforeAfterExecutor. Он "обёртывает" переданный эму экзекутор. Если ничего не передали, то создаётся FailSafeBatchExecutor. Особенностью BeforeAfterExecutor является способность выполнять экшен перед выполнением списка задач и ещё один экшен после выполнения списка задач. Конструктор выглядит следующим образом:

public BeforeAfterExecutor(Action beforeExecute, Action afterExecute, IExecutor executor = null)

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

Пример:

var beforeAction = ()=>{  log4net.ThreadContext.Properties["Meta1"] = "value";};var afterAction = () => ThreadContext.Properties.Clear();//создаём экзекуторvar e = new BeforeAfterExecutor(beforeAction, afterAction);//создаём PoolFibervar fiber = new PoolFiber(e);

Теперь если что-то логгируется из задачи, которая исполняется в fiber, log4net добавит тэг Meta1 со значением value.

ExtendedPoolFiber и ExtendedFailSafeExecutor

Есть ещё одна штука, которой не было в оригинальной версии retlang, и которую мы разработали позже. Этому предшествовала следующая история. Делюсь ей, чтобы и другим неповадно было. Была следующая задача. Есть PoolFiber (это тот, что работает поверх пула потоков .NET). В задаче, которая выполняется этим файбером, нам было необходимо синхронно выполнить HTTP запрос. Сделали просто:

  1. перед выполнением запроса создаём event;

  2. в другой файбер отправляется задача, выполняющая запрос, и, по завершению, ставящая event в сигнальное положение;

  3. после этого встаём ожидать event.

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

Решение было реализовано в ExtendedPoolFiber и ExtendedFailSafeExecutor. Придумали ставить весь файбер на паузу. В этом состоянии он может накапливать новые задачи в очереди, но не исполняет их. Для того, чтобы поставить файбер на паузу вызывается метод Pause. Как только он вызван файбер (а именно экзекутор файбера) ждёт пока текущая задача выполнится и замирает. Все остальные задачи будут ждать первого из двух событий:

  1. Вызов метода Resume

  2. Таймаута (указывается при вызове метода Pause) В метод Resume можно поставить ещё и задачу, которая будет выполнена перед всеми, стоявшими в очереди задачами.

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

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

IFiberAction

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

Интерфейс IFiberAction выглядит следующим образом:

public interface IFiberAction{    void Execute()    void Return()}

Метод Execute содержит собственно, то что нужно исполнить. Метод Return вызывается после Execute, когда пришло время вернуть объект в пул.

Пример:

public class PeerHandleRequestAction : IFiberAction{    public static readonly ObjectPool<PeerHandleRequestAction> Pool = initialization;    public OperationRequest Request {get; set;}    public PhotonPeer Peer {get; set;}        public void Execute()    {        this.Peer.HandleRequest(this.Request);    }        public void Return()    {        this.Peer = null;        this.Request = null;                Pool.Return(this);    }}//теперь использование будет выглядит примерно такvar action = PeerHandleRequestAction.Pool.Get();action.Peer = peer;action.Request = request;peer.Fiber.Enqueue(action);

Заключение

В качестве заключения коротко резюмирую то, о чём рассказал. Для обспечения потокобезопастности в фотон мы используем очереди задач, которые в нашем случае представлены файберами. Основной вид файбера, который мы используем это PoolFiber и его наследники. PoolFiber реализует очередь задач поверх стандартного пула потоков .NET. В силу дешевизны PoolFiber своим файбером могут обладать все, кому это необходимо. Если необходимо ставить очередь задач на паузу, используйте ExtendedPoolFiber.

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

Подробнее..
Категории: C , Net , Разработка игр , Multithreading , Photon

Книга C 8 и .NET Core. Разработка и оптимизация

15.04.2021 12:10:33 | Автор: admin
image Привет, Хаброжители! В издании рассмотрены все темы, связанные с разработкой на C#. В начале книги вы ознакомитесь с основами C#, в том числе с объектно-ориентированным программированием, а также с новыми возможностями C# 8.0. Несколько глав посвящено .NET Standard API, применяемым для запроса данных и управления ими, отслеживания производительности и ее повышения, работы с файловой системой, асинхронными потоками, сериализацией и шифрованием. Кроме того, на примерах кроссплатформенных приложений вы сможете собрать и развернуть собственные. Например, веб-приложения с использованием ASP.NET Core или мобильные приложения на Xamarin Forms.

Также вы познакомитесь с технологиями, применяемыми при создании приложений Windows для ПК, в частности с Windows Forms, Windows Presentation Foundation (WPF) и Universal Windows Platform (UWP).

Улучшение производительности и масштабируемости с помощью многозадачности


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

В этой главе:

  • процессы, потоки и задачи;
  • мониторинг производительности и использования ресурсов;
  • асинхронное выполнение задач;
  • синхронизация доступа к общим ресурсам;
  • ключевые слова async и await.

Процессы, потоки и задачи


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

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

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

Потоки имеют свойства Priority и ThreadState. Кроме того, существует класс ThreadPool, предназначенный для управления пулом фоновых рабочих потоков, как показано на следующей схеме (рис. 13.1).

image

Если вы как разработчик имеете дело со сложными действиями, которые должны быть выполнены вашим кодом, и хотите получить полный контроль над ними, то можете создавать отдельные экземпляры класса Thread и управлять ими. При наличии одного основного потока и нескольких небольших действий, которые можно выполнять в фоновом режиме, вы можете добавить экземпляры делегатов, указывающие на эти фрагменты, реализованные в виде методов в очередь, и они будут автоматически распределены по потокам с помощью пула потоков.
Дополнительную информацию о пуле потоков можно получить на сайте docs.microsoft.com/ru-ru/dotnet/standard/threading/the-managed-thread-pool.

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

В зависимости от задачи удвоение количества потоков (рабочих) не уменьшает вдвое количество секунд, которое будет затрачено на выполнение задачи. Фактически это может даже увеличить длительность выполнения задачи, как показано на рис. 13.2.

image

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

Мониторинг производительности и использования ресурсов


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

Оценка эффективности типов


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

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

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

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

Если же необходимо сохранить только несколько чисел, но выполнить с ними большое количество операций, то лучше выбрать тип, который быстрее всего работает на конкретном ЦПУ.

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

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

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

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

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

Мониторинг производительности и использования памяти


В пространстве имен System.Diagnostics реализовано большое количество полезных типов для мониторинга вашего кода. В первую очередь следует рассмотреть тип Stopwatch.

1. Создайте в папке Code папку Chapter13 с двумя подпапками MonitoringLib и MonitoringApp.

2. В программе Visual Studio Code сохраните рабочую область как Chapter13.code-workspace.

3. Добавьте в рабочую область папку MonitoringLib, откройте для нее новую панель TERMINAL (Терминал) и создайте новый проект библиотеки классов, как показано в следующей команде:

dotnet new classlib

4. Добавьте в рабочую область папку MonitoringApp, откройте для нее новую панель TERMINAL (Терминал) и создайте новый проект консольного приложения, как показано в следующей команде:

dotnet new console

5. В проекте MonitoringLib переименуйте файл Class1.cs на Recorder.cs.

6. В проекте MonitoringApp найдите и откройте файл MonitoringApp.csproj и добавьте ссылку на библиотеку MonitoringLib, как показано ниже (выделено полужирным шрифтом):

<Project Sdk="Microsoft.NET.Sdk">   <PropertyGroup>      <OutputType>Exe</OutputType>      <TargetFramework>netcoreapp3.0</TargetFramework>   </PropertyGroup>   <ItemGroup>      <ProjectReference          Include="..\MonitoringLib\MonitoringLib.csproj" />      </ItemGroup></Project>

7. На панели TERMINAL (Терминал) скомпилируйте проекты, как показано в следующей команде:

dotnet build

Реализация класса Recorder


Тип Stopwatch содержит несколько полезных членов, как показано в табл. 13.1.

image

Тип Process содержит несколько полезных членов, перечисленных в табл. 13.2.

image

Для реализации класса Recorder мы будем использовать классы Stopwatch и Process.

1. Откройте файл Recorder.cs и измените его содержимое, чтобы задействовать экземпляр класса Stopwatch в целях записи времени и текущий экземпляр класса Process для записи использованной памяти, как показано ниже:

using System;using System.Diagnostics;using static System.Console;using static System.Diagnostics.Process;namespace Packt.Shared{   public static class Recorder{   static Stopwatch timer = new Stopwatch();   static long bytesPhysicalBefore = 0;   static long bytesVirtualBefore = 0;   public static void Start()   {      // очистка памяти, на которую больше нет ссылок,      // но которая еще не освобождена     GC.Collect();     GC.WaitForPendingFinalizers();     GC.Collect();     // сохранение текущего использования физической     // и виртуальной памяти     bytesPhysicalBefore = GetCurrentProcess().WorkingSet64;     bytesVirtualBefore = GetCurrentProcess().VirtualMemorySize64;     timer.Restart();   }   public static void Stop()   {     timer.Stop();     long bytesPhysicalAfter = GetCurrentProcess().WorkingSet64;     long bytesVirtualAfter =      GetCurrentProcess().VirtualMemorySize64;    WriteLine("{0:N0} physical bytes used.",       bytesPhysicalAfter - bytesPhysicalBefore);    WriteLine("{0:N0} virtual bytes used.",      bytesVirtualAfter - bytesVirtualBefore);    WriteLine("{0} time span ellapsed.", timer.Elapsed);     WriteLine("{0:N0} total milliseconds ellapsed.",       timer.ElapsedMilliseconds);   } }}

В методе Start класса Recorder используется сборщик мусора (garbage collector, класс GC), позволяющий нам гарантировать, что вся выделенная в настоящий момент память будет собрана до записи количества использованной памяти. Это сложная техника, и ее стоит избегать при разработке прикладной программы.

2. В классе Program в метод Main добавьте операторы для запуска и остановки класса Recorder при генерации массива из 10 000 целых чисел, как показано ниже:

using System.Linq;using Packt.Shared;using static System.Console;namespace MonitoringApp{    class Program    {      static void Main(string[] args)      {        WriteLine("Processing. Please wait...");        Recorder.Start();        // моделирование процесса, требующего ресурсов памяти...        int[] largeArrayOfInts =           Enumerable.Range(1, 10_000).ToArray();        // ...и занимает некоторое время, чтобы завершить        System.Threading.Thread.Sleep(           new Random().Next(5, 10) * 1000);        Recorder.Stop();      }   }}

3. Запустите консольное приложение и проанализируйте результат:

Processing. Please wait...655,360 physical bytes used.536,576 virtual bytes used.00:00:09.0038702 time span ellapsed.9,003 total milliseconds ellapsed

Измерение эффективности обработки строк

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

1. Закомментируйте предыдущий код в методе Main, обернув его символами /* и */.

2. Добавьте в метод Main следующий код. Он создает массив из 50 000 переменных int, а затем конкатенирует их, используя в качестве разделителей запятые, с помощью классов string и StringBuilder:

int[] numbers = Enumerable.Range(1, 50_000).ToArray();Recorder.Start();WriteLine("Using string with +");string s = "";for (int i = 0; i < numbers.Length; i++){   s += numbers[i] + ", ";}Recorder.Stop();Recorder.Start();WriteLine("Using StringBuilder");var builder = new System.Text.StringBuilder();for (int i = 0; i < numbers.Length; i++){   builder.Append(numbers[i]); builder.Append(", ");}Recorder.Stop();

3. Запустите консольное приложение и проанализируйте результат:

Using string with +11,231,232 physical bytes used.29,843,456 virtual bytes used.00:00:02.6908216 time span ellapsed.2,690 total milliseconds ellapsed.Using StringBuilder4,096 physical bytes used.0 virtual bytes used.00:00:00.0023091 time span ellapsed.2 total milliseconds ellapsed.

Исходя из результатов, мы можем сделать следующие выводы:

  • класс string вместе с оператором + использовал около 11 Мбайт физической памяти, 29 Мбайт виртуальной и занял по времени 2,7 с;
  • класс StringBuilder использовал 4 Кбайт физической памяти, 0 виртуальной и занял менее 2 мс.

В нашем случае при конкатенации текста класс StringBuilder выполняется примерно в 1000 раз быстрее и приблизительно в 10 000 раз эффективнее по затратам ресурсов памяти!
Избегайте использования метода String.Concat и оператора + внутри цикла. Вместо этого для конкатенации переменных, особенно в циклах, применяйте класс StringBuilder.

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

Об авторе


imageМарк Дж. Прайс обладатель сертификатов Microsoft Certified Trainer (MCT), Microsoft Specialist: Programming in C# и Microsoft Specialist: Architecting Microsoft Azure Infrastructure Solutions. За его плечами более 20 лет практики в области обучения и программирования.

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

В период с 2001 по 2003 год Марк посвящал все свое время разработке официального обучающего программного обеспечения в штаб-квартире Microsoft в американском городе Редмонд. В составе команды он написал первый обучающий курс по C#, когда была только выпущена ранняя альфа-версия языка. Во время сотрудничества с Microsoft он преподавал на курсах повышения квалификации сертифицированных корпорацией специалистов, читая лекции по C# и .NET.

В настоящее время Марк разрабатывает и поддерживает обучающие курсы для системы Digital Experience Platform компании Episerver, лучшей .NET CMS в сфере цифрового маркетинга и электронной коммерции.

В 2010 году Марк получил свидетельство об окончании последипломной программы обучения, дающее право на преподавание. Он преподает старшеклассникам математику в двух средних школах в Лондоне. Кроме того, Марк получил сертификат Computer Science BSc. Hons. Degree в Бристольском университете (Англия).

О научном редакторе


Дамир Арх профессионал с многолетним опытом разработки и сопровождения различного программного обеспечения: от сложных корпоративных программных проектов до современных потребительских мобильных приложений. Он работал со многими языками, однако его любимым остается C#. Стремясь к совершенствованию процессов, Дамир предпочитает разработку, основанную на тестировании, непрерывной интеграции и непрерывном развертывании. Он делится своими знаниями, выступая на конференциях, ведет блоги и пишет статьи. Дамир Арх семь раз подряд получал престижную премию Microsoft MVP за разработку технологий. В свободное время он всегда в движении: любит пеший туризм, геокэшинг, бег и скалолазание.

Более подробно с книгой можно ознакомиться на сайте издательства
Оглавление
Отрывок

Для Хаброжителей скидка 25% по купону .NET

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Подробнее..

Разработка своей Just Shapes ampamp Beats. Канвас и немного об оптимизации

16.04.2021 12:14:51 | Автор: admin

Предисловие

Добро пожаловать во вторую статью о разработке своей Just Shapes & Beats. Сегодня я продолжу первую статью и расскажу вам об использовании канваса, Unity Timeline'а и немного об оптимизации.

Использование канваса

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

Оптимизация

Приступим к теме оптимизации. Так как я изначально создавал все объекты через Instantiate, а количество вызовов этого метода порой доходило до 100 раз за один кадр, мне понадобилось оптимизировать их создание. После очередного перелопачивания разных материалов, я вычитал, что для таких целей существует пул объектов. Работает он следующим образом:

  • В методе Start создаётся нужное количество объектов и делается неактивным, чтобы не загружать память

  • Каждый объект записывается в List<GameObject>

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

  • Когда мы вызываем метод ReturnObject мы должны передать в него какой-то GameObject, этот GameObject сделается неактивным и запишется в List.

Скрипт пула выглядит вот так:

public class Pool : MonoBehaviour    {        public List<GameObject> PoolObjects = new List<GameObject>();        public GameObject Instance;        public void Init(int instanceCount, GameObject instance){            for(int i = 0; i < instanceCount; i++){                GameObject _instance = GameObject.Instantiate(instance) as GameObject;                _instance.transform.SetParent(Utils.Global.Arena, false);                _instance.SetActive(false);                PoolObjects.Add(_instance);            }            Instance = instance;        }        public GameObject GetObject(){            if(PoolObjects.Count > 0) {                GameObject _instance = PoolObjects[PoolObjects.Count - 1] as GameObject;                PoolObjects.Remove(PoolObjects[PoolObjects.Count - 1]);                _instance.SetActive(true);                return _instance;            }            else{                GameObject _instance = GameObject.Instantiate(Instance) as GameObject;                _instance.transform.SetParent(Utils.Global.Arena, false);                _instance.SetActive(true);                return _instance;            }        }        public void ReturnObject(GameObject instance){            instance.SetActive(false);            PoolObjects.Add(instance);        }    }

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

Редактор уровней

Поговорим немного о редакторе. Он был основал на Unity Timeline, там есть такая замечательная штука как Marker - это что-то вроде команды, которая отправляется в заданное время. Также есть возможность создавать кастомные маркеры и выполнять с помощью них всё, что угодно.

Вкратце код маркера выглядит вот так:

public class NotificationMarker : Marker, INotification{   public PropertyName id { get; }}

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

В ней подробно описано, как можно создать кастомные Notification'ы, маркеры, как их стилизовать и т.д.

Создание уровней

На самом деле, используя Unity Timeline, я обрёк себя на вечные мучения. Так как в JSAB у каждой атаки есть своё время предупреждения и активное время, мне нужно было создавать каждый маркер на секунду или две раньше, чтобы это предупреждение сработало. Именно этот фактор и создавал все проблемы. Для удобства я назначил создание каждой атаки на свою клавишу и добавил стандартное смещение по времени на одну секунду.

Первый релиз

Где-то в июле я выпустил первый билд игры на Gamejolt, он был очень сырой, но там было несколько уровней.

Также немного позже я переписал игру с целью оптимизации (подробнее об этом в следующей статье) и создал ещё три новых уровня.

После создания последнего уровня моё терпение лопнуло и я понял, что мне нужен нормальный редактор уровней. Я в третий раз переписал игру и взялся за его разработку. Как раз об этом я и расскажу в следующих статьях. Спасибо за внимание!

Подробнее..
Категории: C , Gamedev , Разработка игр , Unity , Gamedevelop , Jsab , Jsb

Регдоллы на Unity 3D

16.04.2021 18:11:03 | Автор: admin

Учебные материалы для школы программирования. Часть15

Предыдущие уроки можно найти здесь:

Регдоллы - физика тряпичных кукол, основная задача которых - реалистичное падение тел со скелетом. Регдоллы применяются везде - от шутеров (падающие враги) до гонок. Допустим, в Goat Simulator регдоллы являются важнои частью геимплея.

Goat SimulatorGoat Simulator

В данном занятии рассмотрены следующие аспекты:

  • работа со стандартным генератором регдоллов;

  • понимание скелета гуманоидных моделеи;

  • исправление неверно выставленных коллаидеров на Rigidbidy посредством дополнительных объектов в иерархии.

Порядок выполнения

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

Создадим новую сцену, установим на нее плеин или квад в качестве пола. На пол поставим лестницу, на лестницу - модель робота Каила.

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

Перед созданием регдолл-системы необходимо выставить модель в Т-позу. Поворачиваем 2 этих объекта в локальнои системе координат до нужного угла.

должно быть так:

Далее, нажимаем в окне объектов Create->Ragdoll и конфигурируем его следующим образом:

Жмем Create и упираемся в одну проблему. Как можно заметить, модель имеет неверные коллаидеры.

Исправим их размер вручную, кроме того коллайдера, которыи находится на правои (от нас) руке. С ним поступим иначе. Это капсула на руке, и еи нельзя напрямую выставить угол наклона, но можно скопировать этот компонент на пустои объект, разместить его в пивоте руки и повернуть локально. Капсулу на самои руке удалим.

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

Готово!

Подробнее..

Судно на воздушной подушке на Unity 3D

16.04.2021 18:11:03 | Автор: admin

Учебные материалы для школы программирования. Часть14

Предыдущие уроки можно найти здесь:

Сегодня мы настроены на отдых и развлечения! Поэтому, этот урок будет простой и "короткий". Мы не будем работать с графикой (но вас никто не ограничивает в праве усовершенствовать проект), уделим внимание управлению и работе с физикои, на примере создания судна на воздушнои подушке.

Порядок выполнения

Создадим новыи проект, импортируем в него приложенныи ассет. В данном ассете содержится модели, звук и простая сцена.

Первое, что нам необходимо сделать - это установить на сцену модели карты и СВП, затем создать материал с нулевым трением и назначить его юбке СВП

На само судно устанавливаем Rigidbody со следующими параметрами:

Обратите внимание, что на коллаидерах установлена галочка Convex, а Rigidbody не имеет галочку использования гравитации. Вместо нее используется ConstantForce с довольно большим значением, направленная вниз.

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

Скрипт конфигурируется согласно позапрошлому скриншоту. Полныи листинг скрипта выглядит таким образом:

using UnityEngine;using System.Collections; public class Howercraft: MonoBehaviour {    public Rigidbody HowercraftRigidbody; // риджитбади     public Transform CenterOfMass; // центр масс    public float power = 25000; // мощность вперёд/назад    public float torque = 25000; // мощность влево/вправо    float finAngle; // угол отклонения лопаток     float pitch; // питч для звука    public Transform[] Fins; // массив с лопатками    public AudioSource mainEngine; // звук основного двигателя       public AudioSource pushEngine; // звук турбин     // Use this for initialization     void Start() {        HowercraftRigidbody.centerOfMass = CenterOfMass.position - HowercraftRigidbody.position; // устанавливаем центр масс    }     // Update is called once per frame    void Update() {                float inpFB = Input.GetAxis("Vertical"); // ввод вперёд/назад        float inpLR = Input.GetAxis("Horizontal"); // и влево/вправо              Vector3 vely = new Vector3(HowercraftRigidbody.transform.forward.x, 0, HowercraftRigidbody.transform.for ward.z); // находим вектор приложения силы          float gain = Mathf.Clamp01(HowercraftRigidbody.transform.up.y); // если перевёрнуты, силы будут равны нулю             HowercraftRigidbody.AddForce(vely * power * inpFB * gain, ForceMode.Force); // добавляем линейные силы              HowercraftRigidbody.AddRelativeTorque(0, torque * inpLR * inpFB * gain, 0, ForceMode.Force); // и поворот              finAngle = Mathf.Lerp(finAngle, -45 * inpLR, Time.deltaTime / 0.2f); // угол лопаток            foreach(Transform Fin in Fins) {            Fin.localEulerAngles = new Vector3(0, finAngle, 0); // выставляем угол         }        mainEngine.pitch = 0.9f + HowercraftRigidbody.velocity.magnitude / 60f; //высота звука основного двигателя               pitch = Mathf.Lerp(pitch, Mathf.Abs(inpFB) * 1.3f, Time.deltaTime / 0.5f); // высчитываем высоту звука турбины               pushEngine.pitch = 1f + 2f * pitch;        pushEngine.volume = 0.3f + pitch / 3f;    }}

При этом скрипт лучше давать последовательно, сначала физическии движок, потом звуковои.

Готово!

Подробнее..

Комментарии ложь

16.04.2021 20:09:46 | Автор: admin

Если вы программист, то есть много практик, которые можно ненавидеть.

Жестко запрограммированные значения. Двойная логика. Сложные иерархии наследования. Но должны ли входить комментарии в этом списке?

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

Многие разработчики почувствовали себя загнанными в угол.

Ложный выборЛожный выбор

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

Ленивые комментарии

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

Например, возьмем этот фрагмент кода, взятый из реального приложения:

double delta = h*h-r1*r1;double r2 = Math.Sqrt(delta);

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

// Calculate side length using the Pythagorean Theorem// and put the value into variable "r2"double delta = h*h-r1*r1;double r2 = Math.Sqrt(delta);

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

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

double lengthSideB = Math.Sqrt(  Math.Pow(hypotenuse,2) - Math.Pow(lengthSideA,2);)

Или вы можете вынести операцию в отдельный метод и назвать метод должным образом:

double sideA = Pythagoras.GetLengthOfSide(hyptenuse, sideB);

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

Ложные комментарии

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

/**  * Constructor.  *   * @param name (required) brand name of the product. Must have  * content. Length must be in range 1..50.   * @param price (optional) purchase price of the product.  * @param units (required) number of units currently in stock.  * Can not be less than 0.*/public Product(string name, decimal price, integer units){   ...}

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

Все врет

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

Комментарии - ложь - говорит нам перестать доверять комментариям, а не писать их.

Комментарии не бесполезны

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

// to match ITG1's late arrows.  -KGlobalOffsetSeconds=-0.006

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

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

I spent some time this weekend looking at very well-named, very clean, uncommented code implementing a research algorithm. Im high-level familiar with it, the guy sitting next to me was the inventor, and the code was written a few years ago by someone else. We couldbarelyfollow it. Paul Nathan

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

Комментарии представляют собой страховой полис

Да, есть цена за привнесение двусмысленности и возможной лжи в ваш код. Но есть также расходы на страхование дома или автомобиля. Мы можем снизить затраты на комментарии следующим образом:

  • Помните о плохих методах работы (ленивых комментариях и небрежном коде)

  • Осторожно относиться ко лжи (никому не доверять)

  • Управление мусорным комментарием (с помощью инструментов в среде IDE)

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

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

Подробнее..

Категории

Последние комментарии

© 2006-2021, personeltest.ru