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

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

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

1. Ограничьте поверхность API

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

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

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

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

Еще одна проблема с большой поверхностью API  —  в том, что она дороже: поверхность нуждается в обслуживании. Кроме того, чем больше поверхность, тем выше вероятность появления ошибок. А исправление ошибок обходится дорого.

Следствие: предоставить единственный способ выполнения операции

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

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

  1. Вводит клиента в заблуждение. Какой из альтернатив ему придерживаться?
  2. Может привести к ошибкам, которые необходимо исправить.
  3. Вводит двойные стандарты. В следующий раз, когда придется внедрять какую-либо функцию, мы потратим время на то, чтобы выбрать реализацию. Кроме того, так удваивается объем работы.

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

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

2. Отдавайте должное семантическому версионированию

Семантическое версионирование  —  это стандарт, используемый в промышленной разработке для аннотирования конкретной версии SDK. Он основан на трех числах, разделенных точками: M.m.p.

  1. Первое число  —  M  —  обозначает основную (мажорную) версию библиотеки.
  2. Второе число  — m  —  обозначает младшую (минорную) версию библиотеки.
  3. Третье число  —  p  —  обозначает патч-версию библиотеки.

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

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

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

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

3. Предоставляйте информативные сообщения об ошибках

Представьте, что нам нужно использовать тип, определенный в библиотеке. Этот тип имеет отказоустойчивый инициализатор. Какую альтернативу вы предпочитаете: инициализатор init, который возвращает nil при сбое, или инициализатор который выдает ошибку, сообщающую вам, почему произошел сбой?

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

struct PInt {
enum Error: Swift.Error {
case notPrime
}

var value: Int

// ПЛОХО: Вызывающая функция не знает, почему происходит сбой инициализации
init?(maybeValue: Int) {
guard PInt.isPrime(maybeValue) else {
return nil
}
self.value = maybeValue
}

// ХОРОШО: Если инициализация сбоит, вызывающая функция знает, что ей необходимо передать простое целое число
init(value: Int) throws {
guard PInt.isPrime(value) else {
throw Error.notPrime
}
self.value = value
}

// Взято и адаптировано из источника: https://en.wikipedia.org/wiki/Primality_test#C#
static func isPrime(_ value: Int) -> Bool {
if [2, 3].contains(value) {
return true
}

if value <= 1 || value % 2 == 0 || value % 3 == 0 {
return false
}

for i in stride(from: 5, through: Int(sqrt(Double(value))), by: 6) {
if value % i == 0 || value % (i + 2) == 0 {
return false
}
}

return true
}
}

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

Следствие: никаких падений

Как следствие, внутренний модуль никогда не должен намеренно вызывать падений (crash). Да, некоторые случаи структурно недоступны, и может возникнуть соблазн для упрощения кода воспользоваться ! или fatalError.

Я предлагаю избегать таких решений по крайней мере по двум причинам:

  1. Нет ничего неприятнее, чем видеть, как “падает” твое приложение и обнаруживать, что это происходит из-за сторонней библиотеки.
  2. Усложняется тестирование вашего кода. Невозможно чисто протестировать ветку кода, которая содержит fatalError.

Есть очень конкретная причина, по которой трудно тестировать функции, содержащие fatalError. Сигнатура FatalError выглядит следующим образом:

func fatalError(
_ message: @autoclosure () -> String = String(),
file: StaticString = #file,
line: UInt = #line
) -> Never

Она возвращает специальный тип Never. Сигнатура сообщает компилятору, что эта функция никогда не завершится. Единственный способ протестировать функцию, которая никогда не возвращается, — это заблокировать ее выполнение в бесконечном цикле. У вас в тестах будет появляться зависший поток всякий раз, когда вам нужно будет проверить наличие fatalError-ов. Если таких ошибок много, набор тестов никогда не сможет завершиться, потому что заканчиваются потоки для выполнения оставшихся тестов.

4. Всегда уважайте клиентское приложение

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

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

Мы всегда должны помнить об этих типах и избегать устанавливать свои собственные делегаты. Если мы установим их вслепую, то рискуем заменить те, которые установлены приложением.

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

class SDKNavigationDelegate: NSObject, UINavigationControllerDelegate {

weak private(set) var navigationController: UINavigationController?
weak private(set) var appDelegate: UINavigationControllerDelegate?

init(navigationController: UINavigationController) {
self.navigationController = navigationController
self.appDelegate = navigationController.delegate
super.init()
navigationController.delegate = self
}

func unload() {
self.navigationController?.delegate = self.appDelegate
}

// MARK: - Реализация делегата
func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool
) {
guard self.viewControllerFromLibrary(viewController: viewController) else {
self.appDelegate?.navigationController?(
navigationController,
didShow: viewController,
animated: animated
)
return
}

// выполняется какой-нибудь специфичный для SDK код
}

// MARK: Помощник
private func viewControllerFromLibrary(viewController: UIViewController) -> Bool {
let sdkBundle = Bundle(for: Self.self)
let vcBundle = Bundle(for: type(of: viewController))

return sdkBundle === vcBundle
}

}

В этом примере мы можем видеть, как обернуть UINavigationControllerDelegate.

Во-первых, понадобится пара свойств для отслеживания navigationController и appDelegate. Они должны быть weak, чтобы избежать циклических ссылок. После сохранения этих объектов в свойствах можно безопасно изменить delegate.

Метод unload необходимо вызывать, когда SDK вот-вот будет сброшена/закрыта. В этом методе мы просто заменяем текущий делегат тем, который ранее сохранили.

Наконец, в методах делегата мы:

  1. Проверяем, действительно ли нужно выполнять какую-то операцию в SDK.
  2. Если нет, то вызываем исходный метод appDelegate и позволяем приложению вести себя так, как если бы SDK не было.

5. Всегда проверяйте входные данные

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

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

Это не только гарантирует, что во время компиляции мы работаем с допустимыми типами, но также уменьшает запах кода, называемый одержимостью примитивными типами: когда примитивные типы применяются для объектов, специфичных для домена. creditCardNumber  —  очень хороший пример; вместо использования простой String, которая не дает никаких гарантий относительно свойств кредитной карты, можно задействовать правильный тип, как показано ниже:

struct CreditCardNumber {
typealias StringLiteralType = String

enum Error: Swift.Error {
case empty
case wrongNumberOfCharacters
case characterMustBeDigts
}

let number: String

init(number: String) throws {
guard !number.isEmpty else {
throw Error.empty
}

guard number.count == 16 else {
throw Error.wrongNumberOfCharacters
}

guard number.allSatisfy(\.isNumber) else {
throw Error.characterMustBeDigts
}

self.number = number
}
}

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

У этого подхода есть еще два преимущества:

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

6. Пишите четкую и краткую документацию

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

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

Хорошая документация включает в себя:

  • Что делает метод.
  • Каковы его параметры.
  • Что он возвращает.
  • Какие ошибки он выдает и когда он это делает.
  • Любые конкретные детали, которые могут быть полезны клиенту: например, сложность.

Заключение

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

  1. Ограничьте поверхность API и предоставьте только один способ выполнения операции.
  2. Соблюдайте семантическое версионирование.
  3. Предоставляйте информативные сообщения об ошибках  —  и никогда не допускайте падений.
  4. Всегда уважайте приложение клиента.
  5. Проверяйте входные данные.
  6. Пишите содержательную документацию.

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

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

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Riccardo Cipolleschi: 6 Principles To Write Better Modules for Your iOS Applications

Предыдущая статьяЛучшие способы вызова API на Javascript
Следующая статьяGoogle Test: интеграция модульных тестов в C/C++ проекты