Как вызвать C# Native AOT DLL из C/C++
· Го Комура · C#, .NET, Native AOT, C++, Разработка Windows, Нативная интеграция
В прошлой статье Почему при использовании нативных DLL на C++ из C# стоит делать обёртку на C++/CLI мы разобрали границу для случая, когда C++ вызывается из C#. На этот раз развернём направление в обратную сторону — поговорим о вызове C# из C/C++.
Бывают ситуации, когда хочется вызвать написанную на C# логику из существующего приложения на C/C++, но направление P/Invoke обратное, а тащить ради этого C++/CLI или COM кажется избыточным. Особенно это актуально, когда нативное приложение хочется оставить как есть, перенеся в C# только такие части, как логика принятия решений, обработка строк, интерпретация конфигурации или правила вычислений.
Мост можно построить и через COM, но на этот раз мы возьмём подход более in-process, более похожий на обычную DLL. В .NET Native AOT библиотеку классов можно опубликовать как нативную разделяемую библиотеку, а методы с атрибутом UnmanagedCallersOnly — раскрыть как точки входа C. Иными словами, C# можно использовать как «нативную DLL на вызываемой стороне».
Впрочем, не всё можно перенести через границу как есть. Стоит допустить, чтобы string, List<T>, исключения или владение объектом просочились через границу, — и атмосфера портится очень быстро. В этой статье на минимальном примере Windows + C++ мы разберём, в каких случаях эта конфигурация действительно раскрывается, и какая форма API держится наиболее устойчиво. На Linux / macOS подход почти такой же, но примеры кода рассчитаны на DLL под Windows.
Код, который встречается в этой статье, опубликован на GitHub как полный набор сборно-запускаемых примеров (библиотека C#, публикуемая через Native AOT, пример вызова из C++ и модульные тесты).
csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)
Содержание
- Сначала вывод (в двух словах)
- Как выбирать способ моста
- Схема архитектуры
- Минимальная конфигурация
- 4.1. Проект C#
- 4.2. Экспортируемый код C#
- 4.3. Команда публикации
- 4.4. Пример вызова со стороны C++
- Формы API, которые не ломаются
- 5.1. Придерживаемся C ABI
- 5.2. Обрабатываем строки как указатель + длина + ёмкость буфера
- 5.3. Не даём исключениям пересекать границу
- 5.4. Фиксируем соглашение о вызовах
- 5.5. Экспортируемые методы делаем тонкими, а основное тело выносим отдельно
- Подходящие случаи
- Случаи, для которых это всё же не подходит
- Подводные камни
- Итог
- Список источников
1. Сначала вывод (в двух словах)
- Если хочется вызывать обработку C# из C/C++ in-process, Native AOT +
UnmanagedCallersOnly— весьма достойный кандидат. - Однако экспортируется строго точка входа C-функции. Это не мир, где можно напрямую выставлять
stringилиList<T>. - На практике устойчивее свести всё к плоскому C API вроде
create/destroy/operate, явно обозначив управление жизненным циклом и коды ошибок. - Если хочется естественно работать с классами и STL C++, лучше подходит C++/CLI; если нужна регистрация, автоматизация или пересечение процессов — лучше COM.
Иначе говоря: C# можно использовать как содержимое нативной DLL, но саму поверхность границы нужно проектировать как C ABI, а не как .NET. Если принять эту сделку, инструмент оказывается по-настоящему интересным.
2. Как выбирать способ моста
| Что хочется сделать | Сильный кандидат | Почему |
|---|---|---|
| Вызвать набор C-функций из C# | P/Invoke | Направление естественное, самый органичный вариант |
| Естественно работать с библиотекой C++ из C# | C++/CLI | Типы C++, владение, исключения, std::wstring и подобное легко поглощаются на стороне C++ |
| Пересечь границу 32-бит / 64-бит или границу процессов | COM / IPC | Одной in-process DLL этого не пересечь |
| Вызвать логику C# из C/C++ как нативную DLL | Native AOT + UnmanagedCallersOnly |
Можно самостоятельно экспортировать точки входа C |
Эта конфигурация раскрывается в ситуациях, когда нативная сторона играет главную роль, а C# вызывается как компонент. Это направление ровно противоположно P/Invoke и C++/CLI.
3. Схема архитектуры
flowchart LR
Cpp["Приложение C / C++"] -->|вызовы функций cdecl| Dll["DLL на C#, опубликованная через Native AOT"]
Dll --> Exports["Экспорты с атрибутом UnmanagedCallersOnly"]
Exports --> Core["Бизнес-логика на C#"]
Exports --> Store["Таблица дескрипторов / управление состоянием"]
Картина выглядит просто. Важно свести поверхность границы к C-функциям. Внутренняя реализация на стороне C# может быть какой угодно — классы, коллекции, LINQ, — но поверхность, обращённая наружу, остаётся плоской.
4. Минимальная конфигурация
Здесь мы возьмём минимальный пример: сторона C++ создаёт «аккумулятор», складывает в него значения и в конце получает сумму. На практике это может быть механизм принятия решений, интерпретатор конфигурации или простой парсер. Считайте это паттерном, при котором нативная сторона держит дескриптор и по очереди вызывает функции операций.
4.1. Проект C#
Сначала подготовим библиотеку классов.
<!-- NativeAotSample.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
Здесь важны два момента.
- Включить публикацию Native AOT
- Разрешить
unsafe, поскольку используются указательные параметры
Примеры в этой статье рассчитаны на net8.0, но сам подход одинаков и для .NET 9 / 10.
4.2. Экспортируемый код C#
Методы с атрибутом UnmanagedCallersOnly становятся входными точками, видимыми со стороны нативного кода. Здесь мы выдаём дескрипторы как целые числа, а внутреннее состояние храним в словаре на стороне C#.
// NativeExports.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace KomuraSoft.NativeAotSample;
internal static class NativeStatus
{
public const int Ok = 0;
public const int InvalidArgument = -1;
public const int InvalidHandle = -2;
public const int UnexpectedError = -3;
}
internal sealed class Accumulator
{
public long Total { get; private set; }
public void Add(int value)
{
Total += value;
}
}
internal static class AccumulatorStore
{
private static readonly object s_gate = new();
private static readonly Dictionary<nint, Accumulator> s_instances = new();
private static long s_nextHandle = 0;
public static int Create(out nint handle)
{
try
{
var instance = new Accumulator();
handle = (nint)System.Threading.Interlocked.Increment(ref s_nextHandle);
lock (s_gate)
{
s_instances.Add(handle, instance);
}
return NativeStatus.Ok;
}
catch
{
handle = 0;
return NativeStatus.UnexpectedError;
}
}
public static int Add(nint handle, int value)
{
try
{
lock (s_gate)
{
if (!s_instances.TryGetValue(handle, out var instance))
{
return NativeStatus.InvalidHandle;
}
instance.Add(value);
return NativeStatus.Ok;
}
}
catch
{
return NativeStatus.UnexpectedError;
}
}
public static int GetTotal(nint handle, out long total)
{
try
{
lock (s_gate)
{
if (!s_instances.TryGetValue(handle, out var instance))
{
total = 0;
return NativeStatus.InvalidHandle;
}
total = instance.Total;
return NativeStatus.Ok;
}
}
catch
{
total = 0;
return NativeStatus.UnexpectedError;
}
}
public static int Destroy(nint handle)
{
try
{
lock (s_gate)
{
return s_instances.Remove(handle)
? NativeStatus.Ok
: NativeStatus.InvalidHandle;
}
}
catch
{
return NativeStatus.UnexpectedError;
}
}
}
public static unsafe class NativeExports
{
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_create",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorCreate(nint* outHandle)
{
if (outHandle == null)
{
return NativeStatus.InvalidArgument;
}
var status = AccumulatorStore.Create(out var handle);
*outHandle = handle;
return status;
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_add",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorAdd(nint handle, int value)
{
return AccumulatorStore.Add(handle, value);
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_get_total",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorGetTotal(nint handle, long* outTotal)
{
if (outTotal == null)
{
return NativeStatus.InvalidArgument;
}
var status = AccumulatorStore.GetTotal(handle, out var total);
*outTotal = total;
return status;
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_destroy",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorDestroy(nint handle)
{
return AccumulatorStore.Destroy(handle);
}
}
То, что здесь происходит, довольно незатейливо.
- Нативной стороне показывается только дескриптор
intptr_t - Само состояние хранится на стороне C#
- create / add / get / destroy разбиты на плоские функции
- Возвращаемое значение — код ошибки, выходные значения возвращаются через указательные параметры
При такой форме, даже если позже заменить внутреннюю реализацию на стороне C#, ABI на стороне C остаётся весьма стабильным.
4.3. Команда публикации
Публикуем как разделяемую библиотеку.
dotnet publish -r win-x64 -c Release /p:NativeLib=Shared
В результате в bin/Release/net8.0/win-x64/publish/ появится нативная DLL. На Windows это .dll, на Linux — .so, на macOS — .dylib.
Важно публиковать отдельно для каждого RID. Собранное для win-x64 нельзя использовать как рассчитанное на win-arm64, а разрядность вызывающей стороны и DLL должны совпадать.
4.4. Пример вызова со стороны C++
Здесь мы на время отставим в сторону import lib и вызовем библиотеку напрямую через LoadLibrary / GetProcAddress. В такой форме хорошо видно, что именно экспортируется и с какой сигнатурой это нужно принимать.
/* native_api.h */
#pragma once
#include <stdint.h>
enum km_status
{
KM_STATUS_OK = 0,
KM_STATUS_INVALID_ARGUMENT = -1,
KM_STATUS_INVALID_HANDLE = -2,
KM_STATUS_UNEXPECTED_ERROR = -3
};
typedef int (__cdecl *km_accumulator_create_fn)(intptr_t* out_handle);
typedef int (__cdecl *km_accumulator_add_fn)(intptr_t handle, int value);
typedef int (__cdecl *km_accumulator_get_total_fn)(intptr_t handle, int64_t* out_total);
typedef int (__cdecl *km_accumulator_destroy_fn)(intptr_t handle);
// main.cpp
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <windows.h>
#include "native_api.h"
template <typename T>
T LoadSymbol(HMODULE module, const char* name)
{
FARPROC proc = ::GetProcAddress(module, name);
if (proc == nullptr)
{
std::cerr << "GetProcAddress failed: " << name << '\n';
std::exit(EXIT_FAILURE);
}
return reinterpret_cast<T>(proc);
}
int main()
{
HMODULE module = ::LoadLibraryW(L"NativeAotSample.dll");
if (module == nullptr)
{
std::cerr << "LoadLibraryW failed" << '\n';
return EXIT_FAILURE;
}
auto create = LoadSymbol<km_accumulator_create_fn>(module, "km_accumulator_create");
auto add = LoadSymbol<km_accumulator_add_fn>(module, "km_accumulator_add");
auto getTotal = LoadSymbol<km_accumulator_get_total_fn>(module, "km_accumulator_get_total");
auto destroy = LoadSymbol<km_accumulator_destroy_fn>(module, "km_accumulator_destroy");
intptr_t handle = 0;
if (create(&handle) != KM_STATUS_OK)
{
std::cerr << "create failed" << '\n';
return EXIT_FAILURE;
}
if (add(handle, 10) != KM_STATUS_OK)
{
std::cerr << "add(10) failed" << '\n';
return EXIT_FAILURE;
}
if (add(handle, 20) != KM_STATUS_OK)
{
std::cerr << "add(20) failed" << '\n';
return EXIT_FAILURE;
}
std::int64_t total = 0;
if (getTotal(handle, &total) != KM_STATUS_OK)
{
std::cerr << "get_total failed" << '\n';
return EXIT_FAILURE;
}
std::cout << "total = " << total << '\n';
if (destroy(handle) != KM_STATUS_OK)
{
std::cerr << "destroy failed" << '\n';
return EXIT_FAILURE;
}
handle = 0;
// Разделяемую библиотеку Native AOT не используем с расчётом на выгрузку.
// FreeLibrary(module);
return EXIT_SUCCESS;
}
В этом примере со стороны C++ видно только «C API, вызываемый через указатели на функции». То, что внутри написано на C#, практически не нужно держать в голове.
5. Формы API, которые не ломаются
Возможность экспортировать через Native AOT — вещь интересная, но на практике важнее то, чего вы не экспортируете.
5.1. Придерживаемся C ABI
Типы, выставляемые на границе, спокойнее с самого начала свести примерно к следующему.
- Базовые типы вроде
int32_t/int64_t/double - Структуры с фиксированным layout
- Дескрипторы, эквивалентные
intptr_t/void* uint8_t*вместе с длиной
И наоборот, вот то, что с самого начала не стоит выпускать за границу.
stringobjectList<T>TaskSpan<T>- классы C++,
std::vector,std::wstring
Попытка перенести всё это через границу как есть быстро замутняет поверхность границы. Важно не давать особенностям C# просачиваться в C++ и не давать слишком многим особенностям C++ просачиваться в C#.
5.2. Обрабатываем строки как указатель + длина + ёмкость буфера
Как только захочется обмениваться строками, сразу тянет выставить string напрямую, но здесь стоит сдержаться. На границе библиотеки понятнее свести это, например, к такой форме.
int km_parse_utf8(const uint8_t* text, int32_t text_len, int32_t* out_value);
int km_format_utf8(int32_t value, uint8_t* buffer, int32_t buffer_len, int32_t* out_written);
Суть в том, чтобы заранее решить кодировку, длину и кто выделяет буфер. Поскольку это Windows, есть вариант склониться к UTF-16, но если в поле зрения другие языки, обычно удобнее UTF-8.
5.3. Не даём исключениям пересекать границу
Граница нативной функции — не самая дружелюбная среда для выражения исключений. По меньшей мере безопаснее не проектировать так, чтобы managed-исключения напрямую просачивались к вызывающей стороне.
На практике удобно поступать так:
- возвращаемое значение — код состояния;
- фактические данные возвращаются через выходные буферы или указательные параметры;
- при необходимости дополнительные сведения можно получить через функцию в стиле
get_last_error.
Ничего эффектного в этом нет, но такое неброское проектирование окупается позже. Иначе говоря, не стоит внезапно устраивать борьбу прямо на границе.
5.4. Фиксируем соглашение о вызовах
В примере явно указан CallConvCdecl. Если его опустить, будет использовано соглашение о вызовах по умолчанию для платформы, но если вы хотите зафиксировать заголовки и типы указателей на функции, явно указать соглашение самому — меньше риска аварий.
Особенно если есть вероятность иметь дело с x86, оставлять этот момент неопределённым потом больно аукнется. Даже если на x64 это редко проявляется, правило лучше установить с самого начала.
5.5. Экспортируемые методы делаем тонкими, а основное тело выносим отдельно
Методы с атрибутом UnmanagedCallersOnly не рассчитаны на прямой вызов из обычного managed-кода. Поэтому если начать писать в них всю бизнес-логику целиком, тестировать становится тяжело.
В примере тоже управление реальным объектом вынесено в AccumulatorStore, а экспортируемый NativeExports — лишь тонкая входная точка. Это довольно важно.
- Экспортируемые методы: приёмная стойка ABI
- Внутренние классы: обычная логика на C#
При таком разделении труда границу с C++ и основной код C# можно обдумывать раздельно.
6. Подходящие случаи
Эта конфигурация действительно хорошо ложится в такие сценарии.
- Хочется оставить существующее приложение на C/C++ как есть, перенеся в C# только часть бизнес-логики
- Не хочется, чтобы предварительная установка среды выполнения .NET была обязательным условием распространения
- Можно удержать небольшую поверхность экспортируемых функций
- В перспективе может понадобиться вызывать тот же C API и из других языков, например Rust или Go
Особенно хорошо это сочетается со структурой, при которой нативное приложение остаётся неизменным, а на C# пишется только легко заменяемый слой логики: UI и управление оборудованием остаются на C++, а решения, вычисления и правила конфигурации переезжают на C#.
7. Случаи, для которых это всё же не подходит
Разумеется, это не панацея. Есть случаи, для которых это явно не подходит.
- Хочется напрямую работать с классами C++,
std::vectorили исключениями- В этом случае естественнее C++/CLI или обёртка на нативной стороне.
- Хочется войти в мир регистрации COM, автоматизации VBA / Office, расширений Explorer
- Здесь лучше думать в терминах COM.
- Хочется перебросить мост между 32-бит / 64-бит или пересечь границу процессов
- Не in-process DLL, а COM / IPC / отдельный процесс — более логичный путь.
- Хочется впоследствии выгружать плагин
- Разделяемую библиотеку Native AOT не стоит использовать с расчётом на выгрузку.
- Зависимые библиотеки сильно полагаются на reflection или динамическую генерацию кода
- Если появляются предупреждения при публикации AOT, безопаснее не игнорировать их небрежно.
В конечном счёте водораздел — это укладываетесь ли вы в C ABI. Если не укладываетесь, чище будет выбрать другой мост.
8. Подводные камни
Напоследок соберём незаметные, но частые ловушки при экспорте через Native AOT.
- Метод с атрибутом
UnmanagedCallersOnlyобязан бытьstatic. - Его нельзя разместить в generic-методе или внутри generic-класса.
- Если нужен именованный экспорт, добавьте
EntryPoint. - Не используйте
ref/in/out— лучше возвращать значения через указательные параметры. - Экспортируются только методы сборки, которая публикуется. Пометить атрибутом методы в подключаемой библиотеке-зависимости недостаточно, чтобы они сами по себе оказались на поверхности.
- Разрядность вызывающей стороны и DLL должна совпадать.
- Предупреждения при публикации очень важны. Если появляются предупреждения AOT / trimming, безопаснее сначала разобраться с ними.
Всё это — вещи, о которых, узнав однажды, думаешь «ну да, конечно». Но наступить на эти грабли не зная о них заранее — довольно неприятное занятие на некоторое время.
9. Итог
Когда хочется вызвать C# из C/C++, первым делом на ум приходят COM, C++/CLI или отдельный процесс. Все эти варианты правильные.
Но если хочется встроить обработку на C# как in-process нативную DLL, Native AOT + UnmanagedCallersOnly — по-настоящему интересный вариант.
Перечислим ключевые моменты ещё раз.
- Не показывать C# как есть, а свести его к C ABI
- Явно управлять жизненным циклом на основе дескрипторов
- Пересекать границу через коды ошибок, а не через исключения
- Зафиксировать соглашение о вызовах
- Делать экспортируемые методы тонкими, отделяя их от внутренней логики
Ничего эффектного в этом нет. Но то, как проведена граница, сильно влияет на дальнейшую поддерживаемость. Когда хочется сохранить нативные активы, привнеся в слой логики продуктивность C#, эта конфигурация точно стоит того, чтобы её запомнить.
10. Список источников
- Полный набор примеров кода к этой статье (библиотека C#, пример вызова из C++, модульные тесты) - komurasoft-blog-samples (GitHub)
- Native code interop with Native AOT - Microsoft Learn
- Building native libraries - Microsoft Learn
- Native AOT deployment - Microsoft Learn
- UnmanagedCallersOnlyAttribute Class - Microsoft Learn
- UnmanagedCallersOnlyAttribute.CallConvs Field - Microsoft Learn
- C# compiler breaking changes: ref / ref readonly / in / out are not allowed on methods attributed with UnmanagedCallersOnly
- Building Native Libraries with NativeAOT - dotnet/samples
- Почему при использовании нативных DLL на C++ из C# стоит делать обёртку на C++/CLI - KomuraSoft Blog
- Как вызвать 64-битную DLL из 32-битного приложения - пример из практики, где помогает мост COM - KomuraSoft Blog
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Работают ли бизнес-приложения на Windows на Arm — реальность x64-эмуляции (Prism) и нативных DLL/COM
Отвечаем разработчикам и ИТ-специалистам на вопрос «заработает ли наше бизнес-приложение на Windows на Arm». Разбираем принцип работы x64...
Обратная совместимость интерфейсов DLL и COM — таблица решений: какие изменения ломают вызывающую сторону
Какие изменения DLL или COM-компонента на самом деле ломают вызывающую сторону? Разбираем три слоя совместимости — бинарную, исходную и п...
Версионирование схемы БД бизнес-приложения — практика миграций, предотвращающая «у каждого клиента своя база»
Практическое руководство по версионированию схемы БД бизнес-приложения, установленного у множества клиентов. Разбираем PRAGMA user_versio...
CI/CD для приложений WinForms / WPF на практике — автоматизация от сборки до подписи и распространения через GitHub Actions
Практическое руководство по настройке CI/CD для приложений WinForms / WPF через GitHub Actions. Минимальный YAML для сборки и тестов на w...
Если ваше Windows-приложение приняли за вирус — как реагировать на ложные срабатывания Microsoft Defender и жить с влиянием на производительность
Разбираем правильный порядок действий, если Microsoft Defender ложно определяет ваше Windows-приложение как вредоносное: как устроена сов...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Совместимость 32 и 64 бит
Совместимость 32/64 бит, нативные границы и решения по проектированию Windows.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Тема связана с реализацией границы между C# и C/C++, поэтому она напрямую связана с консультациями по проектированию и реализации в рамках разработки Windows-приложений.
Использование и перенос существующих активов
С точки зрения того, как построить мост между существующими нативными активами и .NET, тема также хорошо сочетается с нашей поддержкой по использованию и переносу существующих активов.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Можно ли вызвать код на C# из C++?
- Да, можно. В .NET Native AOT позволяет опубликовать библиотеку классов C# в виде нативной разделяемой библиотеки, а методы с атрибутом UnmanagedCallersOnly можно раскрыть как точки входа C. То есть C# можно использовать из C/C++ in-process как «нативную DLL на вызываемой стороне».
- В каких ситуациях подходит эта конфигурация?
- В ситуациях, когда хочется оставить нативное приложение как есть, перенеся в C# только такие части, как логика принятия решений, обработка строк, интерпретация конфигурации или правила вычислений. Особенность направления — «нативная сторона играет главную роль, а C# вызывается как компонент». И наоборот: если нужно вызывать набор C-функций из C#, подходит P/Invoke; если хочется естественно работать с типами и владением C++ — C++/CLI; если нужно пересечь границу 32-бит/64-бит или границу процессов — COM/IPC.
- На что обратить внимание при проектировании API?
- Экспортируется, по сути, только точка входа C-функции, поэтому нельзя напрямую выставлять на границе string, List<T> или исключения. Нужно свести всё к плоскому C API вроде create / destroy / operate, явно обозначить управление жизненным циклом и коды ошибок, обрабатывать строки как указатель + длина + ёмкость буфера, не давать исключениям пересекать границу и зафиксировать соглашение о вызовах. Ключевая идея — проектировать поверхность границы как C ABI, а не как .NET.
- Есть ли рабочий пример кода?
- Да, есть. В репозитории komurasoft-blog-samples на GitHub опубликован полный набор сборно-запускаемых примеров, включающий библиотеку C#, публикуемую через Native AOT, пример вызова из C++ и модульные тесты. Примеры кода рассчитаны на DLL под Windows, но сам подход почти так же применим на Linux / macOS.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки