Архитектурный шаблон «Строитель» во вселенной «Swift» и «iOS»/«macOS»

    В этот раз я бы хотел немного поговорить о еще одном порождающем шаблоне проектирования из арсенала «Банды четырех» – «Строителе» («Builder»). Так вышло, что в ходе получения своего (пусть и не слишком обширного) опыта, я довольно часто видел, чтобы паттерн использовался в «Java»-коде вообще и в «Android»-приложениях в частности. В «iOS» же проектах, будь они написаны на «Swift» или «Objective-C», шаблон встречался мне довольно редко. Тем не менее, при всей своей простоте, в подходящих случаях он может оказаться довольно удобным и, как модно говорить, мощным.


    image


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


    image


    Пример из «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» и, при желании, с ним вполне можно ознакомиться. (Хотя, конечно, с тех пор много воды утекло, и к этому коду прикладывались и другие программисты.)


    Другие мои посты о шаблонах проектирования:



    А это мой «Twitter», чтобы удовлетворить гипотетический интерес к моей публично-профессиональной активности.

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 14

      –4
      Может не надо этого счастья в экосистему ios / mac?
        –1
        Во-первых, «URL» обладает некоторыми частями, без которых он, как было обозначено в начале, перестает иметь смысл – это scheme и host.

        кажется, что это уже ошибка. "/etc/passwd" – нормальный валидный ur

          +1
          "/etc/passwd" – нормальный валидный ur

          Это не так, согласно RFC3986: «The scheme and path components are required».
            0
            В документации про NSURL говорится:
            > The URLs employed by the NSURL class are described in RFC 1808, RFC 1738, and RFC 2732.

            и, похоже, что единственной обязательной частью является scheme
              0
              RFC3986 более актуальный документ. А особенности реализации конкретного NSURL не могут являться аргументом.
                0
                так какой хост в URL «file:///etc/passwd»?
                  +1
                  Никакой, его здесь нет. Здесь scheme и path, согласно RFC.
                  0
                  и это же билдер для NSURL, поэтому, как мне кажется, особенности его реализации должны быть аргументом
                    +1
                    Исходная фраза — ""/etc/passwd" – нормальный валидный ur".
                    Если бы вы сказали, что ""/etc/passwd" – нормальный валидный url для NSURL", никаких претензий бы не было.
            0

            Паттерн "Строитель" не нужен, если язык поддерживает передачу аргументов в функцию по имени и значения аргументов по-умолчанию.
            Swift поддерживает и то и другое.
            Паттерн билдер можно заменить на более простую функцию, без потери читаемости.


            Реализация с меньшим количеством бойлерплейта
            import Foundation
            
            enum URLBuilderError: Error {
                case emptyHost
                case inconsistentCredentials
                case systemError
            }
            
            func buildUrl(
                user: String? = nil,
                password: String? = nil,
                host: String? = nil,
                port: Int? = nil,
                path: String = "",
                queryItems: [String: String]? = [String: String](),
                scheme: String = "https"
                ) 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
            }

            Фабричная функция потом может быть вызвана вот так:


            _ = try buildUrl(
                user: "admin",
                password: "Qwerty", 
                host: "somehost.com",
                port: 80,
                path: "/some/path",
                queryItems: ["page": "0"])
              +1
              Это понятно, конечно. Но пост не о том, как поудобней сконструировать объект URL, а о конкретном шаблоне. Так сказать, познавательный, «для самых маленьких».

              P.S. Я бы назвал ваш метод makeURL() – так будет по официальному код-стайлу.
                +1

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


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

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

                  0
                  Спасибо за третий пункт – однозначно достойно упоминания, немного дополнил!
                –1
                Обкурятся солидом своим и сидят придумывают куда бы прикостылить очередной паттерн, хипстота ей-богу.

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

              Самое читаемое