[Из песочницы] DevOps в разработке: автоматизация написания кода веб-приложений
Доброго времени суток, уважаемые Хабражители!
Сегодня DevOps находится на волне успеха. Практически на любой конференции, посвященной автоматизации, можно услышать от спикера мол «мы внедрили DevOps и тут и там, применили это и то, вести проекты стало значительно проще и т. д. и т. п.». И это похвально. Но, как правило, внедрение DevOps во многих компаниях заканчивается на этапе автоматизации IT Operations, и очень мало кто говорит о внедрении DevOps непосредственно в сам процесс разработки.
Мне бы хотелось исправить это маленькое недоразумение. DevOps в разработку может прийти через формализацию кодовой базы, например, при написании GUI для REST API.
В этой статье хотелось бы поделиться с вами решением нестандартного кейса, с которым столкнулась наша компания — нам довелось автоматизировать формирование интерфейса веб-приложения. Я вам расскажу о том, как мы пришли к данной задачей и что использовали для ее решения. Мы не считаем, что наш подход является единственно верным, но нам он очень даже нравится.
Надеюсь данный материал будет вам интересен и полезен.
Ну что ж, начнем!
Предыстория
Эта история началась примерно год назад: был прекрасный летний день и наш отдел разработки занимался созданием очередного веб-приложения. На повестке дня стояла задача по внедрению в приложение новой фичи — необходимо было добавить возможность создавать пользовательские хуки.
На тот момент архитектура нашего веб-приложения была построена таким образом, что для реализации новой фичи нам необходимо было сделать следующее:
- На back-end«e: создать модель для новой сущности (хуки), описать поля данной модели, описать всю логику действий (actions), которые данные модель может выполнять и т. д.
- На front-end«e: создать класс представления, соответствующий новой модели в API, вручную описать все поля, которые у данной модели есть, добавить все типы action«ов, которые данное представление может запустить и т. д.
Выходит, что нам одновременно сразу в двух местах, необходимо было сделать очень похожие изменения в коде, так или иначе, «дублирующие» друг друга. А это, как известно, не есть хорошо, поскольку при дальнейших изменениях, разработчикам нужно было бы вносить правки так же в двух местах одновременно.
Допустим, нам нужно будет поменять тип поля «name» cо «string» на «textarea». Для этого нам нужно будет внести данную правку в код модели на сервере, а затем сделать аналогичные изменения в коде представления на клиенте.
Не слишком ли всё сложно?
Ранее мы мирились с данным фактом, поскольку многие приложения были не очень большими и подход с «дублированием» кода на сервере и на клиенте имел место быть. Но в тот самый летний день, перед началом внедрения новой фичи, внутри нас что-то щелкнуло, и мы поняли, что дальше так работать нельзя. Текущий подход являлся весьма неразумным и требовал больших временных и трудовых затрат. К тому же, «дублирование» кода на back-end«е и на front-end«e могло в будущем привести к неожиданным багам: разработчики могли бы внести изменения на сервере и забыть внести аналогичные изменения на клиенте, и тогда все пошло бы не по плану.
Как избежать дублирования кода? Поиск решения
Мы стали задумываться, как нам можно оптимизировать процесс внедрения новых фич.
Мы задали сами себе вопрос: «Можем ли мы прямо сейчас избежать дублирования изменений в представлении модели на front-end«e, после любого изменения в ее структуре на back-end«e?»
Мы подумали и ответили: «Нет, не можем».
Тогда мы задали себе еще один вопрос: «Окей, в чем тогда заключается причина подобного дублирования кода?»
И тут нас осенило: проблема, по сути, в том, что наш front-end не получает данных о текущей структуре API. Front-end ничего не знает о моделях, существующих в API, до тех пор, пока мы сами ему об этом не сообщим.
И тогда у нас появилась идея: что если построить архитектуру приложения таким образом, чтобы:
- Front-end получал из API не только данные моделей, но и структуру этих моделей;
- Front-end динамически формировал представления на основе структуры моделей;
- Любое изменение в структуре API автоматически отображалось на front-end«e.
Внедрение новой фичи будет занимать гораздо меньше времени, поскольку будет требовать внесения изменений только на стороне back-end«a, а front-end автоматически все подхватит и представит пользователю должным образом.
Универсальность новой архитектуры
И тогда, мы решили подумать еще несколько шире: является ли новая архитектура пригодной только для нашего текущего приложения, или мы можем использовать ее где-то еще?
Ведь, так или иначе, почти все приложения имеют часть схожего функционала:
- почти по всех приложениях есть пользователи, и в связи с этим необходимо иметь функционал связанный с регистрацией и авторизацией пользователя;
- почти во всех приложениях есть несколько типов представлений: есть представление для просмотра списка объектов какой-то модели, есть представление для просмотра детальной записи одного, отдельного взятого, объекта модели;
- почти у всех моделей есть схожие по типу атрибуты: строковые данные, числа и т. д., и в связи с этим, нужно уметь работать с ними как на back-end«е, так и на front-end«е.
И поскольку наша компания часто выполняет разработку веб-приложений на заказ, мы подумали: зачем нам каждый раз изобретать велосипед и каждый раз разрабатывать схожий функционал с нуля, если можно один раз написать фреймворк, в котором были бы уже описаны все базовые, общие для многих приложений, вещи, и затем, создавая новый проект, использовать готовые наработки в качестве зависимостей, и при необходимости, декларативно их изменять в новом проекте.
Таким образом, в ходе долгих рассуждений у нас появилась идея о создании VSTUtils — фреймворка, который бы:
- Содержал в себе базовый, максимально схожий для большинства приложений, функционал;
- Позволял бы генерировать front-end на лету, основываясь на структуре API.
Как подружить back-end и front-end?
Ну что ж, надо делать, подумали мы. Некий back-end у нас уже был, некий front-end тоже, но ни на сервере, ни на клиенте не было инструмента, который мог бы сообщить или получить данные о структуре API.
В ходе поисков решения данной задачи наш глаз пал на спецификацию OpenAPI, которая на основе описания моделей и взаимосвязей между ними генерирует огромный JSON, содержащий всю эту информацию.
И мы подумали, что, по идее, при инициализации приложения на клиенте front-end может получать от API данный JSON и на его основе строить все необходимые представления. Остается только научить наш front-end все это делать.
И спустя некоторое время мы его таки научили.
Версия 1.0 — что по итогу вышло
Архитектура фрейворка VSTUtils первых версий состояла из 3 условных частей и выглядела примерно так:
- Back-end:
- Django и Python — вся логика связанная с моделями. На основе базовой модели Django Model мы создали несколько классов основных моделей VSTUtils. Все actions, которые могут выполнять данные модели мы реализовали с помощью Python;
- Django REST Framework — генерация REST API. На основе описания моделей формируется REST API, благодаря которому происходит общение сервера и клиента;
- Django и Python — вся логика связанная с моделями. На основе базовой модели Django Model мы создали несколько классов основных моделей VSTUtils. Все actions, которые могут выполнять данные модели мы реализовали с помощью Python;
- Прослойка между back-end«ом и front-end«ом:
- OpenAPI — генерация JSON«а с описанием структуры API. После того, как на back-end«е были описаны все модели, для них создаются views. Добавление каждой из views вносит необходимую информацию в итоговый JSON: Пример JSON’a — схема OpenAPI
{ // объект, хранящий в себе пары (ключ, значение), // где ключ - имя модели, // значение - объект с описанием полей модели. definitions: { // описание структуры модели Hook. Hook: { // объект, хранящий в себе пары (ключ, зачение), // где ключ - имя поля модели, // значение - объект с описанием свойств данного поля (заголовок, тип поля и т.д.). properties: { id: { title: "Id", type: "integer", readOnly: true, }, name: { title: "Name", type: "string", minLength:1, maxLength: 512, }, type: { title: "Type", type: "string", enum: ["HTTP","SCRIPT"], }, when: { title: "When", type: "string", enum: ["on_object_add","on_object_upd","on_object_del"], }, enable: { title:"Enable", type:"boolean", }, recipients: { title: "Recipients", type: "string", minLength: 1, } }, // массив, хранящий в себе имена полей, являющихся обязательными для заполнения. required: ["type","recipients"], } }, // объект, хранящий в себе пары (ключ, значение), // где ключ - путь предсталения (шаблонный URL), // значение - объект с описанием свойств представления. paths: { // описание структуры представлений по пути '/hook/'. '/hook/': { // схема представления для get запроса по пути /hook/. // схема представления, соответствующей странице просмотра списка объектов модели Hook. get: { operationId: "hook_list", description: "Return all hooks.", // массив, хранящий в себе объекты со свойствами фильтров, доступных для данного списка объектов. parameters: [ { name: "id", in: "query", description: "A unique integer value (or comma separated list) identifying this instance.", required: false, type: "string", }, { name: "name", in: "query", description: "A name string value (or comma separated list) of instance.", required: false, type: "string", }, { name: "type", in: "query", description: "Instance type.", required: false, type: "string", }, ], // объект, хранящий в себе пары (ключ, значение), // где ключ - код ответа сервера; // значение - схема ответа сервера. responses: { 200: { description: "Action accepted.", schema: { properties: { results: { type: "array", items: { // ссылка на модель, данные которой пришли в ответе от сервера. $ref: "#/definitions/Hook", }, }, }, }, }, 400: { description: "Validation error or some data error.", schema: { $ref: "#/definitions/Error", }, }, 401: { // ... }, 403: { // ... }, 404: { // ... }, }, tags: ["hook"], }, // схема представления для post запроса по пути /hook/. // схема представления, соответствующей странице создания нового объекта модели Hook. post: { operationId: "hook_add", description: "Create a new hook.", parameters: [ { name: "data", in: "body", required: true, schema: { $ref: "#/definitions/Hook", }, }, ], responses: { 201: { description: "Action accepted.", schema: { $ref: "#/definitions/Hook", }, }, 400: { description: "Validation error or some data error.", schema: { $ref: "#/definitions/Error", }, }, 401: { // ... }, 403: { // ... }, 404: { // ... }, }, tags: ["hook"], }, } } }
- OpenAPI — генерация JSON«а с описанием структуры API. После того, как на back-end«е были описаны все модели, для них создаются views. Добавление каждой из views вносит необходимую информацию в итоговый JSON:
- Front-end:
- JavaScript — механизм, парсящий схему OpenAPI и генерирующий представления. Данный механизм запускается один раз, при инициализации приложения на клиенте. Отправив запрос к API, он получает в ответ запрашиваемый JSON с описанием структуры API и, анализируя его, создает все необходимые JS объекты, содержащие параметры представлений моделей. Данный запрос к API довольно увесистый, поэтому мы его кэшируем и запрашиваем повторно только при обновлении версии приложения;
- JavaScript SPA libs — рендеринг представлений и маршрутизация между ними. Данные библиотеки были написаны одним из наших front-end разработчиков. При обращении пользователя к той или иной странице, механизм рендеринга производит отрисовку страницы, на основе параметров сохраненных ранее в JS объектах представлений.
Таким образом, что мы имеем: у нас есть back-end, на котором описана вся логика, связанная с моделями. Затем в игру вступает OpenAPI, который на основе описания моделей формирует JSON с описанием структуры API. Далее эстафетная палочка передается клиенту, который анализируя сформированный OpenAPI JSON автоматически генерирует веб-интерфейс.
Внедрение фичи в приложение на новой архитектуре — как это работает
Помните задачу про добавление пользовательских хуков? Вот как бы мы ее реализовали в приложении на базе VSTUtils:
Теперь благодаря VSTUtils нам не нужно ничего писать с нуля. Вот, что мы делаем, чтобы добавить возможность создавать пользовательские хуки:
- На back-end«e: берем и наследуемся от самого подходящего класса в VSTUtils, добавляем новый функционал, характерный для новой модели;
- На front-end«e:
- если представление для данной модели ничем не отличается от базового представления VSTUtils, то ничего не делаем, все автоматически отображается должным образом;
- если нужно как-то изменить поведение представления, с помощью механизма сигналов декларативно расширяем либо полностью изменяем базовое поведение представления.
В итоге, у нас получилось довольно неплохое решение, мы добились своей цели, наш front-end стал автогенерируемым. Процесс внедрения новых фич в существующие проекты заметно ускорился: релизы стали выходить раз в 2 недели, тогда как ранее мы выпускали релизы раз в 2–3 месяца с гораздо меньшим количеством новых фич. Хотелось бы заметить, что команда разработчиков осталась прежней, такие плоды нам дала именно новая архитектура приложения.
Версия 1.0 — перемен требуют наши сердца
Но, как известно, нет предела совершенству, и VSTUtils не стал исключением.
Не смотря на то, что нам удалось автоматизировать формирование front-end«а, получилось не прям то решение, которое мы изначально хотели.
Архитектура приложения на стороне клиента не была досконально продумана, и получилась не столь гибкой, какой могла бы быть:
- процесс внедрения перегрузок функционала был не всегда удобен;
- механизм парсинга схемы OpenAPI был не оптимальным;
- рендеринг представлений и маршрутизациями между ними осуществлялась с помощью самописных библиотек, что нас тоже не устраивало по ряду причин:
- данные библиотеки не были покрыты тестами;
- к данным библиотекам не было документации;
- у них не было никакого сообщества — в случае обнаружения в них багов или ухода сотрудника, написавшего их, поддержка такого кода была бы очень затруднительной.
И поскольку в нашей компании мы придерживаемся DevOps подхода и стараемся наш код максимально стандартизировать и формализовать, то в феврале этого года мы решили провести глобальный рефакторинг front-end«a фреймворка VSTUtils. У нас было несколько задач:
- формировать на front-end«е не только классы представлений, но и классы моделей — мы поняли, что было бы правильней отделять данные (и их структуру) от их представления. К тому же, наличие нескольких абстракций в виде представления и модели значительно бы облегчило добавление перегрузок базового функционала в проектах на базе VSTUtils;
- использовать для рендеринга и маршрутизации протестированный фреймворк, с большим сообществом (Angular, React, Vue) — это позволит нам отдать всю головную боль с поддержкой кода, связанного с рендерингом и маршрутизацией внутри нашего приложения.
Рефакторинг — выбор JS фреймворка
Среди наиболее популярных JS фреймворков: Angular, React, Vue, наш выбор пал на Vue поскольку:
Версия 2.0 — результат рефакторинга front-end«а
Процесс глобального рефакторинга front-end«а VSTUtils занял около 4 месяцев и вот что у нас в итоге вышло:
Front-end фреймворка VSTUtils по-прежнему состоит из двух больших блоков: первый занимается парсингом схемы OpenAPI, второй — рендерингом представлений и маршрутизацией между ними, но оба этих блоков перенесли ряд существенных изменений.
Был полностью переписан механизм, парсящий схему OpenAPI. Изменился подход к парсингу этой схемы. Мы постарались сделать архитектуру front-end«а максимально похожей на архитектуру back-end«a. Теперь на стороне клиента у нас есть не просто единая абстракция в виде представлений, теперь у нас есть еще абстракции в виде моделей и QuerySet«ов:
- объекты класса Model и его потомков — объекты, соответствующие абстракции Django Models на стороне сервера. Объекты данного типа содержат в себе данные о структуре модели (имя модели, поля модели и т. д.);
- объекты класса QuerySet и его потомков — объекты, соответствующие абстракции Django QuerySets на стороне сервера. Объекты данного типа, содержат в себе методы, позволяющие выполнять запросы к API (добавление, изменение, получение, удаление данных объектов моделей);
- объекты класса View — объекты, хранящие в себе данные о том, каким образом нужно представить модель на той или иной странице, какой шаблон использовать для «рендеринга» страницы, на какие другие представления моделей может ссылаться данная страница и т. п.
Блок, отвечающий за рендеринг и маршрутизацию, тоже заметно преобразился. Мы отказались от самописных JS SPA библиотек в пользу фреймворка Vue.js. Мы разработали собственные Vue компоненты, из которых строятся все страницы нашего веб-приложения. Маршрутизация между представлениями осуществляется с помощью библиотеки vue-router, а в качестве реактивного хранилища состояния приложения мы используем vuex.
Хотелось бы также отметить, что на стороне front-end«а реализация классов Model, QuerySet и View не зависит от средств реализации рендеринга и маршрутизации, то есть если мы вдруг захотим перейти от Vue к какому-то другому фреймворку, например на React или на что-то новое, то все что нам нужно будет сделать, это переписать компоненты Vue на компоненты нового фреймворка, переписать роутер, хранилище, и все — фреймворк VSTUtils снова будет работоспособен. Реализация классов Model, QuerySet и View останется прежней, поскольку никак не зависит от Vue.js. Мы считаем, что это является весьма неплохим подспорьем для возможных будущих изменений.
Подведем итоги
Таким образом, нежелание писать «дублирующий» код вылилось в задачу по автоматизации формирования front-end«a веб-приложния, которая была решена с помощью создания фреймворка VSTUtils. Нам удалось построить архитектуру веб-приложения так, что back-end и front-end гармонично дополняют друг друга и любое изменение в структуре API автоматически подхватывается и отображается должным образом на клиенте.
Преимущества, которые мы получили от формализации архитектуры веб-приложения:
- Релизы приложений, работающих на базе VSTUtils стали выходить в 2 раза чаще. Это связанно с тем, что теперь для внедрения новой фичи, зачастую, нам необходимо добавить код только на back-end«e, front-end автоматически сформируется — это значительно экономит время;
- Упростили обновление базового функционала. Так как теперь весь базовый функционал собран в одном фреймворке, то для того, чтобы обновить какие-то важные зависимости или внести улучшение в базовый функционал, нам необходимо внести правки только в одном месте — в кодовой базе VSTUtils. При обновлении версии VSTUtils в дочерних проектах все нововведения автоматически подхватятся;
- Поиск новых сотрудников стал легче. Согласитесь, гораздо проще найти разработчика под формализованный стек технологий (Django, Vue), чем искать человека, который согласится работать с неизвестным самописом. Результаты поиска разработчиков, упомянувших в своем резюме Django или Vue на HeadHunter«е (по всем регионам):
- Django — найдено 3 454 резюме у 3 136 соискателей;
- Vue — найдено 4 092 резюме у 3 747соискателей.
К недостаткам подобной формализации архитектуры веб-приложения можно отнести следующее:
- За счет парсинга схемы OpenAPI инициализация приложения на клиенте занимает чуть больше времени, чем ранее (примерно на 20–30 миллисекунд дольше);
- Неважная поисковая индексация. Дело в том, что в данный момент мы никак не задействуем серверный рендеринг в рамках VSTUtils, и весь контент приложения формируется в итоговом виде уже на клиенте. Но нашим проектам, зачастую высокая поисковая выдача не нужна и для нас это не так критично.
На этом мой рассказ подходит к концу, спасибо за внимание!