Создание .NET библиотеки от А до Я

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

До начала разработки

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

  1. добавить красивую строчку в резюме, показав заодно потенциальному работодателю пример вашего кода;

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

  3. потешить самолюбие, создав что-то;

  4. получить похвалу от бабушки.

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

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

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

Например, в MIDI-файле все времена записаны как разницы между двумя подряд идущими событиями – дельты (delta-times). Единицы измерения этих дельт принято называть тики. Можно назвать их попугаями и ничего не поменяется. Значение этих попугаев задаётся в заголовке файла, а на их представление в секундах влияют также присутствующие в файле смены темпа. API для работы со временем и длиной в “человеческих” единицах было в DryWetMIDI первой фичей, отсутствующей в других проектах. В конце концов, кому нужна ещё одна библиотека, не предоставляющая новых полезных возможностей?

Помимо заглядывания украдкой к соседям имеет смысл прошерстить профильные ресурсы на предмет вопросов, связанных с реальными задачами людей. На старте своего проекта я изучил таковые на Stack Overflow по тегу midi. Одним из самых частых вопросов был “Как получить ноты из MIDI-файла”. Поэтому в DryWetMIDI можно получить и ноты, и аккорды, и паузы и комбинации всего этого. Кстати, если кто-то не знает, на сайтах сети Stack Exchange (к коей принадлежит и Stack Overflow) можно подписаться на определённые теги, и все новые вопросы, отмеченные ими, будут приходить на почту.

Есть и другие варианты получения информации о запросах ваших потенциальных пользователей. Например:

  1. форумы по интересующей тематике;

  2. сервисы вроде F5Bot, которые умеют присылать уведомления, если случаются упоминания указанных вами ключевых слов на ресурсах типа Reddit или Hacker News;

  3. issues в соседних проектах (можно подписаться (watch) на них и выбрать, за чем вы хотите следить, например, за issues и релизами).

Работа с кодом

В данном разделе, вероятно, многие увидят очевидные вещи, другие же узрят вредные советы. Сразу хочу отметить, что большинство крупных производителей инструментов для разработки предлагают бесплатные лицензии на свои продукты для проектов с открытым исходным кодом и/или не преследующих коммерческие цели. Например, Microsoft предлагает Visual Studio Community, а JetBrains предоставит вам лицензию на все их продукты, в том числе Rider, ReSharper и т.д. Поэтому, если вам приглянулся какой-то инструмент, способный помочь вам в разработке, вероятно, вы можете легально использовать его бесплатно.

Архитектура

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

Иметь чёткое представление о будущем API довольно сложно, у меня получается редко. Расскажу реальный пример. По большому счёту в MIDI есть два основных пласта: MIDI-файлы и MIDI-устройства. Первая версия DryWetMIDI вышла в мае 2017-го. В январе 2018-го вот, что я говорил пользователю, про, например, воспроизведение MIDI-данных:

Thank you for using the DryWetMIDI. At now there is no way to play a MIDI file with the library. Concept of the library is to provide reading, writing and creating MIDI files. Sound generation is not supposed to be a part of it. … Hmm, it seems that playing a MIDI file (especially not saved) doesn't fit the DryWetMIDI concept. There are other libraries that provide sound generation for MIDI files so I recommend to use them.

(Спасибо, что пользуетесь DryWetMIDI. Сейчас нельзя воспроизвести MIDI-файл средствами библиотеки. Идея библиотеки предоставить чтение, запись и создание MIDI-файлов. Генерация звука не планируется. … Хмм, похоже, воспроизведение MIDI-файла (особенно не сохранённого) не соответствует концепции DryWetMIDI. Есть другие библиотеки, предоставляющие генерацию звука для MIDI-файлов, рекомендую использовать их.)

А в январе 2019-го вышла версия 4.0.0, привнёсшая-таки API для работы с устройствами, а также воспроизведение и запись данных. В версии 5.0.0 было много ломающих изменений ввиду переименования пространств имён, ибо их названия изначально шли от идей, проиллюстрированных цитатами выше, и выглядели всё более и более неправильно от версии к версии. А версия 6.0.0 добавила поддержку macOS в API для работы с устройствами, что потребовало реорганизации связанных типов. Это всё к вопросу о прогнозировании развития проекта.

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

Любопытно, что, например, Visual Studio предлагает вычисление некоторых метрик кода:

Узнаем, что думает VS о нашем коде
Узнаем, что думает VS о нашем коде

Вы получите таблицу наподобие этой:

Беспристрастное мнение программы
Беспристрастное мнение программы

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

Тесты

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

Я не призываю к определённым подходам вроде TDD или BDD, вы можете определять чередование написания кода и тестов самостоятельно. Главное, чтобы тесты вообще были. Без них работа с более или менее сложной логикой (а таковая у вас будет достаточно скоро) превратится в постоянное затыкание дыр, причём про эти дыры вы будете узнавать от пользователей. Или не будете, потому что из-за постоянных багов никто вашей библиотекой не пользуется.

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

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

Устаревший API

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

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

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

[Obsolete("OBS19")]
public static IEnumerable<MidiFile> SplitByNotes(this MidiFile midiFile, SplitFileByNotesSettings settings = null)

Это пример из текущей версии кода DryWetMIDI. OBS19, вероятно, ничего вам не говорит. Однако у меня настроена генерация описаний атрибутов Obsolete по специальному JSON-файлу. Я расскажу об этом подробнее в разделе CI и автоматизация.

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

[Obsolete("This method is obsolete.")]
public static void Foo()

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

[Obsolete("Use Bar method instead.")]
public static void Foo()

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

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

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

Статический анализ

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

Я при подготовке релиза прогоняю код через ReSharper и PVS-Studio (долго откладывал, но множество статей на Хабре заставили-таки попробовать). Разработчики обоих продуктов предоставляют бесплатные лицензии для проектов с открытым исходным кодом.

Кроме того, у JetBrains есть бесплатная CLI-утилита InspectCode, так что вы можете запускать статический анализ в рамках CI-билдов. PVS-Studio тоже можно запускать через командную строку, но там это делается несколько сложнее.

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

Есть и встроенные диагностики в IDE, которой вы пользуетесь. Что-то можно настроить в файле csproj.

Оптимизация

Как сказал уважаемый Дональд Кнут:

premature optimization is the root of all evil

(преждевременная оптимизация – корень всех зол)

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

Кроме того, вот тут высказана разумная мысль:

There are obvious optimizations (like not doing string concatenation inside a tight loop) but anything that isn't a trivially clear optimization should be avoided until it can be measured.

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

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

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

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

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

Ещё один продукт JetBrains – dotMemory – подскажет, что происходит с памятью. Например, то же самое чтение MIDI-файла сейчас выглядит с точки зрения динамики потребления памяти вот так:

Количество выделенной памяти отправилось в стратосферу
Количество выделенной памяти отправилось в стратосферу

Это чтение очень большого файла в объект типа MidiFile и итерирование по MIDI-событиям в нём. Наткнувшись на вот этот вопрос на Stack Overflow, захотелось создать API для потокового чтения. Разница между двумя подходами, как при чтении XML-документа с помощью XDocument и XmlReader. Так что я сделал метод MidiFile.ReadLazy (код в ещё неопубликованной версии), который возвращает MidiTokensReader, у которого можно последовательно вызывать ReadToken. Картина по расходу памяти при итерировании по событиям этим методом такая:

Дольше, но памяти точно хватит
Дольше, но памяти точно хватит

Т.е. константное потребление памяти, которое на тестовом файле на два порядка меньше, чем при чтении всего файла в память.

Целевые платформы

Сразу стоит подумать о том, какие целевые платформы вы хотите поддерживать, и указать соответствующие TFM в файле csproj. Например, в DryWetMIDI указаны такие:

<TargetFrameworks>netstandard2.0;net45</TargetFrameworks>

Это значит, что библиотека может быть использована в проектах на платформах .NET Standard 2.0+ и .NET Framework 4.5+. Сразу замечу, что .NET Framework 4.5 в наши дни это весьма старая версия фреймворка. Почему бы не оставить только поддержку .NET Standard 2.0, тем самым отказавшись от устаревших платформ?

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

Любопытно, но начинал я проект на .NET Framework 4.6.1. Однако в некоторый момент ко мне обратился человек с вопросом по использованию библиотеки в Unity 2017.x посредством файлов с исходным кодом (их можно подложить в папку Assets проекта, и библиотека станет “видна” для Unity). Оказалось, что в Unity тех времён поддерживался фреймворк за номером 4.5 (вот тут в таблице можно увидеть, что самая первая версия .NET Standard стала поддерживаться лишь в версии 2018.1), поэтому я перевёл библиотеку на него. Кроме того, я ограничил версию C# – раньше использовалась последняя, я выставил 6-ую (Unity не поддерживал более новые редакции языка):

<LangVersion>6</LangVersion>

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

Разумеется, мало смысла бросаться в крайности и поддерживать совсем уж древние версии .NET. Возможно, начиная проект сейчас, будет хорошим решением выбрать в качестве TFM netstandard2.1 или даже net5.0 (но будьте внимательны, .NET 5 не является long-term support версией, см. .NET and .NET Core Support Policy).

// TODO

Временами при реализации нового API я откладываю несущественные вещи на потом (например, проверку аргументов на null в публичных методах). Дабы не повышать давление в кровеносных сосудах мозга перед релизом, такие места помечаются комментарием // TODO. Мысль вроде “я к этому обязательно вернусь, но прямо сейчас мне важна верхнеуровневая структура шагов” в пылу написания некоего алгоритма – ещё один повод оставить комментарий на будущее.

Другой причиной плодить технический долг является желание поскорее поставить новую функцию пользователям. Функция работает верно, решает возложенные на неё задачи, но делает это не так эффективно, как хотелось бы (см. подраздел Оптимизация выше). И может быть принято решение улучшить реализацию в будущих версиях, а в ближайшей уже дать возможность пользоваться новым API. Комментарий // TODO здесь также уместен. В DryWetMIDI есть пример: в версии 6.0.0 в заметках к выпуску было явно сказано:

Also HighPrecisionTickGenerator implemented for macOS too so you can now use Playback with default settings on that platform. Its implementation for macOS is not good (in terms of performance) for now but will be optimized for the next release of the library.

(Также реализован HighPrecisionTickGenerator для macOS, так что теперь возможно использовать Playback с настройками по умолчанию на этой платформе. Реализация не самая хорошая (в терминах производительности), но она будет оптимизирована к следующему релизу.)

А следующая версия (6.0.1) всё исправила, как и было обещано.

Любая популярная IDE умеет показывать список всех специальных комментариев (коим является и // TODO) в коде. В VS это окно Task List, в Rider – TODO.

CI и автоматизация

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

In software engineering, continuous integration (CI) is the practice of merging all developers' working copies to a shared mainline several times a day.

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

Т.е. это по сути работа в системе контроля версии. Стыдно признаться, но меня это удивило. Я всегда считал CI синонимом автоматических действий по пушу коммита. Оказалось, что CI это сам пуш коммита, а всё остальное – действия, запускаемые непрерывной интеграцией. Я же не один такой?

В качестве механизма автоматизации в рамках CI я использую конвейеры (pipelines, пайплайны) Azure DevOps на агентах Microsoft. Там можно запускать свои задачи на Windows, macOS и Ubuntu. Многие из нас помнят время, когда .NET значило привязку к Windows, но сейчас я считаю необходимым тестировать библиотеку на различных поддерживаемых платформах. Конечно, некоторые части вашего продукта могут не быть кроссплатформенными. Например, работа с MIDI-устройствами требует отдельной реализации под каждую целевую платформу, и в DryWetMIDI сейчас такие реализации сделаны для Windows и macOS. Соответствующий API можно использовать только на этих ОС. Однако весь прочий API опирается на штатные средства .NET и потому проверяется на трёх указанных выше платформах (очень хочется сделать запуск тестов на мобильных системах, но пока с трудом представляется, как это организовать).

Сборка проекта

Первое, что хочется сделать при пуше изменений в основную ветку – выполнить сборку библиотеки. Я имею ввиду не просто dotnet build. В моём проекте конвейер под названием Build library делает следующее:

  1. собирает NuGet-пакет (см. раздел Создание NuGet-пакета);

  2. собирает проект и упаковывает полученные файлы (dll + xml с документацией + нативные файлы) в архивы (их я прикладываю к релизам в GitHub, см. раздел Релизы).

При этом на каждом шаге предварительно выполняются ещё такие шаги:

  1. генерируются ссылки на страницы документации, присутствующие в специальном виде в XML-комментариях (triple slash comments, ///, читайте подробнее в разделе Документация);

  2. генерируются описания атрибутов Obsolete.

Ранее я сказал, что опишу подробнее про генерацию описаний к атрибутам Obsolete. Я сделал простой JSON-файл такого вида:

{
  "OBS19": {
    "Hint": "Use Splitter.SplitByObjects method.",
    "ObsoleteFromVersion": "6.1.1",
    "InLibrary": true
  },
  ...
  "OBS9": {
    "Hint": "Use AddObjects methods from TimedObjectUtilities.",
    "ObsoleteFromVersion": "5.2.0",
    "InLibrary": false,
    "RemovedFromVersion": "6.0.0"
  },
  ...
}

Строка из поля Hint подставляется в описание атрибута, которое в итоге становится таким:

[Obsolete("OBS19: Use Splitter.SplitByObjects method. More info: https://melanchall.github.io/drywetmidi/obsolete/obsolete.html#obs19.")]

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

Остальные поля в JSON-файле влияют на отображение секций на странице https://melanchall.github.io/drywetmidi/obsolete/obsolete.html, где всегда можно увидеть всю историю по устареванию различных функций библиотеки.

Возвращаясь к сборке. Как я и сказал выше, я использую Azure DevOps. Подход к релизам там такой:

  1. есть обычные пайплайны, которые производят какие-то файлы – артефакты;

  2. есть релизные пайплайны, которые берут артефакты и что-то с ними делают (деплоят содержимое сайта, публикуют пакет и т.д.).

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

Запуск тестов

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

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

  1. на .NET Framework (в рамках пайплайна проверяется в 32- и 64-битном варианте);

  2. на .NET Core и .NET (проверяется в Windows и macOS);

  3. на .NET в случае self-contained приложения (проверяется в Windows и macOS).

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

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

Также в DryWetMIDI всегда можно ознакомиться со статусом прохождения тестов – Project health. Публичный статус показывает пользователям вашу заинтересованность в качестве проекта. Эта открытость служит мотивацией как можно скорее решать проблемы и не откладывать их в долгий ящик.

Сборка документации

Подробнее про то, как устроена документация в DryWetMIDI, смотрите в разделе Документация. Здесь же просто упомяну, что ещё одним важным автоматическим действием при влитии изменений является сборка справочных материалов. В DryWetMIDI за это ответственен конвейер Build docs. Он готовит файлы, необходимые для релизного пайплайна Release docs, который выполняет деплой сайта https://melanchall.github.io/drywetmidi.

Выполнение действий по расписанию

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

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

  • Check CS links (в файлах с исходным кодом; речь про такие конструкции в XML-комментариях: <see href="https://www.midi.org/specifications/file-format-specifications/standard-midi-files"/>);

  • Check docs links (в файлах документации);

  • Check README links (в файле README.md).

Кроме того, иногда интересно знать статистику использования вашей библиотеки. Я создал аккаунт в InfluxDB Cloud и с помощью небольших консольных приложений и библиотеки InfluxDB.Client отправляю туда информацию из:

  • GitHub (звёзды, форки и т.д.; у GitHub нет такой статистики);

  • NuGet (статистика по загрузкам доступна только за последние 6 недель, а мне хочется иметь всю);

  • Stack Exchange (складирую вопросы и ответы, содержащие ключевое слово DryWetMIDI).

Соответствующие конвейеры запускаются по расписанию каждый день в полночь.

Ручные действия

Странно видеть подраздел с таким названием в разделе про автоматизацию. Объясню, в чём дело.

Временами приходят обращения от пользователей об ошибках или предложениях новых функций. Исправить проблему и создать новый API – это половина дела. Нужно ещё доставить изменения пользователю. Раньше этот процесс у меня выглядел вот так:

The fix will be in the next release. Or you can take the last version of sources from develop branch and build them to use in your application.

(Исправление будет доступно в следующем релизе. Или же вы можете взять последнюю версию исходного кода из ветки develop и собрать проект из исходников.)

Не очень дружелюбно к пользователю. Но так делают многие разработчики проектов на GitHub, и, думаю, некоторые считают сборку руками самым трушным способом. В корне с этим не согласен. Пользователю должно быть удобно. В итоге я пришёл к решению сделать пайплайн, который бы собирал предрелизную версию библиотеки. Соответствующая пара конвейеров Build library prerelease и Release library prerelease выполняет сборку NuGet-пакета с суффиксом -prereleaseN в имени и публикует его. И теперь изменения доходят до пользователей через такие сообщения:

Changes are available in a prerelease version of the DryWetMIDI on NuGet – 6.1.2-prerelease3.

(Изменения доступны в предрелизной версии DryWetMIDI на NuGet – 6.1.2-prerelease3.)

Так вот, собирать предрелизную версию каждый раз при каких-либо изменениях в коде я не считаю нужным: при каждой сборке увеличивается N в суффиксе, не хочется получать номера версий вроде 6.1.2-prerelease100. Поэтому данный пайплайн я запускаю вручную. Но действия в нём автоматические. Поэтому автоматизация.

Создание NuGet-пакета

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

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

Вы можете заглянуть в файл Melanchall.DryWetMidi.csproj (см. PropertyGroup c атрибутом Label=”Package”) или же обратиться к официальным руководствам Microsoft – Create a NuGet package using MSBuild. Как видите, помимо очевидных свойств вроде названия пакета, версии, описания и иконки можно также указать сведения о репозитории, лицензии, заметках о выпуске. С недавних пор можно запаковать файл README.md и он будет красиво отображаться на nuget.org:

<PackageReadmeFile>README.md</PackageReadmeFile>
…
<None Include="..\README.md" Pack="true" PackagePath="\" />

Сам NuGet-пакет собирается командой dotnet pack. Я в рамках CI-пайплайна ещё проставляю актуальную ветку, из которой собирается пакет.

После сборки будет полезно проверить собранный пакет в утилите NuGetPackageExplorer. Можно не устанавливать себе программу, а воспользоваться онлайн-версией на сайте https://nuget.info. Через меню File есть возможность загрузить полученный при сборке файл nupkg. В левой части сайта вы увидите информацию о пакете, как её будет видеть пользователь. Обратите внимание на секцию Health. Для моего пакета она изначально выглядела так:

Вы тоже это видите?
Вы тоже это видите?

Эти красные значки заставили меня задать себе вопрос: какого чёрта? На всякий случай скажу, что для статьи я сделал этот скриншот из информации о пакете System.Text.Json. И тут может быть второй вопрос: если даже Microsoft не заботится в своём пакете (у которого на момент написания свыше 500 миллионов загрузок) об этих свойствах, нужно ли мне тратить на это время? Для меня ответ очевиден – да. Это опять же мелочи, создающие неряшливость. Поэтому нужно было разобраться.

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

<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>

Во-вторых, добавить элемент для подключения пакета Microsoft.SourceLink.GitHub:

<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

Обратите внимание на то, какой сервис хостинга вашего проекта вы используете. Есть интеграция и для BitBacket и для GitLab, поэтому подключайте соответствующий пакет (Microsoft.SourceLink.YourHostingService).

Теперь, собирая пакет через dotnet pack или же последние версии nuget.exe, вы можете быть уверены, что вместе с nupkg будет также создан файл snupkg, а первый красный значок на https://nuget.info сменится на зелёный.

В той же статье про Source Link вы увидите такую рекомендацию:

✔️ CONSIDER enabling deterministic builds.

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

<PropertyGroup Condition="'$(TF_BUILD)' == 'true'">
  <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>

Что касается последнего пункта – Compiler Flags – я не особенно понимаю, что это значит, кроме того, что библиотека должна быть собрана с помощью как минимум .NET 5.0.300 или MSBuild 16.10. Просто собирайте пакет последней версией .NET.

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

Так-то лучше
Так-то лучше

Имейте в виду, что удалить пакет из https://www.nuget.org можно (хотя заявляется, что нельзя), но по запросу в техническую поддержку. Есть также вариант просто скрыть (unlist) его. Выполняя unlisting, вы скрываете выбранную версию пакета из выдачи. У тех, кто её уже установил, ничего не сломается, пакет будет доступен. Но при поиске пакета, например в Visual Studio, версия отображаться не будет.

После успешного выполнения команды dotnet nuget push пакет не сразу будет доступен для поиска и установки, сперва он проиндексируется. Я в релизном пайплайне использую простой PowerShell-скрипт, который проверяет, стал ли доступен пакет, и только если да, пайплайн завершается:

while ($true)
{
    Write-Host "Checking if version $(Version) is indexed..."

    $response = Invoke-RestMethod -Uri "https://azuresearch-ussc.nuget.org/query?q=PackageId:$(PackageId)&prerelease=$(CheckPrerelease)"
    $latestVersion = $response.data.version
    if ($latestVersion -eq "$(Version)")
    {
        break;
    }

    Start-Sleep -Seconds 10
}

Write-Host "Version $(Version) is indexed."

Да, знаю, использовать прямой URL https://azuresearch-ussc.nuget.org/query неверно, нужно получать актуальный адрес запросом в https://api.nuget.org/v3/index.json, смотря вот сюда в ответе:

{
  "@id": "https://azuresearch-ussc.nuget.org/query",
  "@type": "SearchQueryService",
  "comment": "Query endpoint of NuGet Search service (secondary)"
}

Может же мне иногда быть лень?

Ещё один немаловажный момент – это нативные сборки. Если некоторый ваш API является платформозависимым, необходимо создать нативные файлы, к функциям в которых через атрибут DllImport будет обращаться ваш API. Эти файлы нужно упаковать в NuGet-пакет вместе с основными бинарниками. Я достаточно подробно описал процедуру в статье Создание пакета NuGet для библиотеки с платформозависимым API.

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

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

В первую очередь стоит озаботиться документированием API, создав полный справочник по нему. В .NET существует концепция XML-документации, известная также, как triple slash comments, которая позволяет вам размещать справку по методу, классу и т.д. прямо рядом с ними:

/// <summary>
///  This class performs an important function.
/// </summary>
public class MyClass {}

В статье Recommended XML tags for C# documentation comments вы можете узнать о рекомендуемых тегах для XML-документации. Советую внимательно изучить статью, дабы понимать, когда стоит применять тот или иной элемент.

Получаемые описания могут быть довольно большими. Вот пример из DryWetMIDI:

/// <summary>
/// Represents a MIDI file.
/// </summary>
/// <remarks>
/// <para>An instance of <see cref="MidiFile"/> can be obtained via one of <c>Read</c>
/// (<see cref="Read(string, ReadingSettings)"/> or <see cref="Read(Stream, ReadingSettings)"/>)
/// static methods or via constructor which allows to create a MIDI file from scratch.</para>
/// <para>Content of MIDI file available via <see cref="Chunks"/> property which contains instances of
/// following chunk classes (derived from <see cref="MidiChunk"/>):</para>
/// <list type="bullet">
/// <item>
/// <description><see cref="TrackChunk"/></description>
/// </item>
/// <item>
/// <description><see cref="UnknownChunk"/></description>
/// </item>
/// <item>
/// <description>Any of the types specified by <see cref="ReadingSettings.CustomChunkTypes"/> property of the
/// <see cref="ReadingSettings"/> that was used to read the file</description>
/// </item>
/// </list>
/// <para>To save MIDI data to file on disk or to stream use appropriate <c>Write</c> method
/// (<see cref="Write(string, bool, MidiFileFormat, WritingSettings)"/> or
/// <see cref="Write(Stream, MidiFileFormat, WritingSettings)"/>).</para>
/// <para>
/// See <see href="https://www.midi.org/specifications/file-format-specifications/standard-midi-files"/> for detailed MIDI file specification.
/// </para>
/// </remarks>
/// <seealso cref="ReadingSettings"/>
/// <seealso cref="WritingSettings"/>
/// <seealso cref="MidiChunk"/>
/// <seealso cref="MidiEvent"/>
/// <seealso cref="Interaction"/>
public sealed class MidiFile

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

Хорошо, добавили документирующие комментарии, но для чего? Прежде всего, не забудьте включить создание XML-файла с документацией. Для этого добавьте в csproj-файл такой элемент:

<GenerateDocumentationFile>true</GenerateDocumentationFile>

Теперь при сборке проекта вместе с основным бинарником библиотеки будет создаваться XML-файл, содержащий информацию о всех справочных комментариях. Распространяя библиотеку вместе с этим файлом вы даёте пользователям возможность видеть подсказки в IDE при наведении мышкой на тот или иной метод, класс и т.д., или при наборе кода. И это первый важный момент – контекстная справка по вашему API.

Второй же – генерация справочника по triple slash комментариям. Согласитесь, выглядит весьма очевидным создать, например, набор HTML-страниц с кросс-ссылками по вашему API? Или же файл справки. И это возможно. Есть различные инструменты для этих нужд, например Sandcastle Help File Builder. Я в DryWetMIDI выбрал DocFX.

DocFX позволяет создавать сайт с документацией по вашему проекту. Причём помимо автогенерации справочника по API можно вручную создать отдельные страницы в формате Markdown. Сайт со справкой по DryWetMIDI содержит такие разделы:

  • Overview – дублирует содержимое файла README.md на GitHub;

  • API – справочник по API библиотеки;

  • Obsolete – список всего устаревшего API;

  • Articles – раздел со статьями, рассказывающими подробнее про некоторые концепции библиотеки;

  • GitHub – ссылка на GitHub проекта.

У DocFX на официальном сайте есть все необходимые инструкции. Ключевым элементом является файл docfx.json, описывающий, что должна делать утилита. Здесь можно подсмотреть его содержимое в DryWetMIDI. Кроме того, DocFX выкладывается в Chocolatey, поэтому настроить создание документации в рамках CI-сборок не составит труда.

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

В GitHub есть возможность бесплатно получить сайт для своего проекта – GitHub Pages. По умолчанию адрес будет https://<user>.github.io/<project>, но можно настроить и свой домен. Я воспользовался самым простым вариантом:

  1. переходим в Settings проекта;

  2. переходим в раздел Pages;

  3. выбираем Source = Deploy from a branch;

  4. выбираем ветку и путь внутри ветки.

У меня получилась такая конфигурация:

Сайт будет деплоиться прямо из ветки
Сайт будет деплоиться прямо из ветки

И теперь пуш коммита с файлами документации в ветку gh-pages автоматически приводит к деплою сайта. Т.е. один пайплайн автоматически собирает справку, а второй, релизный, при запуске берёт файлы, полученные первым, и пушит их в ветку gh-pages.

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

Варианты поставок

Большинство людей будут использовать вашу библиотеку через NuGet-пакет. Это основной способ поставки вашего продукта.

Но с течением жизни вашего проекта к вам могут начать обращаться пользователи с вопросами по интеграции в определённой среде. Например, довольно скоро после публикации первого релиза DryWetMIDI мне стали писать касательно использования библиотеки в проектах игр на движке Unity. Изначально люди просто брали собранные бинарные файлы (которые я распространяю вместе с релизами на GitHub) и подкладывали их в папку Assets в директории с проектом. Кому интересно, процедура описана в разделе Manual installation в статье Using in Unity.

Однако есть Unity Asset Store, магазин ассетов Unity. Ассетами считаются и наборы моделей, и текстуры, и аудиофайлы, и программные библиотеки. Поставка DryWetMIDI в виде ассета значительно бы упростила процесс интеграции в проект игры. Но, на мой взгляд, публикация ассета не самый простой процесс, что заставляло меня долгое время откладывать сиё мероприятие. Одна из сложностей – необходимость ручных действий. Пакет с ассетом загружается в аккаунт издателя (publisher account) из редактора Unity через другой ассет – Asset Store Publishing Tools. API для программной отправки файла в аккаунт отсутствует.

Но в 2022 году я взял себя в руки и создал-таки ассет – DryWetMIDI (к слову, процесс оказался не таким уж ужасным, но довольно долгим при первой публикации). И теперь установка библиотеки в проект Unity занимает пару кликов.

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

Ещё одной любопытной штукой является UWP-приложение. Достаточно давно мне завели issue на GitHub, в котором пользователь указал на неуспешную валидацию своего проекта. Причиной была моя библиотека. А именно то, что внутри неё используются вызовы в неуправляемый код через атрибуты DllImport. Дело в том, что UWP поддерживает ограниченный набор Windows API, и лучше попросту считать, что вызовы этого API запрещены (на самом деле варианты есть). В том обращении я поначалу был полон готовности сделать отдельный вариант DryWetMIDI, заменив реализацию проблемного API с использованием штатных средств UWP. Тем более, что были и другие обращения, связанные с Universal Windows Platform.

В итоге отдельную версию для UWP я так и не сделал. Тому есть несколько причин. Во-первых, некоторые статьи заявляют, что Microsoft официально признал платформу устаревшей, что в принципе подтверждается официальными инструкциями портирования проектов с UWP на Windows App SDK. В целом, пациент скорее мёртв. А во-вторых, необходимые мне API работают странно, меня такое не устраивает. В итоге, работы на этом направлении я прекратил и возобновлять не планирую.

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

Один из пользователей попросил версию библиотеки вообще без нативного кода (кстати, для использования в UWP-приложении). Я посчитал, что это может быть хорошей идеей. Если человеку не нужен API для работы с MIDI-устройствами, или он даже мешает ему по каким-то причинам, то теперь можно загрузить пакет Melanchall.DryWetMidi.Nativeless из NuGet или же DryWetMIDI.Nativeless из Unity Asset Store.

Релизы

Версионирование

Прежде всего стоит продумать нумерацию версий библиотеки. Одним из подходов является семантическое версионирование (semantic versioning). Основные его постулаты таковы:

Учитывая номер версии МАЖОРНАЯ.МИНОРНАЯ.ПАТЧ, следует увеличивать:

1. МАЖОРНУЮ версию, когда сделаны обратно несовместимые изменения API.

2. МИНОРНУЮ версию, когда вы добавляете новую функциональность, не нарушая обратной совместимости.

3. ПАТЧ-версию, когда вы делаете обратно совместимые исправления.

Дополнительные обозначения для предрелизных и билд-метаданных возможны как дополнения к МАЖОРНАЯ.МИНОРНАЯ.ПАТЧ формату.

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

Мажорная версия ноль (0.y.z) предназначена для начальной разработки. Всё может измениться в любой момент. Публичный API не должен рассматриваться как стабильный.

Интересно данное замечание тем, что я видел просто огромную массу популярных библиотек, существующих много лет, и имеющих по сей день мажорную версию 0. Далеко ходить за примером не нужно – BenchmarkDotNet. Искренне не понимаю, в чём смысл этого. Скромность? Очевидно, что API таких проектов давно стабилен.

И хотя концепция семантического версионирования мне понятна и приятна, я в DryWetMIDI опираюсь на неё с некоторыми отклонениями:

  1. Мажорная версия увеличивается, когда я считаю изменения “большими” или “важными” (v3.0.0, v4.0.0). При этом ломающие изменения гарантированно увеличивают число (v2.0.0, v5.0.0).

  2. Минорная версия увеличивается при расширении API (v1.2.0, v6.1.0).

  3. Патч-версия увеличивается при незначительных изменениях или исправлениях (v6.0.1, v5.1.1).

Есть, конечно, и другой подход – почти всегда увеличивать мажорную версию. Пример всем известен – Google Chrome, версия которого на момент написания статьи начинается с числа 106.

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

Публикация

Определившись с версионированием, можно приступить к главному. А именно разработке процесса публикации релиза. Ниже список шагов, который выполняется в DryWetMIDI, из которого вы можете почерпнуть что-то полезное (или же сказать, насколько он ущербный).

  1. Пройтись по // TODO комментариям, найти важные для подготавливаемого релиза и исправить соответствующий код. Я редко обрабатываю все такие комментарии, ибо не всегда соотношение пользы исправления к затраченному времени сильно больше нуля. Многие из таких пометок носят смысл “неплохо бы” (можете вспомнить о // TODO на своей работе и как часто вы проводите ревизию таких приветов из прошлого).

  2. Создать XML-документацию для нового API, скорректировать ошибки, связанные с этой документацией (например, в методе удалился параметр, а в документирующих комментариях остался соответствующий <param>). Разумеется, делается это не хождением по файлам, а по наличию предупреждений и ошибок в IDE или статическом анализаторе.

  3. Прогнать проект через статические анализаторы. Лично у меня редко находится что-то полезное, но пару раз находились неприятные ошибки (например, однажды в PVS-Studio совершенно по делу сработала диагностика V3066). В любом случае must have перед отправкой версии пользователям.

  4. Влить ветку develop (основная ветка для разработки) в master.

  5. Дождаться выполнения всех автоматических пайплайнов на ветке master (см. раздел CI и автоматизация). Если будут падения в тестах, придётся чинить (или перезапускать, некоторые тесты нестабильны (да, с этим нужно бороться)).

  6. Запустить пайплайн деплоя документации.

  7. Создать драфт (черновик) релиза на GitHub с ветки master. Данный шаг выполняется после предыдущего для того, чтобы на руках уже были ссылки в документацию по новому API. Ничего не могу с собой поделать, трачу время при подготовке заметок о выпуске на добавление этих ссылок. В целом подход к release notes у меня такой – это должен быть мини-рассказ о том, что содержит новая версия, ссылки для ознакомления с более подробной информацией обязательны. Если есть какие-то значительные изменения (как с точки зрения API, так и с точки зрения покрытия пользовательских сценариев), я пишу об этом отдельно в самом начале. Пример: версия 6.0.0, привнёсшая работу с MIDI-устройствами на macOS. Также к релизу я прикрепляю архивы с бинарниками библиотеки.

  8. Запустить конвейер публикации NuGet-пакета (как полной, так и nativeless версии).

  9. Опубликовать созданный ранее черновик релиза на GitHub.

  10. Влить ветку master в develop, дабы новый тег (созданный на предыдущем шаге) приехал в основную ветку.

  11. Обновить бинарники библиотеки в проектах Unity, предназначенных для отправки пакета в Unity Publisher Account, скорректировать информацию в метаданных ассетов и отправить их на ревью команде Unity. Я всегда ставлю галку Auto-publish, так что в случае одобрения ассета он автоматически публикуется.

Но на этом работа с релизом не заканчивается. Почему бы дополнительно не донести информацию о новой версии на профильных ресурсах? Например, я сообщаю о релизах на форуме midi.org и пишу о новых версиях ассетов в отдельном треде на форуме Unity.

Помните, я писал о мониторинге вопросов на Stack Overflow по тегу midi на предмет возможности дать ответ с использованием своей библиотеки? А что, если ответ на некий вопрос вами уже давно дан, а пример кода в нём содержит API, изменённый в новой версии, или вовсе удалённый? Человек, находящийся в поисках решения своей проблемы, попадает на ваш ответ, пробует применить описанный там подход, и видит ошибки компиляции на свежей версии библиотеки. Не годится. Поэтому после публикации релиза я заглядываю в данные мной когда-то ответы на разных площадках и корректирую их при необходимости.

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

Источник: https://habr.com/ru/post/691128/


Интересные статьи

Интересные статьи

Друзья, в этой части мы создадим врагов, реализуем поочередную систему ходов, механику способностей и напишем простой ИИ вражеским юнитам.Перед прочтением этой части ознакомьтесь с предыдущей.
Часть 1. ConfigurationManager Часть 2. WebApplicationBuilderВ предыдущем посте я сравнивал новый WebApplication с универсальным хостом. В этом посте я рассмотрю код, лежащий в основе WebApplicationBui...
Шлюзы в InterSystems IRIS это механизм взаимодействия между ядром InterSystems IRIS и прикладным кодом на языках Java/.Net. С помощью шлюзов вы можете работать как с объектами Java/.NET и...
Вам приходилось сталкиваться с ситуацией, когда сайт или портал Битрикс24 недоступен, потому что на диске неожиданно закончилось место? Да, последний бэкап съел все место на диске в самый неподходящий...
[Первая и вторая части туториала] Размещаем на поле башни. Целимся во врагов при помощи физики. Отслеживаем их, пока это возможно. Стреляем в них лазерным лучом. Это третья часть серии...