Как стать автором
Обновить
85.11
ПСБ
Блог ИТ-команды ПСБ — банка из топ-4

Формализация принципа Open/Closed: как сохранить обратную совместимость с помощью SOLID

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров1.7K

Привет, Хабр! Меня зовут Дмитрий Сурков, я iOS-разработчик приложения для среднего и малого бизнеса ПСБ.

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

Реализация фич осуществляется в отдельных модулях — фреймворках и библиотеках. При разработке мы руководствуемся принципами SOLID. Разработку выделенного модуля важно вести в соответствии с принципом Открытости/Закрытости (система должна быть открыта для расширения, но закрыта для изменения). 

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

В данной статье, мы рассмотрим:

  • Пример на библиотеке дизайн-системы с компонентами, так как она используется в основном приложении и дополнительном фреймворке построения UI.

  • Что такое семантическое версионирование и как мы его используем.

  • Анализ и поиск решения наиболее частых нарушений обратной совместимости.

  • Итоги проделанной работы.

Исходная точка

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

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

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

На основе этого можно выделить следующую цепочку действий:

  1. Необходимо завести задачу на поднятие версии (≈ 5 мин).

  2. Создать ветку, внести изменения и проверить работоспособность, а еще внести правки, если необходимо (≈ 15-20 мин).

  3. Отправить на код-ревью (≈ 2-3 мин).

  4. Подвинуть задачу по статусам, в нашем случае необходим ресурс тестировщика (1-∞ мин).

  5. Пройти ревью, дождаться успешного выполнения пайплайна (≈ 60 мин).

  6. Влить в мастер.

  7. И снова по кругу...

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

Это довольно много для простого поднятия версии, поэтому мы решили разобраться, как мы можем сократить это время и свести к минимуму мажорные изменения. Давайте приступать!

Семантическое версионирование

По сути семантическое версионирование — это соглашение об именовании, которое мы применяем к версиям продукта, будь то приложение или собственная библиотека.

Схема версионирования
Схема версионирования

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

Основные компоненты версионирования

Мажор версия — почти все изменения, которые затрагивают публичный интерфейс и нарушают обратную совместимость. Т.е. требуют от клиента вносить правки после обновления, провоцируют появление ошибок и могут влиять на сборку проекта.

Минор версия — все изменения публичного интерфейса, которые не нарушают обратную совместимость.

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

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

Логи автоподнятия версий
Логи автоподнятия версий

Желтым выделен warning = поднятие минорной версии.
Красным выделен error = поднятие мажорной версии.
А далее идет описание того, что вызвало данные изменения. Если ничего нет, то это обычно патч.

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

Major версия

Изменения в public протоколе:

  • Удаление протокола

  • Изменение названия

  • Расширение новым типом, требующим реализации обязательных методов / свойств / соответствия, без дефолтной реализации в расширении протокола

  • Добавление или удаление метода / свойства

  • Изменение названия метода / свойства

  • Изменение типа возвращаемого значения метода / свойства

  • Изменение сигнатуры метода — добавление обязательных аргументов, изменение типов или названий аргументов

Изменения в public классах и структурах:

  • Удаление класса / структуры

  • Изменение названия

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

  • Изменение типа данных свойств

  • Удаление метода или свойства

Изменения в public перечислениях:

  • Удаление случая (case)

  • Добавление случая (case)

  • Переименование случая (case)

  • Изменение ассоциированных значений (associated values)

  • Изменение методов / свойств и их названий / типов / аргументов

Изменения во внешних зависимостях (зависимости SPM пакета):

  • Добавление и удаление зависимости — в случае, когда зависимость используется в коде и вызывает мажорные изменения

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

Minor версия

Изменения в public протоколе:

  • Добавление нового public протокола

  • Добавление свойства или метода с дефолтной реализацией через расширение протокола

Изменения в public классах и структурах:

  • Добавление нового public класса или структуры

  • Добавление инициализатора

  • Добавление свойств в инициализаторы с дефолтными значениями

  • Добавление свойств с дефолтным значением или вычисляемым свойством

  • Увеличение уровня доступа методов и свойств

Изменения в public перечислениях:

  • Добавление свойств с дефолтным значением или вычисляемым свойством

Изменения во внешних зависимостях (зависимости SPM пакета):

  • Добавление и удаление зависимости — в случае, когда зависимость используется в коде и вызывает минорные изменения

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

Patch версия

Изменения в public протоколе:

  • Расширение типом, который не нарушает обратную совместимость, не требует обязательной реализации и соответствия, а также не добавляет новый функционал, например добавление 'Sendable'

  • Изменение порядка объявления методов / свойств / типов

  • Изменение дефолтного значения свойства

Изменения в public классах и структурах:

  • Аналогичные 'Патч' изменениям в протоколах

  • Добавление не публичных методов / свойств / сущностей

Изменения в public перечислениях:

  • Аналогичные 'Патч' изменениям в протоколах

  • Добавление непубличных методов / свойств / сущностей

Изменения во внешних зависимостях (зависимости SPM пакета):

  • Удаление и добавление зависимостей — в случае, когда зависимость используется в коде и не вызывает мажорных и минорных изменений 

  • Повышение версии зависимости — при условии ненарушения совместимости, которая влечет к поднятию мажорной версии

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

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

Нарушение обратной совместимости

Добавление обязательных методов/свойств в публичный протокол

error: ABI breakage: func OldProtocol.makeSomething2() has been added as a protocol requirement

  public protocol OldProtocol {
    func makeSomething()
+   func makeSomething2()
}

Решения:

1. Еще раз подумать, а нужен ли вам этот метод. Это не шутка. Бывает, понимаешь, что данная доработка и вовсе не нужна.

2. Объявление метода в расширении протокола. Простой вариант обратной совместимости, но имеет ограничение — можно использовать только интерфейсы, объявленые в протоколе, нельзя переопределять.

public protocol OldProtocol {
    func makeSomething()
}
 
extension OldProtocol {

   public func makeSomething2() {
      // do something
      makeSomething()
   }
}

3. Дефолтная реализация в расширении протокола. Простой вариант обратной совместимости, можно переопределять, но имеет ограничение — можно использовать только интерфейсы, объявленные в протоколе.

public protocol OldProtocol {
    func makeSomething()
+   func makeSomething2()
}
 
extension OldProtocol {

   public func makeSomething2() {
      // do something
      makeSomething()
   }
}

4. Создать новый протокол. Такое решение можно применять, если новый метод не имеет зависимостей на старый протокол.

public protocol OldProtocol {
    func makeSomething()
}
 
public protocol NewProtocol {
   func makeSomething2()
}
 
extension SomeViewModel: NewProtocol {

   public func makeSomething2() {
      // do something
   }
}

Данное решение позволяет расширять функционал, не затрагивая старых клиентов.

let value: OldProtocol & NewProtocol

5. Сделать метод опциональным. Такое решение можно применять только для objc протоколов, если вы хотите расширить функционал необязательным в реализации методом.

@objc public protocol OldProtocol {
    func makeSomething()
+   @objc optional func makeSomething2()
}

Добавление обязательных свойств в публичный протокол

Способы аналогичны решению по добавлению методов в публичный протокол.

Добавление параметра в публичный метод или инициализатор

error: ABI breakage: constructor init(title:identifier:) has been removed

error: ABI breakage: func perform(title:) has been removed

public init(
   title: String? = nil,
+  subtitle: String? = nil
   identifier: String = String(describing: Self.self)
) {
   self.title = title
+  self.subtitle = subtitle
   self.identifier = identifier
}
 
public func perform(
    title: String,
+   subtitle: String? = nil
) {
    // do something
}

Установка дефолтного значения не помогает избежать ошибки. Решением является создать отдельный инициализатор / метод.

public struct ViewModel {

    public let title: String?
+   public let subtitle: String?
    public let identifier: String  
 
    // Will be removed in the future major versions.
    public init(
        title: String? = nil,
        identifier: String
    ) {
        self.init(
            title: title,
            subtitle: nil,
            identifier: identifier
        )
    }
     
+    public init(
+        title: String? = nil,
+        subtitle: String? = nil,
+        identifier: String
+    ) {
+        self.title = title
+        self.subtitle = subtitle
+        self.identifier = identifier
+    } 
 
    // Will be removed in the future major versions.     
    public func perform(
        title: String
    ) {
        // do something
    }
 
+    public func perform(
+        title: String,
+        subtitle: String? = nil
+    ) {
+        // do something
+    }
 }

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

public class ViewModel {

    public let title: String?
+   public private(set) var subtitle: String?
    public let identifier: String
 
    // Will be removed in the future major versions.
    public convenience init(
        title: String? = nil,
        identifier: String
    ) {
        self.init(
           title: title,
           subtitle: nil,
           identifier: identifier
        )
    }
     
+    public init(
+        title: String? = nil,
+        subtitle: String? = nil,
+        identifier: String
+    ) {
+        self.title = title
+        self.subtitle = subtitle
+        self.identifier = identifier
+    }
 
    // Will be removed in the future major versions.
    public func perform(
        title: String
    ) {
        // do something
    }
 
+    public func perform(
+        title: String,
+        subtitle: String? = nil
+    ) {
+        // do something
+    }
 }

⚠️ Обратите внимание! При вызове нового инициализатора внутри старого обязательно нужно указывать все параметры, иначе может произойти рекурсивный вызов, и компилятор выведет: warning - Function call causes an infinite recursion.

Удаление public протокола/сущности/метода/свойства

Если функционал устарел и существует новый аналог.

Публичные свойства, методы и кейсы из сущностей стоит помечать 'deprecated', чтобы клиенты не использовали их в новом функционале, а также указывать сообщение или название замены, если такое имеется.

error: ABI breakage: protocol MyProtocol has been removed

@available(*, deprecated, message:"Will be removed in the future major versions. Use `MyProtocolV2`")
public protocol MyProtocol {...}
 
public protocol MyProtocolV2 {
    @available(*, deprecated, message:"Will be removed in the future major versions. Use `MyProtocolV2.makeSomething2()`")
    func makeSomething()
    func makeSomething2()
    @available(*, deprecated, message:"Will be removed in the future major versions. Use `MyProtocolV2.somePropertyV2`")
    var someProperty { get }
    var somePropertyV2 { get }
}

Изменение названия public протокола

error: ABI breakage: protocol MyProtocol has been renamed to protocol MyProtocolV2

Добавляем 'typealias' со старым названием и помечаем протокол 'deprecated', чтобы клиенты не использовали в новом функционале.

@available(*, deprecated, message:"Will be removed in the future major versions. Use `MyProtocolV2`")
public typealias MyProtocol = MyProtocolV2
 
public protocol MyProtocolV2 { ... }

Поднятие версии платформы

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

Заключение

Ведя разработку таким образом, мы соблюдаем принцип Open / Closed. Не затрагиваем существующий функционал, а только расширяем его, добавляя дополнительные инициализаторы и методы, делая расширения на протоколы и сущности.

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

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

  • За три месяца в мастер было влито 27 мердж реквестов.

  • Из них 1 Major, 17 Minor,  9 Patch версий

  • Удалось исправить 6 мердж реквестов с мажорных на минорные

  • Учитывая данные, количество времени на процесс поднятия версии у клиента и их количество, мы сэкономили 6 мердж реквестов * 3 часа * 2 модуля ≈ 24 - 36 часов

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

Теги:
Хабы:
+10
Комментарии6

Публикации

Информация

Сайт
www.psbank.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
Наталья Низкоус