28 июля 2012 г.

Разработка системы плагинов, часть 8: расширение системы и обратная совместимость

Предыдущая часть.

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

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

Введение в обратную совместимость

Итак, вы написали программу Litware Application v1.0 и выпустили её (выложили в Интернете или отправили заказчику). Программа снабжена ядром для системы плагинов и документацией к ней (SDK). Сторонние разработчики (или даже вы сами) начали писать плагины к Litware Application v1.0 и выкладывать их (или отправлять заказчику). В результате у вас получится одна программа Litware Application v1.0, взаимодействующая с десятками, сотнями, тысячами, а иногда даже и миллионами плагинов (понятно, не сразу, не одновременно и не на одной машине).

Затем у вас возникла потребность добавить в Litware Application новую возможность (функциональность). Поэтому вы выпускаете Litware Application v2.0.

Тут-то и возникает проблема: для программы Litware Application v1.0 написана куча плагинов. Вы не можете обновить весь этот код. Вы не являетесь его разработчиком. Почти все плагины разработаны кем-то (не вами). Да, вы могли бы сказать всем разработчикам плагинов: "обновите свои плагины, чтобы они работали с Litware Application v2.0". Проблема в том, что много плагинов могут уже не разрабатываться - компания-разработчик разорилась, уволился разработчик плагина ("эту программу написал нанятый специалист пять лет назад. У нас есть исходный код, но никто в нём ничего не понимает"), утерян исходный код плагина, иногда разработчика плагина даже может не быть уже в живых.

Итого, если только вы не изобрели машину времени, вы не можете обновить код уже написанных плагинов. Поэтому при выпуске Litware Application v2.0 вам нужно быть здорово уверенным, что все плагины для Litware Application v1.0 смогут работать и в Litware Application v2.0. И это - ваша задача.

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

Аспект второй связан с недокументированным поведением вашей программы. Недокументированное поведение - это то, что выходит за рамки контракта взаимодействия ядро-плагины (API), это то, что не описано в SDK.

Например, пусть ваше ядро экспортирует такую функцию:
function ICore.Funcenstein(Buffer: Pointer; Size: Integer): Boolean; safecall;
Контракт по вызову прост: функция вернула True - значит, она завершилась успешно и вы можете использовать данные в Buffer. Если же функция вернула False, то она завершилась ошибкой, а значение Buffer не определено. Казалось бы, простое и логичное правило: что тут может пойти не так?

Дело в том, что проверять результат работы функции - это же куча работы. Люди, как известно, ленивы. Поэтому они могут заметить, что ваша функция не трогает буфер Buffer, если она завершается неудачей. И они могут написать код вроде такого:
Buf := AllocMem(Sz);
Core.Funcenstein(Buf, Sz);
DoSomething(Buf, Sz);
FreeMem(Buf);
В этом куске кода подразумевается, что если функция Funcenstein завершится неудачно, то в Buf будут нули (ведь он не тронут). Нули - это допустимое значение (скажем, нуль-терминированная строка нулевой длины), поэтому мы просто продолжаем выполнение (функция DoSomething).

Но нигде в контракте функции Funcenstein об этом не сказано (о том, что при неудаче буфер не трогается), поэтому это - деталь реализации. Иными словами, код плагина работает исключительно благодаря случайности!

Конечно же, вы и подумать не можете, что кому-то придёт в голову использовать вашу функцию таким образом. Поэтому в новой версии программы вы используете буфер Buffer для промежуточных вычислений. Вам нужен кусок памяти, а тут как раз он без дела болтается - так что вы используете его. Вы имеете полное право на это, вы всё делаете правильно. Вот только, когда вы установите на машину клиента новую версию своей программы, как перестанет работать установленный плагин стороннего разработчика. Вина ваша? Нет. Но что скажет пользователь? "Не ставьте новую версию Litware Application - из-за неё ломается <плагин другого человека>!".

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

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

Некоторые разработчики могут подумать, что уж в случае-то явной вины разработчика плагинов мы можем не заботиться о их работе в Litware Application v2.0?

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

Предположим, что вы IT-менеджер в какой-то компании. Ваша компания использует Litware Application v1.0 с Плагином X как свой текстовый процессор, и вы обнаруживаете что по какой-то причине Плагин X несовместим с Litware Application v2.0. Станете ли вы обновляться?

Ну конечно же нет! Ваш бизнес остановится.

"Почему не позвонить в Компанию X и не взять у них обновление?"

Конечно, вы можете это сделать, но часто ответ будет: "о, вы используете Версию 1.0 Плагина X. Вам нужно обновиться до Версии 2.0 за $150 за экземпляр". Поздравляю, ваша цена апгрейда на Litware Application v2.0 только что утроилась.

И это, если вам ещё повезло и Компания X ещё ведёт дела.

Суммируя вышесказанное: если вы, как разработчик Litware Application, выпустили Litware Application v2.0, которая нарушила работу каких-то плагинов, и пользователей это устроило - это всего лишь означает, что эти плагины - говно*.

* - "Пожалуйста, помните, что моё определение 'говна' (crap) - это 'мусор, который я не использую'. Если я использую софт, то, по-определению, это уже не 'говно'. И я понимаю, что моё 'говно' - не всегда ваше 'говно'" (C) Ларри Остерман.

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

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

Вместо этого предлагается такой путь:
  1. Явно потребовать, чтобы каждый плагин был проверен на совместимость с новой версией программы, иначе он не будет работать в ней.
  2. ...
  3. Litware Application v999.0 - успешное приложение с ясной и чистой архитектурой, с грамотно написанными плагинами, которые не нарушают правил контракта.
Но в этом плане есть изъян. Дело в том, что не упомянутый шаг 2 выглядит так: "Все плагины перестанут работать, пока их разработчики не проверят и обновят их. Пользователи не используют Litware Application v2.0, потому что там нет нужного им плагина".

Итого, вы просто не доберётесь до Litware Application v999.0, потому что никто не будет использовать Litware Application v2.0 (опять же, в предположении, что плагины к Litware Application v1.0 делают нужные и полезные вещи).

Вот всё это выше и называется "обратной совместимостью" (backward compatibility). Думаю, что вы уже поняли, почему у каждого разработчика начинает болеть голова, как только он слышит эти слова.

Базовые правила

Здесь есть всего одно правило, которое нужно распечатать большими буквами и повесить на монитор:

НИКОГДА НЕ ИЗМЕНЯЙТЕ УЖЕ ОПУБЛИКОВАННЫЙ КОНТРАКТ!

Здесь слово "контракт" означает договор между программой и плагинами, API. Т.е. правила, по которым они взаимодействуют. В нашем случае это будут интерфейсы (ну и функции инициализации/финализации из DLL).

Слово "опубликованный" означает, что интерфейс был использован в релизе программы, описан в документации (SDK) и его использовали плагины.

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

И, да, слово "никогда" означает именно "никогда". Вообще. Абсолютно. Даже для исправления багов**. Причём сильно желательно не менять даже внутренности реализующего объекта (которые вообще-то никак не фиксируются в контракте и являются, что называется, "недокументированными особенностями реализации").

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

Основы расширения и наследование

До этого момента все наши интерфейсы выглядели однотипно:
type
  ISomeInterface = interface
  [{...}]
    // ... какая-то возможность
  end;
Мы не использовали версионность и наследование, потому что у нас есть только один вариант интерфейса. Стоит вспомнить, что интерфейсы идентифицирются по IID (Interface ID - идентификатор интерфейса). IID имеет форму GUID и указывается в квадратных скобках после заголовка объявления интерфейса. Имя интерфейса не имеет никакого значения (поскольку у интерфейса вообще нет имени). К примеру, мы можем свободно переименовать ISomeInterface в IAnotherInterface и это не изменит ровным счётом ничего. Два интерфейса с разными именами, но с одинаковым IID считаются одним и тем же интерфейсом. Откуда напрямую следует, что нельзя изменить интерфейс, не поменяв его IID.

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

Итак, когда мы вводим в программу новую функциональность, это может означать:
  1. Добавление нового интерфейса или метода
  2. Изменение существующего метода
  3. Удаление существующего метода или интерфейса (функциональность более не поддерживается)
Начнём с чего попроще - пункта 2. Согласно правилу "никогда не изменяйте уже опубликованный контракт", мы не можем менять сигнатуры и семантику уже опубликованных интерфейсов и методов. Это означает, что пункт 2 следует разложить на две части - пункт 3 и пункт 1: т.е. введение нового метода с замещением (удалением) старого.

Пункт 3 по началу выглядит ровно так же, как пункт 2 - мы же не можем менять опубликованный контракт, верно? Да, это правильно. Однако в реальной жизни всегда будут встречаться ситуации, когда нам нужно кардинально изменить правила игры. Так что какие-то старые возможности просто перестанут существовать в новом мире. И их надо бы убрать. Получается противоречие (убирать или нет?). Разрешается оно следующим образом:
  • Устаревшие методы и/или интерфейсы следует оставить в программном коде
  • (Опционально) Устаревшие методы и/или интерфейсы следует убрать из заголовочников
  • Устаревшие методы и/или интерфейсы следует убрать из документации (SDK)
  • Реализацию устаревших методов следует изменить таким образом, чтобы внешний контракт соблюдался бы, возвращая осмысленные значения, но внутренняя кухня работала бы с новым ядром. Сделать это можно одним из следующих способов:
    • Пустая заглушка. Метод должен существовать, но возвращать пустые результаты (или ошибку вроде "not implemented"). Это самый простой путь, но применять его следует только тогда, когда старая функциональность вообще никак не применима в новой версии программы. К сожалению, для применения этого метода также необходимо, чтобы плагины корректно обрабатывали бы пустой результат или "not implemented".
    • Метод-переходник. Метод берёт параметры в старом формате, преобразует в новый формат и вызывает современный (новый) метод. Этот вариант относительно прост и применяется, когда вы обновляете метод (см. выше п2. "Изменение существующего метода").
    • Эмуляция. Применяется, когда вызов метода нельзя свести к переформулировке в новых терминах. В этом случае вы частично копируете код из старого варианта программы. Этот код реализует старое поведение, так как его ожидает код старых плагинов.
Опциональное удаление устаревшего кода из заголовочников стоит производить в тех (редких) случаях, когда вы хотите, чтобы старые плагины нельзя было скомпилировать с новым SDK, а только со старым SDK. Обычно этого следует избегать, но я упоминаю о такой возможности. Если вы решите почистить заголовочники, то все эти определения всё равно не исчезнут бесследно, а перейдут во внутренний код (потому что код ядра всё ещё должен уметь работать с этими определениями, т.к. они используются старыми плагинами).

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

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

Когда мы работаем с классами, то мы наследуем один класс от другого, подразумевая наследование функциональности. Т.е. есть, к примеру, TWinControl и есть TButton. Кнопка наследует оконный описатель у TWinControl - это часть функциональности.

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

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

Вот как это может выглядеть на практике. Пусть у нас есть интерфейс ICore, который выглядит так:
type
  ICore = interface
  ['{602AFD4B-D766-4352-BA77-91AACCB8981D}']
  // private
    function GetVersion: Integer; safecall;
  // public
    property Version: Integer read GetVersion;
  end;
Пусть в новой версии нашей программы мы хотим дать возможность плагинам узнавать друг о друге. Для этого нам нужны новые методы (свойства Count и индексное Plugins), а, следовательно, и новый интерфейс. Наивная реализация выглядела бы так:
type
  ICoreEx = interface(ICore)
  ['{AB739898-6A48-4876-A9FF-FFE89B409A56}']
  // private
    function GetCount: Integer; safecall;
    function GetPlugin(const AIndex: Integer): IPlugin; safecall;
  // public
    property Count: Integer read GetCount;
    property Plugins[const AIndex: Integer]: IPlugin read GetPlugin; default;
  end;
Но это - скорее привычка из работы с классами, чем реальная необходимость. Функциональность "перебрать плагины" никак не связана с "узнать версию сервера". Ну вообще никак. Зачем же связывать их наследованием? Поэтому правильный вариант будет выглядеть так:
type
  // Существует в v1.0
  ICore = interface
  ['{602AFD4B-D766-4352-BA77-91AACCB8981D}']
  // private
    function GetVersion: Integer; safecall;
  // public
    property Version: Integer read GetVersion;
  end;

  // Появился в v2.0
  IPlugins = interface
  ['{AB739898-6A48-4876-A9FF-FFE89B409A56}']
  // private
    function GetCount: Integer; safecall;
    function GetPlugin(const AIndex: Integer): IPlugin; safecall;
  // public
    property Count: Integer read GetCount;
    property Plugins[const AIndex: Integer]: IPlugin read GetPlugin; default;
  end;
Да, внутри программы-сервера оба этих интерфейса могут реализовываться одним и тем же классом (а иногда - и одним и тем же объектом). Это не должно вас заботить. Это нормально. Зато вас ничего не ограничивает, поэтому в будущем вы сможете разнести эти интерфейсы по разным объектам или классам.

При этом старые плагины как работали в Litware Application v1.0, так и продолжат работать в Litware Application v2.0 - потому что существующий для них контракт (интерфейс ICore) не изменился. Про новые возможности они ничего не знают и не используют их.

А новые плагины, написанные для Litware Application v2.0, в курсе про новую функциональность (интерфейс IPlugins), так что они могут её использовать:
var
  Core: ICore;
  Plugins: IPlugins;
begin
  ...
  // работа с возможностями v1.0 через Core
  if Supports(Core, IPlugins, Plugins) then
  begin
    // работа с возможностями v2.0 через Plugins
  end; 
end;
При таком формате плагин, изначально разработанный для Litware Application v2.0, сможет работать и в Litware Application v1.0, но новые возможности Litware Application v2.0, конечно, будут при этом недоступными.

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

Для простоты начнём с варианта нового метода. Пусть у нас снова есть интерфейс ICore, который выглядит так:
type
  ICore = interface
  ['{602AFD4B-D766-4352-BA77-91AACCB8981D}']
  // private
    function GetVersion: Integer; safecall;
  // public
    property Version: Integer read GetVersion;
  end;
Пусть в Litware Application v2.0 мы захотели добавить получение читабельного (human-readable) описания сервера. Тогда наивным способом будет такой код:
type
  ICore = interface
  ['{602AFD4B-D766-4352-BA77-91AACCB8981D}']
  // private
    function GetVersion: Integer; safecall;
  // public
    property Version: Integer read GetVersion;
  end;

  ICoreV2 = interface(ICore)
  ['{7984F1B9-3803-41AB-821D-97FD7E2A7D78}']
  // private
    function GetDescription: WideString; safecall;
  // public
    property Description: Integer read GetDescription;
  end;
И в этот раз наивное решение будет верным.

В этом случае расклад по плагинам ровно такой же, как и ранее: старые плагины не затронуты, потому что интерфейс ICore не менялся, а новые плагины в курсе про новые возможности/интерфейс (ICoreV2).

Отличие тут только в том, что плагин, который рассчитан только на Litware Application v2.0 и не собирается работать в Litware Application v1.0, может вместо такого кода:
var
  Core: ICore;
  CoreV2: ICoreV2;
begin
  ...
  // работа с возможностями v1.0 через Core
  ...
  Supports(Core, ICoreV2, CoreV2);
  Assert(Assigned(CoreV2));
  ...
  // работа с возможностями v2.0 через CoreV2
  ...
end;
Использовать такой код:
var
  Core: ICoreV2;
begin
  // ACore - некий параметр или свойство, из которого мы изначально получаем ICore
  Supports(ACore, ICoreV2, Core); 
  Assert(Assigned(CoreV2));
  ...
  // работа с возможностями v1.0 через Core
  ...
  // работа с возможностями v2.0 через Core
  ...
end;
В остальном эти два случая эквивалентны.

Остаётся рассмотреть последний вариант - "обновление существующих возможностей", "изменение существующего метода". Давайте начнём с примера. И снова - ICore:
type
  ICore = interface
  ['{602AFD4B-D766-4352-BA77-91AACCB8981D}']
  // private
    function GetVersion: Integer; safecall;
  // public
    property Version: Integer read GetVersion;
  end;
В этот раз мы хотели бы изменить метод (свойство) Version так, чтобы возвращать не только основную (major) версию, но и младшую (minor). Для этого нам необходимо изменить прототип метода, чтобы он возвращал не просто число, а запись из двух чисел.

В этом случае, хотя поверхностно наш случай похож на предыдущий (взаимосвязанность нового и старого интерфейсов), но посмотрите что при этом получается:
type
  ICore = interface
  ['{602AFD4B-D766-4352-BA77-91AACCB8981D}']
  // private
    function GetVersion: Integer; safecall;
  // public
    property Version: Integer read GetVersion;
  end;

  TVersion = packed record
    Major, Minor: Integer;
  end;

  ICoreV2 = interface(ICore)
  ['{602AFD4B-D766-4352-BA77-91AACCB8981D}']
  // private
    function GetVersionEx: TVersion; safecall;
  // public
    property VersionEx: TVersion read GetVersionEx;
  end;
Мы получаем, что у нас есть два варианта одного метода (новый и старый). Один метод устарел, другой - нет. При этом в рамках одного интерфейса (одной переменной) у нас есть доступ к обоим вариантам методом (новому и старому). Наверное, это не очень удачное решение?

Поэтому в случае замены метода лучше поступать как в случае с автономной возможностью - создание не связанного наследованием интерфейса:
type
  ICore = interface
  ['{602AFD4B-D766-4352-BA77-91AACCB8981D}']
  // private
    function GetVersion: Integer; safecall;
  // public
    property Version: Integer read GetVersion;
  end;

  TVersion = packed record
    Major, Minor: Integer;
  end;

  ICoreV2 = interface
  ['{602AFD4B-D766-4352-BA77-91AACCB8981D}']
  // private
    function GetVersion: TVersion; safecall;
  // public
    property Version: TVersion read GetVersion;
  end;
Отличие же в том, что в данном случае вызывающий будет использовать либо ICoreV2, либо ICore, но не оба сразу. В то время как выше вызывающий использовал и ICore и IPlugins.

Итого, у нас получается три случая:
  1. ISomeInterface = interface, IAnotherInterface = interface
  2. ISomeInterfaceV1 = interface, ISomeInterfaceV2 = interface
  3. ISomeInterfaceV1 = interface, ISomeInterfaceV2 = interface(ISomeInterfaceV1)

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

Заметьте, что вышеуказанные техники применимы и к внутренним интерфейсам, которые не документируются в SDK. Предположим, что вы реализуете какой-то интерфейс в Litware Application v1.0. Это внутренний интерфейс, не документированный для внешних клиентов. Поэтому вы вольны изменить его в любой момент, не беспокоясь об обратной совместимости с любым сторонним кодом.

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

И это применимо к любым интерфейсам - в том числе и внутренним.

Предположим, что вы решили нарушить это правило и использовать тот же IID для немного изменённого интерфейса в Litware Application v2.0. Поскольку это внутренний интерфейс, то вы не постесняетесь это сделать.

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

Теперь у этого патча есть проблемы. Он может вызвать IUnknown.QueryInterface и запросить интерфейс по этому IID, и он что-то получит в ответ. Только вот он не знает, это ему вернули версию интерфейса 1.0, или же версию 2.0. А если вы не в курсе, что такое могло произойти, то ваш патч просто предположит, что это версия интерфейса 2.0 - и если в действительности ему вернули версию 1.0, то начнут происходить всякие забавные вещи.

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

Вот почему вам нужно строго следовать правилам расширения функциональности даже для внутренних интерфейсов.

Совместимость уровня исходного кода

Всякий раз когда вы создаёте новую версию интерфейса, у вас возникает вопрос как быть с нумерацией версий и что делать с именем без суффикса. Есть несколько вариантов действий:
  1. Писать без суффиксов, после появления новой версии - переименовать интерфейсы в V1 и V2 (как мы только что это сделали).
  2. Оставить имя без суффикса для первой версии, дать суффикс V2 второй версии. Этот подход имеет то преимущество, что плагины, собранные с самым первым SDK, смогут быть перекомпилированны и с более поздним SDK без изменения кода.
  3. Изначать дать суффикс V1 всем интерфейсам, даже если вы не видите сейчас возможности для их будущего изменения. Всем последующим интерфейсам давать суффиксы. Аналогично, плагины, собранные с самым первым SDK, смогут быть перекомпилированны и с более поздним SDK без изменения кода.
Здесь мы видим, что появляется новое понятие совместимости - не как двоичная совместимость двух модулей по контракту, а как совместимость на уровне исходных кодов. Первый подход её не обеспечивает (и потому - не рекомендуется), а вот второй и третий - да.

Итого, остаётся два варианта:
  1. Если вы начали давать интерфейсам имена без суффиксов - не изменяйте их. А новым интерфейсам давайте суффиксы V2, V3 и т.д. Т.е. у вас будет ISomeInterface, ISomeInterfaceV2, ISomeInterfaceV3 и т.д.
  2. Если вы начали давать интерфейсам имена с суффиксами - просто продолжайте это делать. Т.е. у вас будет ISomeInterfaceV1, ISomeInterfaceV2, ISomeInterfaceV3 и т.д.
Впрочем, первый вариант из предыдущего списка сводится к первому варианту из последнего списка введением
type
  ISomeInterface = ISomeInterfaceV1;

В любом случае, в последнем (втором) случае у вас оказывается свободным имя интерфейса без версионных суффиксов. Его можно:
  1. Не использовать. Тогда весь код будет оперировать только с V1, V2 и т.д.
  2. Спроецировать на один из версионных имён:
    1. На последний (Vn)
    2. На первый (V1)
Случай 1 тривиален, а 2.2 не интересен (фактически он дублирует подход "Оставить имя без суффикса для первой версии, дать суффикс V2 второй версии", т.е. сводится к другому случаю). А вот 2.1 рассмотрим подробнее. При этом получится:
// В первом релизе:
type
  ISomeInterfaceV1 = interface
    ...
  end;

  ISomeInterface = ISomeInterfaceV1;
// В третьем релизе:
type
  ISomeInterfaceV1 = interface
    ...
  end;


  ISomeInterfaceV2 = interface(ISomeInterfaceV1)
    ...
  end;

  ISomeInterfaceV3 = interface(ISomeInterfaceV2)
    ...
  end;

  ISomeInterface = ISomeInterfaceV3;
Т.е. это такой вот "динамический" тип, который всегда будет указывать на самую последнюю версию интерфейса из SDK. Понятно, что мы не сможем использовать этот тип (который без суффикса) в прототипах функций/методов - потому что он может меняться. Но зато его можно использовать в переменных. Например:
var
  SI: ISomeInterface;
begin
  // ASI - интерфейс типа ISomeInterfaceV1, который нам как-то передают
  Supports(ASI, ISomeInterface, SI);
  Assert(Assigned(SI));
  // ... работа с SI типа ISomeInterface - всегда с самой последней версией
end;
Подобный код не является ужасно полезным, и я бы рекомендовал его избегать. Он очень хорошо работает, если весь ваш код лежит в одном проекте (одном исполняемом модуле), но крайне плохо - при межмодульном взаимодействии.

Поэтому в сухом остатке остаётся только единственно допустимая проекция ISomeInterface = ISomeInterfaceV1 (или наоборот, если вы изначально начали писать с ISomeInterface).

Заключение

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

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

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

Читать далее.

10 комментариев :

  1. > "динамический" тип, который всегда будет указывать на самую последнюю версию интерфейса

    Вот. Я такое встречал пару раз (в сторонних библиотеках), и всё никак не мог понять, что же мне в этом не нравится. Вроде и нормально, но подсознательно - что-то не так. Теперь всё понятно, спасибо.

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

    Но в схеме с плагинами это будет означать, что мы не используем минимально возможный вариант. Т.е. плагин, собранный для v2, в v1 работать не будет.

    ОтветитьУдалить
  3. Анонимный31 июля 2012 г., 6:33

    Увидел в серии статей пример плагина с использованием FMX и сразу назрел вопрос. А в Os X такие dll будут работать? Если будут, то и пример ядра с использованием FireMonkey не помешал бы.....

    ОтветитьУдалить
  4. Этот вопрос уже задавали.

    Судя по этому - должно работать без проблем: IUnknown serves as the base for Mac OS X's Core Foundation CFPlugIn framework.

    Но я не могу про это писать - у меня нет Apple-устройств.

    ОтветитьУдалить
  5. >Но я не могу про это писать - у меня нет Apple-устройств.

    А виртуальной машине не доверяете?
    http://www.youtube.com/watch?v=n7nMtJMdEyw&feature=player_embedded

    ОтветитьУдалить
  6. Эээ... это как бы личный блог. Т.е. пишу я про то, что мне лично интересно.

    Виртуальную машину использовать можно. А в чём интерес писать что-то под несуществующее (для тебя) устройство?

    ОтветитьУдалить
  7. Хочу заметить, что в виртуальной машине Мас аппаратное ускорение графики не работает. И также оно не работает на реальном Мас, если к нему не подключен монитор. Странно, правда? )

    ОтветитьУдалить
  8. Александр здравствуйте!

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

    Задача стоит такая: плагин управляет внешним устройством, отправляет, получаемые данные в основную программу. Из программы имеется доступ к элементам управления настройками приборов. Это все реализовалось без особых проблем.
    Считывание данных для ряда случаев должно происходить непрерывно. В dll я создаю поток, который получает данные с прибора и отправляет их в основную программу.

    Основная программы передает плагину интерфейс типа:
    IReceiverData = interface
    ['{F9BB2FF2-6099-4F12-B1AD-682C1B270284}']
    procedure SendData(AData:WideString); safecall;
    end;

    Если в потоке я вызываю этот интерфейс следующим образом:

    procedure TDeviceThread.SendData;
    begin
    FReceiver.SendData(FSendData);
    end;

    procedure TDeviceThread.Execute;
    ...
    Synchronize(SendData)
    ...
    end;

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

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

    Как простой вариант - синхронизацию может делать не плагин, а ядро (реализация SendData).

    ОтветитьУдалить
  10. Здравствуйте, Алексей.

    После прочтения вашей серии статей "Разработка системы плагинов", решил испробовать исходники. Но столкнулся со сложностями. В частности, с выгрузкой плагинов. Как правильно производить выгрузку плагинов?

    Ссылка на мой вариант исходников (delphi 2010): https://www.dropbox.com/s/nt2slcko465btft/PluginAPI_11%282%29.zip
    Зеркало: http://depositfiles.com/files/nu61px1jr

    1. После выгрузки плагина через контекстное меню "Unload plugin", файл плагина невозможно удалить.

    2. После выгрузки всех плагинов с помощью кнопки "Unload all plugins", не получается загрузить плагины используя кнопку "Load all plugins". Появляется Access violation:

    http://s42.radikal.ru/i097/1210/39/5db5d20001b8.jpg

    Пытался разными способами исправить, но всё безрезультатно.

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

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