[Из песочницы] DevOps в разработке: автоматизация написания кода веб-приложений

Доброго времени суток, уважаемые Хабражители!

Сегодня DevOps находится на волне успеха. Практически на любой конференции, посвященной автоматизации, можно услышать от спикера мол «мы внедрили DevOps и тут и там, применили это и то, вести проекты стало значительно проще и т. д. и т. п.». И это похвально. Но, как правило, внедрение DevOps во многих компаниях заканчивается на этапе автоматизации IT Operations, и очень мало кто говорит о внедрении DevOps непосредственно в сам процесс разработки.

Мне бы хотелось исправить это маленькое недоразумение. DevOps в разработку может прийти через формализацию кодовой базы, например, при написании GUI для REST API.

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

Надеюсь данный материал будет вам интересен и полезен.

Ну что ж, начнем!

Предыстория


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

Процесс добавление новой фичи на старой архитектуре

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

  1. На back-end«e: создать модель для новой сущности (хуки), описать поля данной модели, описать всю логику действий (actions), которые данные модель может выполнять и т. д.
  2. На 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 — фреймворка, который бы:

  1. Содержал в себе базовый, максимально схожий для большинства приложений, функционал;
  2. Позволял бы генерировать front-end на лету, основываясь на структуре API.


Как подружить back-end и front-end?


Ну что ж, надо делать, подумали мы. Некий back-end у нас уже был, некий front-end тоже, но ни на сервере, ни на клиенте не было инструмента, который мог бы сообщить или получить данные о структуре API.

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

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

И спустя некоторое время мы его таки научили.

Версия 1.0 — что по итогу вышло


Архитектура фрейворка VSTUtils первых версий состояла из 3 условных частей и выглядела примерно так:

  1. Back-end:
    • Django и Python — вся логика связанная с моделями. На основе базовой модели Django Model мы создали несколько классов основных моделей VSTUtils. Все actions, которые могут выполнять данные модели мы реализовали с помощью Python;
    • Django REST Framework — генерация REST API. На основе описания моделей формируется REST API, благодаря которому происходит общение сервера и клиента;
  2. Прослойка между 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"],
                  },
              }
          }
      }
  3. Front-end:
    • JavaScript — механизм, парсящий схему OpenAPI и генерирующий представления. Данный механизм запускается один раз, при инициализации приложения на клиенте. Отправив запрос к API, он получает в ответ запрашиваемый JSON с описанием структуры API и, анализируя его, создает все необходимые JS объекты, содержащие параметры представлений моделей. Данный запрос к API довольно увесистый, поэтому мы его кэшируем и запрашиваем повторно только при обновлении версии приложения;
    • JavaScript SPA libs — рендеринг представлений и маршрутизация между ними. Данные библиотеки были написаны одним из наших front-end разработчиков. При обращении пользователя к той или иной странице, механизм рендеринга производит отрисовку страницы, на основе параметров сохраненных ранее в JS объектах представлений.


Таким образом, что мы имеем: у нас есть back-end, на котором описана вся логика, связанная с моделями. Затем в игру вступает OpenAPI, который на основе описания моделей формирует JSON с описанием структуры API. Далее эстафетная палочка передается клиенту, который анализируя сформированный OpenAPI JSON автоматически генерирует веб-интерфейс.

Внедрение фичи в приложение на новой архитектуре — как это работает


Помните задачу про добавление пользовательских хуков? Вот как бы мы ее реализовали в приложении на базе VSTUtils:

Процесс добавление новой фичи на новой архитектуре

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

  1. На back-end«e: берем и наследуемся от самого подходящего класса в VSTUtils, добавляем новый функционал, характерный для новой модели;
  2. На 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 месяцев и вот что у нас в итоге вышло:

новая архитектура fron-end VSTUtils

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, и весь контент приложения формируется в итоговом виде уже на клиенте. Но нашим проектам, зачастую высокая поисковая выдача не нужна и для нас это не так критично.


На этом мой рассказ подходит к концу, спасибо за внимание!

Полезные ссылки


© Habrahabr.ru