Обратная совместимость интерфейсов DLL и COM — таблица решений: какие изменения ломают вызывающую сторону

· · COM, DLL, .NET, C#, C++, Обратная совместимость, Версионирование, Устаревшие технологии, Использование существующих активов, Таблица решений

«Для этого исправления достаточно просто заменить DLL, или вызывающую сторону тоже нужно пересобрать?» — если вы сопровождаете общую DLL или COM-компонент, на который ссылаются несколько приложений, вам приходится отвечать на этот вопрос при каждом релизе. Ответите неверно — и старый EXE, всё ещё работающий у клиента, может перестать запускаться вовсе, а то и хуже: он продолжает прекрасно запускаться, но результаты вычислений под ним тихо меняются.

Сложность в том, что это решение обычно принимается на основании смутного ощущения «кажется, это рискованно». На деле же то, какие изменения ломают совместимость, можно определить почти механически. У нативных DLL есть чётко определённые правила экспорта и соглашений о вызовах; у COM есть явное, письменно закреплённое железное правило неизменности интерфейсов1; а у .NET есть опубликованный список правил совместимости, которые сама Microsoft использует при разработке библиотек .NET2.

В этом блоге мы уже разбирали основы COM в статье «Что такое COM / ActiveX / OCX?» и его философию проектирования в статье «Что такое COM — почему проектирование Windows COM до сих пор выдерживает проверку временем». В этой статье мы сводим в виде таблицы решений то, какие изменения ломают вызывающую сторону для DLL, COM и сборок .NET по отдельности, а также разбираем процедуру на случай, когда нарушить совместимость неизбежно.

1. Сначала вывод

  • У совместимости есть три слоя: бинарная совместимость (работает без пересборки), исходная совместимость (работает при пересборке) и поведенческая совместимость (поведение не меняется). «Пересборка не нужна» не равно «безопасно» — нужно оценивать и поведенческую совместимость тоже.3
  • Для нативных DLL базовое правило: добавление экспорта безопасно, изменение или удаление существующего экспорта разрушительно. Сигнатуры функций, соглашения о вызовах и раскладка структур — это и есть сам бинарный контракт.
  • Интерфейс COM неизменяем после публикации. Добавление, удаление или перестановка методов после публикации нарушает спецификацию; изменения нужно добавлять как новый интерфейс с новым IID (IFoo → IFoo2).41
  • Клиенты VB6/VBA используют раннее связывание (early binding), которое зашивает позиции слотов vtable, что делает их наиболее уязвимыми к любому изменению раскладки интерфейса.
  • В .NET то, что считается разрушительным изменением публичного API, опубликовано как правила совместимости Microsoft, и в этом списке к разрушительным отнесены не только удаление метода или изменение сигнатуры, но и добавление virtual к члену и даже переименование параметра.2
  • Семантическое версионирование — это соглашение повышать основную версию при разрушительном изменении, но оно работает только после того, как вы объявите определение того, что считается разрушительным.5 В качестве этого определения можно использовать таблицу решений из этой статьи.
  • Когда нарушить совместимость неизбежно, действуйте в порядке: параллельная поддержка старого и нового → период устаревания → инвентаризация вызывающей стороны → отказ от старого. Принцип — никогда не заменять всё разом.

2. Три слоя совместимости — кто и когда сталкивается со сбоем

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

Слой Значение Что происходит при поломке Кто в основном страдает
Бинарная совместимость Вызывающая сторона работает с новой DLL без пересборки Точка входа не найдена при запуске, MissingMethodException во время выполнения, падения Старые EXE, всё ещё работающие у клиентов, сторонние приложения, которые нельзя пересобрать
Исходная совместимость Вызывающая сторона работает, если её пересобрать Ошибки компиляции при следующей сборке Другая команда внутри компании, разработчики, у которых есть исходный код
Поведенческая совместимость Заданное спецификацией поведение не меняется Результаты, тайминг или тип брошенного исключения меняются без какой-либо ошибки Конечные пользователи (и все, кто потом расследует возникший инцидент)

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

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

3. Таблица решений по совместимости для нативных DLL (C/C++)

Совместимость нативной DLL определяется таблицей экспорта, соглашениями о вызовах и раскладкой памяти. То, как DLL находится и загружается, разобрано в статье «Как работает разрешение имён DLL в Windows»; а после успешной загрузки совместимость оценивается по таблице ниже.

Изменение Бинарная совместимость Примечание
Добавление экспортируемой функции Не ломается Самый безопасный способ расширения. Однако если вы полагаетесь на неявные порядковые номера из .def-файла, добавление функции может перенумеровать существующие номера в зависимости от того, куда она попадёт; если хоть один клиент линкуется по номеру, явно зафиксируйте существующие номера и добавляйте новые в конец
Удаление или переименование экспортируемой функции Ломается Разрешение импорта не удаётся, ошибка при загрузке или из GetProcAddress
Изменение сигнатуры существующей функции (добавление/удаление/изменение типа параметров, изменение типа возвращаемого значения) Ломается Соглашения передачи через стек и регистры расходятся. То же касается возвращаемого значения — переключение с целого числа (RAX) на число с плавающей точкой (XMM0) заставляет вызывающую сторону читать мусор по старому ABI. Может работать без ошибки и просто сойти с рельсов
Изменение соглашения о вызовах (__cdecl__stdcall) Ломается (32-бит) На x86 меняется ответственность за очистку стека, что приводит к повреждению стека. У x64 единое соглашение о вызовах, и эти спецификаторы фактически игнорируются, поэтому эта строка касается только 32-битных DLL
Изменение порядкового номера экспорта (ordinal) Ломается, условно Вызывающая сторона, линкующаяся по номеру, начинает вызывать другую функцию. Если все линкуются только по имени, влияния нет
Добавление члена в структуру, которую выделяет вызывающая сторона Ломается Старая вызывающая сторона по-прежнему выделяет и передаёт меньшую версию структуры (это можно смягчить соглашением cbSize, описанным ниже)
Изменение упаковки/выравнивания публичной структуры (#pragma pack, /Zp, смена набора инструментов) Ломается Смещения существующих членов и общий размер меняются, даже если вы не тронули ни одного члена. cbSize не спасает от сдвинутой раскладки, поэтому явно зафиксируйте упаковку в публичном заголовке
Внутренние изменения структуры, которую выделяет и освобождает только сама DLL Не ломается Если проектирование раскрывает наружу только указатель (хендл), внутреннее устройство можно менять свободно
Изменение смысла возвращаемого значения или кода ошибки Не ломается (но ломается поведенческая совместимость) Линковка по-прежнему успешна, а поведение меняется — паттерн, который обнаруживается медленнее всего
Добавление члена данных или виртуальной функции в класс C++, экспортируемый напрямую Ломается Меняется размер объекта или раскладка vtable. Добавление только невиртуальной функции-члена оставляет раскладку неизменной и напрямую не ломает существующих клиентов, но прямой экспорт класса C++ изначально не имеет межкомпиляторной совместимости, и сама необходимость каждый раз принимать это решение — признак хрупкого ABI

Проектное указание, вытекающее из этой таблицы, не менялось десятилетиями: ограничивайте границу C ABI (функциями extern "C" и простыми структурами), а расширение выполняйте добавлением функций. То же касается и создания нативной DLL из C#: экспортируемая поверхность, разобранная в статье «Как вызвать C# Native AOT DLL из C/C++», управляется в точности по этой таблице.

3.1 Соглашение cbSize — приём Win32 для расширяемости структур

Классическое противодействие проблеме «добавление члена структуры ломает совместимость» — соглашение Win32 помещать поле размера в начало структуры. Вызывающая сторона заполняет cbSize размером структуры, известным ей на момент компиляции, и передаёт его; DLL смотрит на этот размер, чтобы определить, какое поколение структуры знает данная конкретная вызывающая сторона.

typedef struct KS_CONFIG {
    DWORD cbSize;      // Вызывающая сторона указывает sizeof(KS_CONFIG)
    DWORD dwMode;
    DWORD dwTimeout;
    // Будущие члены всегда добавлять в конец
} KS_CONFIG;

// Сторона DLL: по cbSize определяем поколение, для старой вызывающей стороны используем значение по умолчанию
if (pConfig->cbSize >= FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)) {
    timeout = pConfig->dwTimeout;   // Новая вызывающая сторона
} else {
    timeout = DEFAULT_TIMEOUT;      // Старая вызывающая сторона
}

Действительно, структура NOTIFYICONDATA из Windows API версионируется по поколениям именно по этой схеме, и официально задокументировано, что правильное значение в cbSize позволяет сохранять совместимость со старыми версиями Shell32.dll.6 Если поместить cbSize в публичные структуры собственной DLL начиная с самой первой версии, последующие расширения переходят из категории «разрушительное изменение» на безопасную сторону таблицы решений. Тем не менее новые члены всегда добавляются в конец, а изменение типа или порядка существующего члена по-прежнему запрещено. Есть ещё одна вещь: для структуры, используемой для вывода, на сторону DLL ложится дополнительная ответственность — запись и инициализация всегда должны укладываться в пределы cbSize, реально переданного вызывающей стороной. Безусловная запись всего нового sizeof выходит за пределы меньшего буфера, выделенного старой вызывающей стороной, и DLL сама вызывает именно ту поломку, которую это соглашение было призвано предотвратить.

4. Железное правило интерфейсов COM — никаких изменений после публикации

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

  • У интерфейса есть уникальный IID (идентификатор интерфейса).1
  • Интерфейс неизменяем (immutable). После создания и публикации ни одна часть его определения не может быть изменена.1
  • Добавление или удаление метода, либо изменение его семантики, означает не «новую версию старого интерфейса», а создание нового интерфейса с другим IID.4

Причина такой строгости в том, что реальная суть интерфейса COM — это бинарная раскладка: vtable, таблица указателей на функции. Клиент на C++ или VB6 зашивает при компиляции факт «слот 3 — это GetName» — позицию. Вставьте метод после публикации, и старый клиент, безо всякой ошибки, вызовет другой метод. Именно поэтому COM исключил саму операцию «изменения» интерфейса из спецификации и вместо этого предоставил следующую процедуру расширения.

// v1: уже опубликован. Больше никогда не менять
[object, uuid(1111....)]
interface ICalc : IUnknown {
    HRESULT Add([in] long a, [in] long b, [out, retval] long* result);
};

// v2: новый интерфейс с новым IID. Расширяет через наследование от ICalc
[object, uuid(2222....)]
interface ICalc2 : ICalc {
    HRESULT AddChecked([in] long a, [in] long b, [out, retval] long* result);
};

Реализующий класс (coclass) реализует и ICalc, и ICalc2; старые клиенты по-прежнему используют ICalc без изменений, а новые клиенты запрашивают ICalc2 через QueryInterface. Официальная теория версионирования RPC/COM формулирует это так же: новый интерфейс, унаследованный от старого, эквивалентен повышению минорной версии, а изменение существующего метода или типа требует совершенно нового, не наследующего интерфейса — эквивалента повышения основной версии.7 Работоспособность этой схемы обеспечивается тем, что QueryInterface позволяет вызывающей стороне безопасно проверить во время выполнения, какие интерфейсы поддерживаются. Проектная элегантность этого механизма подробно разобрана в статье «Что такое COM».

4.1 Разделение ролей между CLSID, ProgID и IID

При размышлении о версионировании COM полезно разделять роли трёх видов идентификаторов.8

  • IID идентифицирует интерфейс (контракт). Любое изменение контракта всегда означает новый IID.
  • CLSID идентифицирует реализующий класс. Вы можете свободно заменять реализацию, сохраняя тот же CLSID, пока соблюдаете контракт каждого опубликованного интерфейса.
  • ProgID — это человекочитаемый псевдоним (KomuraSoft.Calc.1), используемый для поиска соответствующего CLSID в реестре. Принято хранить и версионированный ProgID, и не зависящий от версии ProgID (KomuraSoft.Calc), всегда указывающий на последнюю версию, причём второй сопоставляется с текущей версией через CurVer.8

Иными словами, «повышение версии реализации» относится к миру CLSID и ProgID, а «изменение контракта» — к миру IID; смешивать их нельзя. Если вы хотите вовсе избежать регистрации в реестре, этот вариант разобран в статье «Что такое Reg-Free COM?».

4.2 Почему клиенты VB6/VBA особенно хрупки

Когда VB6 или VBA используют COM-компонент через ссылку в проекте (раннее связывание), они при компиляции читают библиотеку типов, чтобы разрешить вызовы. Раннее связывание — рекомендуемая форма: работают IntelliSense и проверка типов, а выполнение быстрее9 — но ценой этого становится жёсткая привязка к раскладке библиотеки типов. Изменение vtable интерфейса, разумеется, приводит к проблемам, но даже изменение исключительно определений в библиотеке типов может проявиться как «при открытии проекта ссылка оказалась сломана» или как ошибка времени выполнения 430/438.

Поэтому в компонентах, у которых вызывающей стороной выступают VB6, VBA или макросы Excel, правило неизменности интерфейса нужно соблюдать максимально строго. У библиотеки типов тоже есть версия (major.minor), и её повышают при расширении контракта. Генерация библиотеки типов при публикации типизированного кода .NET для VBA разобрана в статье «Вызов типизированной .NET 8 DLL из VBA — dscom и TLB». С другой стороны, клиенты с поздним связыванием, использующие только CreateObject, разрешают вызовы по имени и потому устойчивы к изменениям раскладки, но точно так же подвержены влиянию изменения смысла метода (поведенческой совместимости).

5. Совместимость сборок .NET — механическая оценка по официальным правилам

В .NET опубликован набор «правил изменений для совместимости», которые сама Microsoft использует при разработке библиотек .NET, классифицируя каждый вид изменения как разрешённый (✔️), запрещённый (❌) или требующий оценки (❓).2 В документации прямо указано, что этот набор можно принять как есть в качестве критерия для собственных библиотек, поэтому приведём основные строки.

Изменение публичного API Вердикт Примечание
Добавление метода, типа или члена ✔️ Безопасно в принципе Однако стоит остерегаться добавления, меняющего разрешение существующих перегрузок. Добавление поля экземпляра в публичную структуру — исключение: меняются размер и раскладка, что ломает взаимодействие и потребителей unsafe-кода
Удаление или переименование публичного типа или члена ❌ Разрушительно Ломается во время выполнения с MissingMethodException или подобным
Изменение сигнатуры (добавление/удаление/изменение порядка/типа параметров или возвращаемого значения) ❌ Разрушительно Ломает и бинарную, и исходную совместимость
Переименование параметра ❌ Разрушительно Ломает именованные аргументы C# и позднее связывание VB. Легко упустить из виду
Добавление virtual к члену ❌ Разрушительно Классическая ловушка, выглядящая безопасной, поскольку это «просто добавление». Может возникнуть несовпадение инструкций IL call/callvirt
Удаление virtual или превращение виртуального члена в abstract ❌ Разрушительно Ломает переопределения в производных классах
Добавление абстрактного члена в неseal-запечатанный публичный тип ❌ Разрушительно У существующих производных классов нет реализации для него
Запечатывание (sealed) типа ❌ Разрушительно Существующие производные классы перестают компилироваться
Добавление члена в интерфейс ❓ Требует оценки Можно смягчить через реализацию интерфейса по умолчанию (DIM), но есть условия по языку/runtime
Изменение константы или значения enum, переименование/удаление члена enum ❌ Разрушительно Значение зашивается в вызывающую сторону при компиляции
Изменение кода так, чтобы бросать более производное исключение ✔️ Разрешено Существующие catch-блоки продолжают работать
Бросание исключения нового вида на существующем пути кода ❌ Разрушительно Бросать его только для нового значения параметра — допустимо

Это не так просто, как COM-правило «интерфейсы неизменны», но философия та же: публичный API — это контракт; к контракту можно добавлять, но существующий контракт менять нельзя. А тот факт, что такие изменения, как добавление виртуального метода или переименование параметра — вещи, на первый взгляд выглядящие безопасными, — классифицированы как разрушительные, и есть та самая причина, по которой решение нужно принимать по таблице, а не по ощущению.

5.1 Строгие имена и три номера версий

У сборок .NET есть несколько номеров версий, у каждого своя роль.10

  • AssemblyVersion: единственная версия, которую runtime использует для идентификации и загрузки сборки. Для сборок со строгим именем CLR .NET Framework требует точного совпадения, поэтому каждое повышение вынуждает вызывающую сторону добавлять binding redirect (.NET/.NET Core, напротив, автоматически принимает более высокую версию). Чтобы сократить число редиректов, официальные рекомендации предлагают отражать в AssemblyVersion только основную версию.
  • FileVersion (AssemblyFileVersion): виден только в свойствах файла в Проводнике и никак не влияет на поведение во время выполнения. Рекомендуемое место для номера сборки CI.
  • InformationalVersion: произвольная строка, предназначенная для человека. Используется для записи версии пакета в формате semver или хэша коммита исходного кода.

То есть на практике удобна трёхуровневая конфигурация: «объявлять совместимость через версию пакета/продукта (semver), в AssemblyVersion отражать только основную версию, а FileVersion использовать для отслеживания сборок».

6. Как назначать номера версий — semver работает только при наличии «определения»

Суть семантического версионирования (semver) укладывается в три строки: повышайте MAJOR при несовместимом изменении, MINOR при обратно совместимом добавлении функциональности и PATCH при обратно совместимом исправлении бага.5

Легко упустить из виду, что самое первое требование спецификации semver — программное обеспечение, использующее semver, обязано объявить публичный API.5 Без объявления того, что считается публичным API, критерия для «несовместимого изменения» просто не существует, и решение о повышении основной версии зависит от настроения конкретного человека. В большинстве мест, где semver не работает, проблема не в способе нумерации, а в пропуске именно этого объявления.

Реалистичная эксплуатация для внутренне распространяемой DLL выглядит так.

  1. Объявите границы публичного API — для нативной DLL это экспортируемые функции и публичные заголовки, для COM — IDL/библиотека типов, для .NET — публичные типы и члены. Прямо укажите: «всё остальное — внутренняя реализация, которая может измениться без предупреждения».
  2. Примите определение разрушительного изменения — поместите таблицы решений из разделов 3 и 5 этой статьи вместе с правилами изменений .NET2 в репозиторий как «собственное определение».
  3. Автоматизируйте проверку — для .NET инструменты вроде Package Validation / ApiCompat могут механически проверять бинарную совместимость с предыдущей версией.11 Это исключает решение «наверное, всё в порядке» на ревью.
  4. Добавьте поле совместимости в release notes — каждый раз явно указывайте одно из трёх значений: «пересборка не нужна / рекомендуется пересборка / есть разрушительное изменение». Это механизм, дающий ответ на вопрос из начала статьи — «достаточно ли просто заменить?» — в письменном виде ещё до того, как его зададут.

7. Процедура на случай, когда нарушить совместимость неизбежно

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

  1. Предоставьте старое и новое параллельно — для COM это добавление IFoo2 с сохранением IFoo (раздел 4). Для нативной DLL — добавление новой функции (FooEx) или сосуществование новой DLL с другим именем. Для .NET — выпуск изменения как нового пакета с повышенной основной версией, при этом старая основная версия продолжает получать только исправления багов.
  2. Установите период устаревания — в .NET атрибут [Obsolete] может выдавать предупреждение при компиляции. Для нативного кода/COM объявляйте это через комментарии в заголовках и release notes, явно указывая планируемую дату удаления. Ключевой момент — назначить конкретную дату, а не расплывчатое «когда-нибудь уберём».
  3. Проведите инвентаризацию вызывающей стороны — составьте список тех, кто ещё вызывает старый API, через поиск по внутренним исходникам, записи распространения инсталлятора, а для COM — через использование ссылок в реестре. Если обнаружится бинарник, который нельзя пересобрать (инструмент, созданный уволившимся сотрудником, стороннее приложение), либо продлите жизнь старого API специально для этого случая, либо перекиньте мост через обёртку.
  4. Удалите старый API — только после того, как инвентаризация подтвердит отсутствие оставшихся вызывающих сторон, удалите его и повысьте основную версию.

Эта процедура затратна. Именно поэтому, как ни парадоксально, проектирование небольшого API с оглядкой на таблицу решений с самого первого релиза (то, что вы никогда не публикуете, вообще не несёт обязательств по совместимости) — самая мощная защита совместимости, какая у вас есть.

8. Итог

  • Думайте о совместимости в трёх слоях: бинарном, исходном и поведенческом. Поведенческая совместимость может сломаться даже без пересборки.3
  • Для нативных DLL: добавление безопасно, изменение существующего экспорта, сигнатуры или раскладки структуры разрушительно. Снабжайте структуры полем cbSize, оставляя место для расширения.6
  • Интерфейс COM неизменяем после публикации. Изменения добавляйте как новый интерфейс с новым IID (IFoo2), позволяя вызывающей стороне различать их через QueryInterface.147 Соблюдайте это особенно строго при наличии клиентов VB6/VBA с ранним связыванием.
  • .NET можно оценивать механически по официальным правилам совместимости. Следите за «на первый взгляд безопасными изменениями» — виртуализацией, переименованием параметров, запечатыванием типа, — классифицированными как разрушительные.2
  • Реалистична трёхуровневая конфигурация: AssemblyVersion несёт только основную версию, FileVersion отслеживает сборки, semver объявляет совместимость.10
  • Semver работает только после объявления публичного API и определения разрушительного изменения.5 Примите таблицу решений как это определение и автоматически проверяйте её инструментами вроде Package Validation.11
  • Когда что-то приходится ломать, действуйте по схеме параллельная поддержка → период устаревания → инвентаризация → удаление. Отказ от одномоментной замены — вот что защищает старый EXE, всё ещё работающий у клиента.

Похожие статьи

Смежные области консультаций

В Komura Software LLC мы занимаемся проектированием совместимости DLL, COM-компонентов и библиотек .NET, на которые ссылаются другие системы, инвентаризацией публичных API и выстраиванием политики версионирования, а также проектированием и реализацией расширений — паттерна IFoo2, параллельной поддержки, — не ломающих существующих клиентов.

Справочные материалы

  1. Microsoft Learn, Interface Design Rules. О том, что интерфейсы объектов COM должны иметь уникальный IID, и что ни одна часть определения интерфейса не может быть изменена после создания и публикации (неизменность).  2 3 4 5

  2. Microsoft Learn, Change rules for compatibility (.NET). О том, что изменения API .NET классифицируются как разрешённые, запрещённые или требующие оценки; что удаление или переименование публичных типов/членов, изменения сигнатуры, переименование параметров, добавление/удаление virtual, запечатывание и изменение значений констант/enum классифицируются как запрещённые (разрушительные); что добавление члена в интерфейс требует оценки; и что авторы библиотек могут использовать эти правила как критерий оценки собственной библиотеки.  2 3 4 5

  3. Microsoft Learn, Breaking changes (.NET library guidance). О том, что разрушительные изменения классифицируются как ломающие исходный код, ломающие поведение или ломающие бинарную совместимость, и что при ломке бинарной совместимости сборка, скомпилированная против старой версии, падает во время выполнения с MissingMethodException или подобным.  2 3

  4. Microsoft Learn, Interface Pointers and Interfaces. О том, что интерфейсы COM неизменяемы, и что добавление или удаление метода либо изменение его семантики означает создание нового интерфейса, а не новой версии старого, причём IID однозначно определяет контракт.  2 3

  5. semver.org, Semantic Versioning 2.0.0. О повышении MAJOR при несовместимых изменениях API, MINOR при обратно совместимом добавлении функциональности и PATCH при обратно совместимом исправлении багов; о том, что программное обеспечение, использующее semver, обязано объявить публичный API; и о том, что обратно несовместимое изменение публичного API всегда требует повышения версии MAJOR.  2 3 4

  6. Microsoft Learn, NOTIFYICONDATAW structure (shellapi.h). Об установке размера структуры в члене cbSize, о том, что структура расширялась поколение за поколением, и о том, что установка правильного значения cbSize позволяет сохранять совместимость со старыми версиями Shell32.dll.  2

  7. Microsoft Learn, The Versioning Theory for RPC and COM. О том, что создание нового интерфейса — лучший способ расширения функциональности в COM, что новый интерфейс, унаследованный от старого, эквивалентен повышению минорной версии, что изменения существующих методов или типов требуют совершенно нового, не наследующего интерфейса, и что QueryInterface позволяет вызывающей стороне проверить, что поддерживается.  2

  8. Microsoft Learn, COM Registry Keys. О том, что CLSID — это GUID, идентифицирующий класс COM, что ProgID — человекочитаемая строка, сопоставленная с CLSID без гарантии уникальности, что не зависящий от версии ProgID сопоставляется с последней версией класса через CurVer, и что ключ Interface регистрирует IID.  2

  9. Microsoft Learn, OLE programmatic identifiers, late binding, and early binding (Project). О том, что в VBA рекомендуется раннее связывание через ссылку в проекте, что позднее связывание (CreateObject/ProgID) не показывает члены во время написания кода и работает медленнее, и что раннему связыванию требуется ссылка на целевую библиотеку объектов. 

  10. Microsoft Learn, Versioning (.NET library guidance). О том, что AssemblyVersion используется runtime для загрузки и требует точного совпадения для сборок со строгим именем в .NET Framework, о предложении включать в AssemblyVersion только основную версию, о том, что FileVersion предназначен для отображения в Windows и не влияет на поведение во время выполнения, что InformationalVersion предназначен для записи дополнительной информации о версии, и что для версий пакетов NuGet рекомендуется использовать semver 2.0.0.  2

  11. Microsoft Learn, NuGet package compatibility rules. О необходимости избегать разрушающих бинарную совместимость изменений, о том, что инструменты Package Validation и ApiCompat могут автоматически обнаруживать совместимость с базовой версией, и о том, что AssemblyVersion нельзя понижать между релизами.  2

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

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

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

Если в DLL просто добавить функцию, нужна ли пересборка вызывающей стороны?
Как правило, нет — если просто добавить экспортируемую функцию, существующая вызывающая сторона продолжает работать как есть, потому что разрешение импорта по-прежнему проходит успешно, пока вы не меняете имя, сигнатуру, соглашение о вызовах или порядковый номер экспорта существующей функции. Тем не менее, если добавить член в структуру, которую выделяет и передаёт вызывающая сторона, или изменить смысл возвращаемого значения либо кода ошибки существующей функции, поведенческая совместимость может сломаться, даже если пересборка формально не требуется. Базовое правило: добавление функции безопасно, изменение существующей сигнатуры разрушительно.
Почему нельзя добавлять метод в интерфейс COM после его публикации?
Потому что по спецификации COM интерфейс становится неизменяемым (immutable) после публикации. Реальная суть интерфейса — это контракт бинарной раскладки: vtable, упорядоченный список указателей на функции, и вставка, удаление или перестановка методов означают, что старый бинарник вызывает другой метод по позиции слота, зашитой при компиляции. Добавление в конец не сдвигает существующие слоты, но создаёт новую опасность: новый клиент может получить экземпляр старого компонента и предположить, что добавленный метод присутствует, а затем вызвать слот, которого там на самом деле нет. Именно поэтому даже добавление в рамках того же IID не допускается. Когда нужно добавить функциональность, добавляют новый интерфейс с новым IID (IFoo2), оставляя существующий IFoo нетронутым. Вызывающая сторона затем может безопасно определить во время выполнения через QueryInterface, с каким из интерфейсов - старым или новым - она имеет дело.
Как правильно использовать AssemblyVersion, FileVersion и InformationalVersion в .NET?
AssemblyVersion - единственная версия, которую runtime использует для идентификации и загрузки сборки; для сборок со строгим именем CLR .NET Framework требует точного совпадения, поэтому каждое повышение вынуждает вызывающую сторону добавлять binding redirect. Именно поэтому официальные рекомендации предлагают отражать в AssemblyVersion только основную (major) версию. FileVersion виден только в свойствах файла в Проводнике и никак не влияет на поведение во время выполнения, что делает его удобным местом для номера сборки CI. InformationalVersion - это произвольная строка, предназначенная для человека, используемая для записи версии в формате semver или хэша коммита.
Решает ли внедрение семантического версионирования (semver) проблемы совместимости само по себе?
Нет, один только semver этого не решает. Semver - это соглашение повышать основную версию при внесении несовместимого с обратной совместимостью изменения, но оно предполагает, что вы уже заранее объявили, что считается публичным API, а что - разрушительным изменением. Проставляя номера версий без такого определения, вы получаете решение, которое каждый раз зависит от настроения конкретного человека, и схема перестаёт работать. Semver обретает смысл только тогда, когда вы принимаете нечто вроде таблицы решений из этой статьи (для нативных DLL) или официальных правил совместимости Microsoft (для .NET) как собственное определение разрушительного изменения и встраиваете это определение в процесс релиза.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

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

Публичные ссылки

Вернуться в блог