Это новый доклад Ruby Russia 2022, в котором Анвар Туйкин и Михаил Поспелов рассказывают о том, как в Toptal учили разработчиков писать правльно оформленный код. Ниже подробный текст о том, почему гайдлайны не всегда работают, что делать, чтобы они работали, и можно ли это автоматизировать.

Toptal — это огромный монолит на Ruby, сотни разработчиков и миллионы написанных строк кода. Мы используем GraphQL, которого при таких масштабах тоже немало: больше 20 схем. Чтобы раз за разом не повторять типовые ошибки и писать похожий код, мы разработали правила готовки для GraphQL внутри компании. Но правила не работают сами по себе, поэтому мы хотим рассказать о наших копах для rubocop, матчерах для rspec и генераторах: какие части GraphQL мы проверяем, почему это важно и что из наших лучших практик вы можете позаимствовать для своих проектов.

Наш сервис для фрилансеров работает с 2011 года. В компании работает больше 450 программистов, 150 из которых Ruby-разработчики. Соответственно, это большой Ruby-монолит, который мы постепенно распиливаем на микросервисы. Пять лет назад мы решили привнести в нашу работу GraphQL.

Wild Wild GraphQL

GraphQL сам по себе прекрасен, но есть нюансы.

Каждая команда работает по-своему, поэтому мы напилили уже больше 20 различных схем. Таким образом, даже при переходе из одной команды в другую теряется экспертиза, а стороннему программисту влиться еще сложнее.

Для решения этой проблемы мы решили разработать собрать best practices, чтобы наши инженеры знали о том, как надо пилить GraphQL. Задумка была в том, чтобы упростить разработку, уменьшить количество ошибок и ответить на три вопроса:

  • Как писать (т. е.  правила реализации).

  • Как тестировать (матчеры, структуры тестов и т. д.).

  • Как взаимодействовать (API-дизайн).

Однако оказалось, что гайдлайны не работают, потому что:

  1. документацию никто не читает,

  2. сложно освоить и запомнить все за одно прочтение,

  3. гайдлайны обязательны, но не необходимы, т. е. можно написать код, не соответствующий гайдлайнам.

Для начала необходимо понять, откуда появились эти стандарты и что такое наши схемы. Наши схемы — это, по сути, наборы абстракций, в частности, GraphQL-абстракций, знакомых разработчикам, под нашим соусом.

GraphQL-схема описывает набор типов. Типы нужны для получения и модификации данных. Данные мы получаем из корневого типа query, изменяем данные из корневого типа mutation. Две этих типа являются входными точками в схему.

Типичная схема
Типичная схема

В данном примере у нас есть два поля — node и nodes. Как мы видим, Talent реализует этот интерфейс, и через поля node и nodes мы можем получить Talent. Talent также имеет поля (nullable или не nullable в зависимости от домена, над которым мы работаем).

Надежность в runtime

В наших схемах для реализации того или иного типа мы используем абстракцию под названием Entity. Entity — это Ruby class, который делегирует вызовы к переданному объекту, добавляя GQL-специфики. Так как мы не используем статическую типизацию, на вход может прийти разный объект, что может повлечь некорректную работу реализации.

Передаваемый в Entity объект
Передаваемый в Entity объект

Чтобы компенсировать отсутвие статичекой типизации, а так же привнести гарантии на передаваемый объект, мы добавили специальный DSL — это object_type. Мы проверяем, что Entity (класс, который оборачивает структуры, в частности, ActiveRecord-модели, чтобы мы могли, например, отделить реализацию, делегирование, бизнес-логику моделей от уровня GraphQL) — объект типа Talent класса ActiveRecord. Если это не так, мы возвращаем GraphQL-ошибку.

Правильный ID
Правильный ID

Следующая runtime-проверка. ID — это Base64, в который закодирован Тип и число (ID объекта в БД). Клиент может сгенерировать такой ID на своей стороне, и передать не то, что мы хотим. Нам же необходимо, чтобы клиент передал то, что объявлено в типах, например, Talent. Соответственно, мы проверяем что ID типа Talent.

Наличие AR preload context
Наличие AR preload context

Следующая гарантия, которую мы проверяем в режиме реального времени, наличие у объектов и инстансов модели свойства — Lazy context (привнесенное гемом, который мы используем, чтобы бороться с N+1). Если контекст отсутствует, есть вероятность появления проблемы N+1.

N+1 ar lazy period
N+1 ar lazy period

N+1 AR lazy period — волшебный гем, который позволяет решать N+1, так же как includes, preload в Rails, но без явного указания, что именно мы сейчас будем загружать.

Как гем делает это? Есть какая-то входная точка и мы инстанциируем список каких-то объектов, например, таланты, и всем им присваиваем ссылку на один и тот же контекст (т. е. у всех есть ссылка на какой-то контекст). Когда мы подгружаем следующую ассоциацию, мы проверяем наличие контекста, и кто с этим контекстом связан. Соответственно, вместо того, чтобы подгрузить что-то у одного таланта, мы подгружаем это сразу у десяти талантов.

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

Надежность при тестировании

Следующая потребность — надежность при тестировании. К сожалению, у нас много абстракций, и они связаны. В идеальном мире абстракции не связаны и не ссылаются на что-либо, все функции иммутабельны, код хорошо покрывается юнит-тестами. Однако в реальности необходимо связать типы с моделями, а также с entity и т. д.

Описанные гайдлайны основаны на реальных фактах. Соответственно, эти гарантии необходимо проверять. Мы используем матчеры.

Типичные матчеры:

  • have_operations_for_mutations

Соответствующий гайдлайн предусматривает, что на каждую мутацию, т. е. точки входа для модификации состояния, есть operation, т. е. объект, который проверяет предусловие для выполнения данной модификации. Например, мы хотим добавить referrer к таланту, указать, кто его пригласил. Если же referrer уже существует, мы не можем его изменить. Типичный пример для таких операций — это модальные окна, в которых после проверки operation либо мутация выполняется, либо отображается ошибка пользователю.

  • be_compatible_with_policies

be_competitive_with_policies — проверка уровня авторизации для каждого типа. Авторизованный пользователь может видеть только данные, к которым у него имеется доступ. В данном примере приведена типичная реализация проверки. TalentPolicy имеет два поля (full_name и activated_at), и реализация предусматривает, что если пользователь не имеет соответствующего доступа, поле зануляется. Если поле не nullable, мы «бьем по рукам» с помощью матчера.

  • contain_all_gql_fields_from — это проверка на coverage.

Разумеется, есть возможность использовать обычный Ruby coverage, но в этом случае происходит делегация, а слой достаточно тонкий, и мы не хотим их смешивать, мы хотим знать, что у нас все хорошо на уровне GraphQL. Поэтому при тестировании типа мы проверяем, что все поля, которые объявлены в этом типе, присутствуют спеке для типа.

  • be_n1_efficient

С помощью этого матчера мы проверяем, что при передаче какого-либо блока не происходят N+1-запросы. Мы используем волшебный гем N1, который хранит хэш, в котором хранятся call-stack-и и хэш-запросы. Соответственно, если call-stack-и и хэш-запросы совпадают (блок всегда работает на нескольких инстансах одного типа), матчер упадет с ошибкой.

Контракт, подписанный кодом

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

Копы:

  • подсказывают, как писать код с помощью автокоррекции;

  • способствуют постепенному погружению разработчика, вместо того, чтобы вывалить на него «всё лучшее» (best practices) разом;

  • помогают анонсировать новые стандарты (новый стандарт -> коп -> ошибка при следующем обновлении кода разработчиком).

По сути своей, копы представляют собой контракт, подписанный кодом.

Типичные копы:

  • Include общего модуля для Schema

У нас есть базовый модуль, который нужно всегда включать в схему. Это параметризированный модуль, в который мы передаем какой-то конфиг. Это нетипичная конструкция в Ruby-коде. Мы проверяем, чтобы в каждую схему мы заинклудили этот модуль, и чтобы у нас были базовые методы.

  • Post настройка для Schema

Этот коп проверяет, что после объявления схемы, вызывается setup. Мы используем GraphQL внутри Rails, где есть Lazy loading с файлов и классов, поэтому не всегда есть гарантия того, что все типы и все классы загружены, когда мы работаем внутри объявления класса. Следовательно, когда написано end, всё, на что ссылается схема, уже объявлено, и по окончании объявления нужно вызвать setup. Это совсем неочевидная вещь, которую важно помнить, особенно новым разработчикам. Именно для этого мы и объясняем это правила и вместе с ошибкой даем ссылки на гайдлайны.

  • Отсутствие of_type параметра у ID-аргумента

Мы расширяем DSL и у нас была гарантия, что нужный тип ID передается во фронтенд. Соответственно, мы всегда должны использовать этот DSL. Если разработчик это пропустил, мы ему об этом напоминаем.

  • Нескалярные типы в массивах

Это соглашение о том, что массив не может быть нескалярного типа, например, массив объектов. Для этого используются connections — это, по сути, тоже массивы, но с мета-полями. Например, totalCount, или какие-то данные лежат на ребре графа. А если это массив, то такое представление станет невозможным. И это создаст дополнительные ограничения при эволюции схемы.

  • Использование deprecated методов в Policy

Мы также используем копы, когда меняются используемые методы. Например, мы раньше использовали метод field_authorized_by_default, а сейчас он стал методом класса по каким-то нашим внутренним причинам. В данном случае мы используем коп, чтобы уведомить разработчика, что теперь нужно писать по-новому.

Как писать код удобно?

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

bin/rails generate gql ...
  • Генератор схемы: аналог rails new

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

Мы на этом не остановились и попытались на каждую абстракцию написать по генератору.

  • Генератор type и field

Мы можем сгенерировать тип для схемы или расширить какой-то тип каким-то полем.

  • Генератор мутации

Мутации работают по-другому. Для них используются другой набор стандартов, необходимы другие входные данные. И, соответственно, для них нужен другой генератор.

Итоги

Это рассказ не про GraphQL, а про стандарты и про то, как их разрабатывать. По сути, мы автоматизировали стандартизацию кода.

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

В глобальном смысле мы не совершили какого-либо прорыва. Но то, что мы сделали, сильно облегчило нашу жизнь как разработчиков.

Комментарии (0)