Решение конфликтов зависимостей .net 4.8 в плагинах autodesk revit через appdomain

Решение конфликтов зависимостей в .NET 4.8 на примере плагина для Autodesk Revit: изоляция через Cross Domain Interaction

Предисловие

Меня зовут Худошин Илья, я занимаюсь разработкой десктопных, серверных и веб‑приложений. Хотя в последние годы я почти не пишу под .NET Framework, мне попалась на размышление одна типичная проблема из мира плагинов для Autodesk Revit. Идея её решения оказалась достаточно интересной, чтобы реализовать прототип и разобрать подход подробно.

Речь пойдёт о конфликте зависимостей в плагинах для Revit, работающих на .NET Framework 4.8, и о том, как с помощью отдельного домена выполнения (AppDomain) изолировать часть логики плагина и его библиотеки, минимизировав риск "битвы" версий и непредсказуемых падений.

---

Контекст: Revit, .NET и архитектура плагинов

Autodesk Revit предназначен для BIM‑моделирования различных разделов проекта. Один из самых распространённых способов автоматизировать рутинные задачи в Revit - писать плагины на платформе .NET.

До версии Revit 2024 включительно средой выполнения для плагинов является .NET Framework 4.8. Начиная с Revit 2025, Autodesk переехал на .NET 8 (наследник .NET Core), что радикально меняет подход к структуре проектов, csproj, подбору NuGet‑пакетов и совместимости библиотек. Однако в этой статье мы намеренно остаёмся в "старом мире" - Revit 2024 и ниже, где всё крутится вокруг .NET Framework 4.8.

Плагины подключаются в Revit через манифесты формата `.addin`. При запуске программы Revit сканирует предопределённые каталоги, находит такие манифесты и на их основе загружает сборки, реализующие интерфейсы `IExternalApplication`, `IExternalCommand` и т.п.

Ключевой момент: при старте `Revit.exe` загружается CLR .NET Framework 4.8 и инициализируется базовый `DefaultDomain`. Уже в этом домене Revit начинает подтягивать свои собственные сборки и их зависимости, а затем - плагины сторонних разработчиков. Никакой отдельной инициализации рuntime для каждого плагина не происходит: все сборки оказываются в общем домене.

---

В чём суть проблемы: конфликты зависимостей в одном домене

Так как все плагины и сам Revit работают в одном `DefaultDomain`, то:

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

Ситуация осложняется тем, что на одном рабочем месте может быть установлено несколько плагинов разных разработчиков. Они нередко используют одни и те же популярные пакеты: логгеры, DI‑контейнеры, WPF‑UI‑фреймворки, вспомогательные библиотеки. Одно и то же имя сборки с разными версиями - и вот вы уже имеете типичный конфликт:

- ваш плагин ожидает одну версию библиотеки;
- другой плагин или сам Revit уже подгрузили другую;
- CLR использует первую загруженную версию, а остальной код вынужден "как-то ужиться" с ней.

Простой пример - логгер Serilog. Если в проекте плагина добавить ряд зависимостей и их транспарентные зависимости подтянут конкретные версии Serilog, а в другом плагине или в экосистеме Revit будет другая версия, высока вероятность конфликта при инициализации логгера. Аналогичная история с WPF UI‑китами: темы, контролы, вспомогательные компоненты нередко несовместимы между версиями, но CLR об этом не предупреждает заранее.

---

Почему это опасно и сложно диагностируется

Сложность таких конфликтов в том, что они:

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

Результат - неочевидные исключения, падения плагина, а иногда и критические сбои, влияющие на работу Revit в целом. Это ведёт к дополнительным трудозатратам: приходится тратить время не на развитие функционала, а на разбор стека вызовов, эксперименты с версиями библиотек и попытки воспроизведения ошибки на машине пользователя.

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

---

Очевидные, но неудобные пути решения

В теории вариантов борьбы с конфликтами немало:

- ручное редактирование IL‑кода, переименование сборок, "склеивание" нескольких версий;
- создание собственных форков популярных библиотек с уникальными именами сборок;
- отказ от сторонних плагинов и попытка "жить в вакууме", жёстко контролируя окружение;
- агрессивное использование `bindingRedirect` в конфиге приложения (что не всегда доступно для плагинов).

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

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

---

Идея: изолировать плагин в отдельный домен выполнения

Цель подхода - вытащить "опасные" части плагина (логика, много зависящая от внешних библиотек, WPF‑UI, логгеры, DI, сторонние пакеты) из общего `DefaultDomain` в отдельный AppDomain.

Мы получаем:

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

По сути, Revit и минимальный "стартер" плагина живут в `DefaultDomain`, а "ядро" плагина и модули с богатыми зависимостями - в другом домене. Между ними организуется чёткий протокол взаимодействия.

---

Нефункциональные требования к решению

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

1. Изолировать конфликтующие зависимости от DefaultDomain, предоставляемого `Revit.exe`.
2. Не ограничивать себя выбором пакетов под .NET Framework 4.8:
- свободно использовать разные WPF UI‑киты;
- подключать любые библиотеки логирования;
- использовать удобный контейнер зависимостей, не боясь его версии.
3. Обеспечить модульность: новые функциональные блоки плагина должны подключаться без значимых архитектурных изменений.
4. Создать уровень абстракции над реализацией, которая фактически исполняется в другом домене:
- в DefaultDomain - только контракты и минимальная логика;
- в отдельном домене - тяжёлая реализация.
5. Сохранить возможность взаимодействия с Revit из другого домена:
- как в блокирующем UI сценарии (например, модальные окна),
- так и в неблокирующем режиме, не мешая основной работе пользователя.
6. Предоставить удобный способ управления зависимостями и их получения:
- для стартового кода плагина;
- для ядра системы;
- для отдельных модулей.

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

---

Архитектура: ядро, домен‑менеджер и модули

Логически систему можно разделить на несколько слоёв:

1. Стартер плагина в DefaultDomain
- минимальный код, который Revit вызывает через `IExternalApplication` (и, при необходимости, `IExternalCommand`);
- отвечает за инициализацию домен‑менеджера и регистрацию модулей;
- не тянет тяжёлые зависимости.

2. Домен‑менеджер (Domain Manager)
- отвечает за создание и конфигурацию нового AppDomain;
- загружает в него ядро плагина и модули;
- реализует прокси‑взаимодействие между доменами (вызовы методов, передача данных).

3. Ядро плагина
- основной каркас приложения: DI‑контейнер, логгирование, базовые сервисы;
- располагается в изолированном домене, использует любые необходимые зависимости.

4. Модули (например, `ITestSampleModule`)
- независимые функциональные блоки;
- реализуют определённый контракт (интерфейс), известный стартеру и домен‑менеджеру;
- подключаются к ядру динамически.

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

Такое разделение позволяет изолировать всё потенциально конфликтное в отдельный AppDomain, а в DefaultDomain оставить только "обвязку" и контракты.

---

Реализация домен‑менеджера и ядра

В практической реализации домен‑менеджер:

- создаёт новый AppDomain с нужной конфигурацией (пути поиска сборок, настройки загрузки);
- инициализирует в нём стартовый объект ядра (например, класс Bootstrapper или Kernel);
- предоставляет вызывающему коду методы наподобие:
- загрузить модуль по имени/пути;
- выполнить команду модуля;
- передать данные и получить результат.

Само ядро в изолированном домене может:

- поднять контейнер зависимостей;
- настроить логирование (Serilog, NLog или любой другой);
- инициализировать WPF‑подсистему (если требуется UI);
- зарегистрировать модули и их зависимости.

Домен‑менеджер в DefaultDomain не знает деталей реализации - только общие интерфейсы. Это даёт гибкость: сменить DI‑контейнер или логгер в ядре можно без изменения стартового кода плагина.

---

Модуль `ITestSampleModule` как пример

Отдельный модуль вроде `ITestSampleModule` служит демонстрацией того, как можно строить независимые части функционала:

- модуль реализует интерфейс, описывающий его API: методы запуска, остановки, взаимодействия с Revit;
- физически собирается в отдельную сборку, которая загружается в изолированный домен;
- использует любые зависимости, не заботясь о версиях в DefaultDomain.

Через домен‑менеджер стартер в Revit может:

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

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

---

Пример взаимодействия между доменами

Типичная схема вызова выглядит так:

1. Пользователь запускает Revit.
2. Revit по манифесту `.addin` инициализирует стартер плагина в `DefaultDomain`.
3. Стартер создаёт домен‑менеджер и инициирует создание изолированного AppDomain.
4. В новом домене запускается ядро плагина, которое регистрирует модули.
5. При необходимости стартер вызывает у домен‑менеджера определённые методы (например, запуск модуля), передавая аргументы через контракты.
6. Модуль выполняет работу и возвращает результат обратно в `DefaultDomain`.

Важно, что при этом:

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

---

Взаимодействие с Revit: блокирующий и неблокирующий режимы

Отдельный аспект - работа с Revit API из другого домена. Здесь важно соблюдать несколько правил:

1. Блокирующий сценарий (модальные операции)
- используется, когда нужно показать модальное окно, запросить у пользователя действие, начало/окончание транзакций и т.п.;
- логика может инициироваться из отдельного домена, но фактический вызов Revit API лучше проксировать в `DefaultDomain`, чтобы строго контролировать поток и контекст.

2. Неблокирующий сценарий (фоновая обработка)
- когда требуется выполнить длительную операцию, не блокируя интерфейс;
- вычисления, парсинг, подготовка данных выполняются в изолированном домене;
- UI‑обновления и взаимодействие с документом выполняются "мостом" в основном домене.

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

---

Подключение модулей и домен‑менеджера к стартеру плагина

Со стороны стартера схема выглядит упрощённо:

- при `OnStartup` плагина создаётся домен‑менеджер;
- домен‑менеджер поднимает ядро, загружает модули и сообщает стартеру, какие функции доступны;
- в зависимости от сценария работы (кнопки на панели, команды, события) стартер вызывает соответствующие методы домен‑менеджера.

С точки зрения Revit весь этот механизм "прозрачен": снаружи это просто плагин, состоящий из одной или нескольких команд. Но внутри он устроен как отдельное мини‑приложение со своей средой выполнения.

---

Точки роста и развитие архитектуры

Представленный подход даёт основу, но его можно развивать дальше:

1. Горячая перезагрузка модулей
- возможность выгружать и перезагружать модули без перезапуска Revit;
- полезно при разработке и отладке.

2. Версионирование модулей
- разные версии одного и того же модуля могут coexist в разных доменах;
- облегчает поэтапный переход между версиями.

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

4. Гибкая конфигурация загрузки
- конфиг‑файл или интерфейс в Revit для включения/отключения модулей;
- выбор зависимостей и поведения без перекомпиляции.

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

---

Практические рекомендации

Чтобы такой подход оказался полезен на практике, а не остался теоретической конструкцией, стоит придерживаться нескольких принципов:

- Минимизировать контракт между доменами
Чем меньше типов нужно шарить между доменами, тем проще поддержка и меньше риск ошибок сериализации.

- Жёстко разделять ответственность
В DefaultDomain - только то, что необходимо Revit (инициализация, контакты с API, UI‑мост). В изолированном домене - логика, данные, тяжёлые зависимости.

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

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

- Планировать переход на .NET 8
Несмотря на ориентацию на .NET 4.8, стоит сразу проектировать архитектуру так, чтобы при переходе на Revit 2025 и .NET 8 иерархия слоёв и контрактов сохранилась, а механику изоляции можно было адаптировать под новые механизмы (например, загрузчики сборок, контейнеры, отдельные процессы).

---

Заключение

Конфликты зависимостей в плагинах для Autodesk Revit на .NET Framework 4.8 - не уникальная проблема, но в условиях общего `DefaultDomain` и большого количества сторонних решений они становятся особенно болезненными.

Изоляция "опасных" зависимостей в отдельный домен выполнения и построение Cross Domain взаимодействия позволяют:

- защитить ваш плагин от версионных конфликтов с другими сборками;
- свободно выбирать WPF UI‑киты, логгеры и DI‑контейнеры;
- строить модульную архитектуру, удобную для расширения;
- контролировать взаимодействие с Revit, не нарушая ожиданий его API.

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

Прокрутить вверх