В этот раз я бы хотел немного поговорить о еще одном порождающем шаблоне проектирования из арсенала «Банды четырех» – «Строителе» («Builder»). Так вышло, что в ходе получения своего (пусть и не слишком обширного) опыта, я довольно часто видел, чтобы паттерн использовался в «Java»-коде вообще и в «Android»-приложениях в частности. В «iOS» же проектах, будь они написаны на «Swift» или «Objective-C», шаблон встречался мне довольно редко. Тем не менее, при всей своей простоте, в подходящих случаях он может оказаться довольно удобным и, как модно говорить, мощным.
Шаблон используется для замены сложного процесса инициализации конструированием нужного объекта шаг за шагом, с вызовом финализирующего метода в конце. Шаги при этом могут быть опциональными и не должны иметь строгой последовательности вызова.
Пример из «Foundation»
В случаях, когда нужный «URL» не зафиксирован, а конструируется из составляющих (например, адреса хоста и относительного пути до ресурса), вы наверняка пользовались удобным механизмом URLComponents
из библиотеки «Foundation».
URLComponents
– это, по большей части, просто класс, объединяющий множество переменных, которые хранят значения тех или иных компонентов «URL», а также свойство url
, которое возвращает соответствующий текущему набору компонентов «URL». Например:
var urlComponents = URLComponents()
urlComponents.scheme = "https"
urlComponents.user = "admin"
urlComponents.password = "qwerty"
urlComponents.host = "somehost.com"
urlComponents.port = 80
urlComponents.path = "/some/path"
urlComponents.queryItems = [URLQueryItem(name: "page", value: "0")]
_ = urlComponents.url
// https://admin:qwerty@somehost.com:80/some/path?page=0
По сути, приведенный выше пример использования – это реализация паттерна «Строитель». URLComponents
в этом случае выступает в роли собственно строителя, присвоение различным его свойствам (scheme
, host
и пр.) значений – это инициализация будущего объекта по шагам, а вызов свойства url
– это подобие финализируещего метода.
В комментариях развернулись жаркие баталии о «RFC»-документах, описывающих «URL» и «URI», поэтому, чтобы быть более точным, предлагаю для примера считать, что мы говорим только об «URL» удаленных ресурсов, и не принимаем во внимание такие «URL»-схемы, как, скажем, «file».
Вроде бы все хорошо, если пользоваться таким кодом нечасто и знать все его тонкости. Но что, если что-нибудь забыть? Например, что-нибудь важное, как адрес хоста? Как вы думаете, что будет результатом выполнения следующего кода?
var urlComponents = URLComponents()
urlComponents.scheme = "https"
urlComponents.path = "/some/path"
_ = urlComponents.url
Мы работаем со свойствами, а не методами, и никаких ошибок «выброшено» точно не будет. «Финализирующее» свойство url
возвращает опциональное значение, так может быть, мы получим nil
? Нет, мы получим вполне полноценный объект типа URL
с бессмысленным значением – «https:/some/path». Поэтому мне пришло в голову поупражняться написанием собственного «строителя» на основе описанного выше «API».
(Здесь должен был быть «эмодзи» «велосипед», но «Хабр» его не отображает)
Не смотря на сказанное выше, я считаю URLComponents
хорошим и удобным «API» для сборки «URL» из составных частей и, наоборот, «парсинга» составных элементов известного «URL». Поэтому на его основе мы сейчас и напишем собственный тип, собирающий «URL» из частей и обладающий (предположим) нужным нам в данный момент «API».
Во-первых, хочется избавиться от разрозненной инициализации путем присваивания новых значений всем нужным свойствам. Вместо этого реализуем возможность создания экземпляра строителя и присвоение значений всем свойствам с помощью методов, вызываемых по цепочке. Цепочка заканчивается финализирующим методом, результатом вызова которого и будет соответствующий экземпляр URL
. Возможно, вы встречали на своем жизненном пути что-нибудь вроде StringBuilder
у «Java» – примерно к такому «API» мы сейчас и будем стремиться.
Чтобы иметь возможность вызывать методы-шаги по цепочке, каждый из них должен возвращать экземпляр текущего строителя, внутри которого будет храниться соответствующее изменение. По этой причине, а также чтобы избавиться от множественного копирования объектов и от пляски вокруг mutating
-методов, особенно не задумываясь, объявим наш строитель классом:
final class URLBuilder { }
Объявим методы, задающие параметры будущего «URL», с учетом перечисленных выше требований:
final class URLBuilder {
private var scheme = "https"
private var user: String?
private var password: String?
private var host: String?
private var port: Int?
private var path = ""
private var queryItems: [String : String]?
func with(scheme: String) -> URLBuilder {
self.scheme = scheme
return self
}
func with(user: String) -> URLBuilder {
self.user = user
return self
}
func with(password: String) -> URLBuilder {
self.password = password
return self
}
func with(host: String) -> URLBuilder {
self.host = host
return self
}
func with(port: Int) -> URLBuilder {
self.port = port
return self
}
func with(path: String) -> URLBuilder {
self.path = path
return self
}
func with(queryItems: [String : String]) -> URLBuilder {
self.queryItems = queryItems
return self
}
}
Заданные параметры мы сохраняем в частных свойствах класса для дальнейшего использования финализируюим методом.
Еще одна дань «API», на котором мы основываем наш класс – это свойство path
, которое, в отличие от всех соседних свойств, не является опциональным, а в случае отсутствия относительного пути хранит в качестве своего значения пустую строку.
Чтобы написать этот, собственно, финализирующий метод необходимо подумать еще о нескольких вещах. Во-первых, «URL» обладает некоторыми частями, без которых он, как было обозначено в начале, перестает иметь смысл – это scheme
и host
. Первого мы «наградили» значением по умолчанию, поэтому забыв о нем, мы все равно получим, скорее всего, ожидаемый результат.
Со вторым дела обстоят чуть сложнее: ему нельзя присвоить какое-то значение по умолчанию. В таком случае у нас есть два пути: в случае отсутствия значения у этого свойства либо вернуть nil
, либо выбросить ошибку и предоставить клиентскому коду самому решать, что с ней делать. Второй вариант сложнее, но зато позволит недвусмысленно указать на конкретную ошибку программиста. Пожалуй, для примера, по этому пути мы и пойдем.
Еще один интересный момент связан со свойствами user
и password
: они имеют смысл только в том случае, если используются одновременно. Но что, если программист забудет присвоить одно из этих двух значений?
И, наверное, последнее, что необходимо учесть – это то, что результатом финализирующего метода мы хотим иметь значение свойства url
URLComponents
, а оно, в данном случае, не очень кстати является опциональным. Хотя при любом сочетании заданных значений свойств nil
мы не получим. (Значение будет отсутствовать только у пустого, только что созданного, экземпляра URLComponents
.) Чтобы преодолеть это обстоятельство, можно использовать !
– оператор «forced unwrapping». Но вообще-то не хотелось бы поощрять его использование, поэтому в нашем примере мы немного абстрагируемся от знаний тонкостей «Foundation» и будем считать обсуждаемую ситуацию системной ошибкой, возникновение которой не зависит от нашего кода.
Итак:
extension URLBuilder {
func build() throws -> URL {
guard let host = host else {
throw URLBuilderError.emptyHost
}
if user != nil {
guard password != nil else {
throw URLBuilderError.inconsistentCredentials
}
}
if password != nil {
guard user != nil else {
throw URLBuilderError.inconsistentCredentials
}
}
var urlComponents = URLComponents()
urlComponents.scheme = scheme
urlComponents.user = user
urlComponents.password = password
urlComponents.host = host
urlComponents.port = port
urlComponents.path = path
urlComponents.queryItems = queryItems?.map {
URLQueryItem(name: $0, value: $1)
}
guard let url = urlComponents.url else {
throw URLBuilderError.systemError // Impossible?
}
return url
}
enum URLBuilderError: Error {
case emptyHost
case inconsistentCredentials
case systemError
}
}
Вот, пожалуй, и все! Теперь покомпонентное создание «URL» из примера в начале может выглядеть так:
_ = try URLBuilder()
.with(user: "admin")
.with(password: "Qwerty")
.with(host: "somehost.com")
.with(port: 80)
.with(path: "/some/path")
.with(queryItems: ["page": "0"])
.build()
// https://admin:Qwerty@somehost.com:80/some/path?page=0
Разумеется, использование try
вне блока do
-catch
или без оператора ?
при возникновении ошибки заставит программу завершиться аварийно. Но мы предоставили «клиенту» возможность обрабатывать ошибки так, как он сочтет необходимым.
Да, и еще одна полезная особенность пошагового конструирования с помощью этого шаблона – возможность помещать шаги в разных частях кода. Не самый частый «кейс», но тем не менее. Спасибо akryukov за напоминание!
Заключение
Шаблон экстремально прост для понимания, а все простое – как известно, гениально. Или наоборот? Ну, неважно. Главное, что я, не покривив душой могу сказать, что он (шаблон), уже случалось, выручал меня в решении задач по созданию больших и сложных процессов инициализации. Например, процесс подготовки сессии связи с сервером в библиотеке, которую я писал для одного сервиса почти два года назад. Кстати, код – «open source» и, при желании, с ним вполне можно ознакомиться. (Хотя, конечно, с тех пор много воды утекло, и к этому коду прикладывались и другие программисты.)
Другие мои посты о шаблонах проектирования:
- «Фабричный метод» и «Абстрактная фабрика» во вселенной «Swift» и «iOS»,
- «Архитектурный шаблон «Итератор» («Iterator») во вселенной «Swift»,
- «Архитектурный шаблон «Посетитель» (“Visitor”) во вселенной «iOS» и «Swift».
А это мой «Twitter», чтобы удовлетворить гипотетический интерес к моей публично-профессиональной активности.