Вступление
Знаете, что я не люблю? — Я не люблю, когда мне приходится через раз вылезать из моей уютной IDE, чтобы посмотреть в документации по API какого-то сервиса, какие там параметры есть в очередной сущности. Ну серьезно, некоторые сервисы если и создают свой SDK/обертку или что у них там, то мало кто озаботится тем, чтобы реализовать взаимодействие пользователя с оберткой посредством преобразователя данных (Data Mapper). А ведь это может сильно упростить жизнь простым смертным разработчикам при использовании API.
В связи с этим, я решил написать свой велосипед собственную обертку для API Битрикс24 на PHP, т.к. официальная — оставляет желать лучшего в вышеописанном плане.
В статье я:
- опишу образ моего мышления при обдумывании архитектуры по принципам GRASP и SOLID
- выстрелю себе в ногу, а затем исправлю ситуацию
- разработаю прототип с первыми сущностями и методами
Поиски подходящей архитектуры
В первую очередь нужно определиться каким образом пользователь будет использовать обертку. Если не продумать это на начальном этапе, и сразу приступить к наброскам архитектуры, то можно получить совсем не то, чего хотелось. Так-же, нужно определится с тем, какой функционал у нас будет отправной точкой, и начинать реализацию поэтапно, предварительно декомпозировав задачу. Если этот принцип нарушить, то у вас в конечном итоге может получится десинхронизация интерфейсов в одном из модулей программы, а может даже и в нескольких, даже если проект делается в одиночестве.
Я бы хотел, чтобы пользователь мог сделать какую-то элементарную часть своей работы за минимальное кол-во строк, но при этом не нарушать читабельность кода. Будем держать это в уме и пойдем изучим доступные способы взаимодействия с Б24.
В процессе изучения я обратил внимание, что к API можно обращаться посредством «входящих вебхуков», а так-же через приложение, авторизация которого происходит средствами OAuth2.0.
Наиболее простым способом мне видится реализация через «входящие вебхуки», но стоит понимать, что это не очень безопасный способ, да и не хочется терять поддержку приложений.
Тем более что официальная обертка только с ними и работает, а мы вроде как должны быть лучше. Значит наш велосипед должен поддерживать работу с разными движками авторизации, и быть способным переключать их на ходу.
Хорошо, приступим к наброскам кода. Если отталкиваться от моих слов, что разработчик должен написать минимальное количество символов, и у него все должно завестись, то код может выглядеть, например, так:
$leads = BX24Wrapper\Engine\WebHook::instanсe('https://b24-xxxxxx.bitrix24.ru/rest/1/********/')
->resources()
->leads()
->list(['STATUS' => 'NEW']);
$applicationEngine = BX24Wrapper\Engine\Application::instanсe('client_id', 'client_secret', 'access_token',...);
$leads = $applicationEngine
->resources()
->leads()
->list(['STATUS' => 'NEW']);
// даем возможность пользователю самому указать апи-метод и параметры, на всякий случай
$responseFromSomeCall = $applicationEngine->get('crm.some.list', ['SOME' => 'VALUE']);
То есть, у нас имеется 2 движка (WebHook и Application), эти движки принимают в себя либо «входящий вебхук», либо авторизационные данные приложения. Метод resources() вернет нам объект, содержащий список доступных ресурсов, с которыми мы можем работать(leads, deals, tasks…), а ресурсы в свою очередь — список доступных методов для работы с данными(list, add, update…). Ну, вроде все выглядит неплохо… да? Да, с точки зрения использования этот код действительно выглядит простым, но вот с архитектурной точки зрения мы выстрелили себе в ногу… из гаубицы.
Для того, чтобы осознать глубину проблемы, давайте нарисуем ориентировочную UML-схему с распределенными обязанностями:
Engine\AbstractBasic — отвечает за взаимодействие с API.
Resources – справочник, в котором перечислены ресурсы к которым мы можем обращаться.
Resource\AbstractBasic — сервис, предоставляющий список доступных API-методов, собирает готовый запрос для API-движка, и приводит ответ к формату сущности.
Entity\AbstractBasic — сущность-маппер, в которой будет хранится API-ответ.
Глядя на схему, уже становится очевидно, что Engine\AbstractBasic и Resources стали взаимозависимыми, потому как движок вызывает ресурсы, передавая в них свой собственный экземпляр. И, таким нехитрым образом, мы наступили на GRASP-паттерн «низкая связанность», который призывает нас минимизировать количество связей между компонентами программы.
Теперь взглянем на свойство _engine класса Resources – оно нигде не используется внутри данного класса и предназначено только для передачи оного в конструкторы Resource\AbstractBasic и прочих однотипных классов. То есть, класс стал своеобразным мостом между движком и конкретной схемой, что начинает отдавать еще и нарушением «высокого зацепления».
Так же, все эти методы-фабрики orders(), contacts() и т.д. приводят нас к тому, что класс Resources станет самым изменяемым и самым огромным, и это выводит нас на паттерн «Устойчивый к изменениям» и SOLID-принцип «Открытости/закрытости». В идеале, любое расширение программы должно производиться добавлением нового объекта, а не редактированием уже существующих.
Окей, мы поняли, что наше решение может и выглядит неплохо для рядового пользователя обертки, но усложняет нам дальнейшую поддержку и расширение приложения. Значит будем искать компромисс!
В первую очередь подумаем, как нам избавится от взаимной зависимости Engine и Resources? Resources, по сути своей, является обычным справочником со списком доступных ресурсов, с которыми можно взаимодействовать, и автоматически инициализирует объект выбранного.
Однако, что мешает пользователю аналогично вызвать ресурс напрямую? IDE прекрасно подскажет какие есть классы ресурсов, а значит нужда в этом справочнике — отпадает. Это также, позволит нам расширять архитектуру без внесений изменений в этот класс и уберет мост между конкретным ресурсом и движком.
В итоге, после всех изменений код для пользователя приобретает следующий вид:
$leads = Resource\Lead::instanсe(new Engine\WebHook('https://b24-xxxxxx.bitrix24.ru/rest/1/********/'))
->list(['STATUS' => 'NEW']);
$applicationEngine = BX24Wrapper\Engine\Application::instanсe('client_id', 'client_secret', 'access_token',...);
$leads = Resource\Lead::instanсe($applicationEngine)
->list(['STATUS' => 'NEW']);
$responseFromSomeCall = $applicationEngine->get('crm.some.list', ['SOME' => 'VALUE']);
Получилось весьма неплохо, может быть даже лучше, чем было до этого, но это уже субъективно. Посмотрим, как это теперь выглядит на схеме:
Как можно увидеть, схема стала значительно проще для понимания, хотя мы, по сути, всего лишь избавились от одного класса. Конечно, все согласно формуле: меньше классов → меньше связей. Если мы хотим вообще избавится от связей, то можно создать один единственный суперкласс, который и API-метод подберет, и запрос отправит, и сущности создаст, да еще и спинку вам почешет. Вот только поддерживать такой класс придется на тех еще костылях.
Поэтому очень важно грамотно распределить обязанности, чтобы получить баланс между связями и зацеплением. Ответом на вопрос: «что делает класс?» – всегда должно быть что-то одно, если в описании класса есть буква «и», то появляется явный намек на то, что класс делает больше одного направленного действия, а значит это, как минимум, два класса, и стоит подумать об их разделении.
Принимая в учет вышесказанное, и прочитав описание Resource\AbstractBasic, можно с уверенностью сказать, что он выполняет больше одного действия. Первое — это предоставление API-движку запроса в нужном формате, а второе – конвертация API-ответа к сущности.
Делегируем логику создания сущности отдельному классу – «билдеру», получаем максимально зацепленный класс Resource\AbstractBasic и можем приступить к написанию кода…
Беда в пакете
Потратив некоторое время на реализацию первого движка и тестов под него, я решил приступить к созданию первого ресурса. Открыв API-документацию, я начал пробегать глазами по методам, чтобы выбрать первого кандидата на почетную роль ресурса. И взор мой пал на весьма нестандартный метод пакетных запросов. Я на него посмотрел, подумал и понял, что нет смысла его реализовывать без запросов, которые можно в него вложить…
И на этом моменте в моей голове щёлкнуло! Если я в будущем буду поддерживать этот метод (а поддерживать я его буду, ведь на больших нагрузках он может сильно помочь), то как пользователи будут передавать в него запросы? Строить руками? Даже в официальной обертке есть поддержка пакетных запросов, а у меня нет?! Я никак не мог согласится с таким исходом и принялся переосмыслять текущую архитектуру.
За построение запросов отвечает Resource, и вроде вот оно решение — нужно вложить в этот ресурс другие ресурсы, и дело в шляпе!
$resource = Resource\Batch::instanсe(new Engine\WebHook('https://wtfkjg.ru/fdsgfds'));
$resource->sendBatchCalls([
'lead_ids' => Resource\FindByComm::find(['type' => 'EMAIL', 'values' => ['79780001122'], 'entity_type' => 'LEAD']),
'contacts' => Resource\Contact::list(['ID' => "\$result['lead_ids'][LEAD]"]),
]);
Но возникает проблема с тем, что при вызове какого-либо метода ресурса сразу отправляется запрос в API-движок, чего нам категорически не нужно. Значит придется отделять построение запроса от ресурса, и я выделил для этого отдельный класс Request. Этот класс сможет вызывать как ресурс, так и пользователь для построения пакетного запроса.
Но теперь я наткнулся на то, что добавление нового метода к ресурсу, влечет за собой еще и создание нового класса Request. К тому же, давать пользователю знание об еще одной группе классов мне не очень хочется…
В конечном итоге, я задал себе вопрос, а зачем нужна группа классов ресурсов? Разве они не стали обычными посредниками между запросом и движком? Да и в принципе, разве это не уменьшенная копия Resources, которую я выпилил до этого? Тут аналогичные нарушения «устойчивости» и «открытости/закрытости» из-за всех этих методов list, get и т.д.
Сделав волевое решение, я удалил этот элемент архитектуры, переделал интерфейс движка на то, чтобы он принимал Request-ы как в единичном, так и пакетном виде и перенес вызов билдера сущностей на ответственность движка. В итоге получилось следующее:
$engine = new Engine\WebHook('https://b24-xxxxxx.bitrix24.ru/rest/1/********/');
$leads = $engine->execute(Request\Lead\Items::all(['STATUS' => 'NEW'], ['ID' => 'DESC']));
$responseFromSomeGetCall = $engine->execute(Request\Custom::get('some.api.method', ['SOME' => 'PARAMS']));
$responseFromSomePostCall = $engine->execute(Request\Custom::post('some.api.method', ['SOME' => 'PARAMS']));
$response = $engine->execute([
'lead_ids' => Request\FindByComm\Find::byPhone('79780001122', 'LEAD'),
'contacts' => Request\Contact\Items::firstPage(['ID' => "\$result['lead_ids'][LEAD]"], ['ID' => 'ASC']),
]);
Пользователь сперва инициирует нужный ему движок, затем подготавливает запрос(ы), который посылает в метод execute движка, движок извлекает из запроса нужные данные, подготавливает полноценный http-запрос и отправляет его. При необходимости догружает элементы, если API-метод подразумевает работу с несколькими страницами выдачи. Затем отправляет полученный ответ в билдер, который в свою очередь создает на основе данных сущности-мапперы, которые отдаются пользователю для дальнейшей работы.
Запросы в целом очень однотипные, поэтому всю их логику можно инкапсулировать в целевых абстрактных классах, а в наследниках просто определять, на какой метод посылать запрос, и какую сущность-маппер создать.
Разумеется, не обошлось без проблем:
Во-первых, пострадала типизация, так как пользователь посылает запрос на метод execute, у которого на возвращении прописан mixed. А это значит, что IDE не поймет, что этот метод вернет в тот или иной момент. Но это не слишком большая проблема, т.к. со сторонней библиотекой нужно взаимодействовать через собственный фасад, а там и типизацию можно указать.
Вы же так и делаете, да?
Во-вторых, API-методов может быть очень много, и текущая архитектура строится на модели 1 метод = 1 класс. В итоге сотня-другая классов тут обеспечена. Не уверен, что это прямо проблема, лишние байты на жестком диске потратить не должно быть слишком страшно, но все равно стоит иметь ввиду.
Итоги
Через несколько итераций создания архитектуры, удалось создать достаточно гибкую и расширяемую архитектуру для обертки под API внешней системы. Хочу обратить ваше внимание, что эта архитектура не завязана конкретно на Б24. Она прекрасно подойдет практически ко всем сервисам!
Давайте быстренько пробежимся по ней взглядом с точки зрения GRASP и SOLID, и убедимся, что мы ничего не упустили.
GRASP:
- Информационный эксперт: каждый класс выполняет только свою работу и не нарушает инкапсуляцию.
- Создатель: объекты может создавать только пользователь и Builder, все остальные классы не участвуют в создании объектов.
- Контроллер: не вижу каким образом его можно было бы тут применить.
- Низкая связанность: компоненты имеют между собой минимальное количество связей.
- Высокое зацепление: проистекает от информационного эксперта, и, если он не нарушен, то и зацепление само собой в порядке.
- Полиморфизм: архитектура построена на взаимодействии интерфейсов, а не конкретных классов. Соответственно функционал можно легко модифицировать и расширять.
- Чистая выдумка: вынесение функционала создания сущностей в класс Builder.
- Перенаправление: все тот же Builder, потому что он является посредником между сущностью и пользователем.
SOLID:
- Принцип единственной ответственности: аналогично, что и с информационным экспертом только другими словами.
- Принцип открытости/закрытости: мы без лишнего труда можем добавить новый API-движок, новый запрос, другой билдер, и все благодаря интерфейсам.
- Принцип подстановки Барбары Лисков: текущая архитектура не предусматривает несоблюдение предоставленных интерфейсов, и, что бы их нарушить, еще нужно постараться.
- Принцип разделения интерфейса: интерфейсы предполагают максимально узкий спектр функционала, даже разделять нечего.
- Принцип инверсии зависимостей: все классы взаимодействуют друг с другом через интерфейсы, благодаря чему, зависимость от подробностей сведена, если не к 0, то к минимуму.
В целом, я считаю, что получилось весьма неплохо. Какие выводы из всего этого можно сделать? Как минимум, всегда пытайтесь выведать информацию о выполняемой задаче, если не на 100%, то хотя бы на 90%, что бы не приходилось потом переделывать сделанное, как я. Впрочем мне повезло, и все, что я исправил — это схему и интерфейс движка. А ведь мог бы создать десятки ресурсов, а потом их все со слезами на глазах удалять…
Так-же, советую вам распечатать шаблоны GRASP и принципы SOLID на бумаге и повесить где-нибудь возле рабочего стола, если вы с ними не знакомы. Это позволит вам легко их заучить и начать строить архитектуру приемлемого уровня. Даже если вы работаете на какой-нибудь галере «формошлепом», то хорошее знание этих вещей даст вам шанс пробиться в неплохую продуктовую компанию.
Так же буду благодарен за объективную критику как архитектуры и образа мышления, так и кода.
Ссылочка на код: https://github.com/Dangetsu/bitrix24-api-wrapper
Обертка пока что только в разработке, потому еще не оформлена, как подобает.
Код пишется под версию PHP 7.1, если будет достаточно желающих, то могу создать отдельную репку под меньшие версии.
Благодарности:
slyshkin за советы по изложенной теме и вычитку текста
Полезные ссылочки:
- Все схемы (открывается через веб-приложение draw.io)
- Курс Сергея Немчинского по паттернам
- Используемый code conventions
- Приёмы объектно-ориентированного проектирования. Паттерны проектирования