Это новый доклад 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-дизайн).
Однако оказалось, что гайдлайны не работают, потому что:
документацию никто не читает,
сложно освоить и запомнить все за одно прочтение,
гайдлайны обязательны, но не необходимы, т. е. можно написать код, не соответствующий гайдлайнам.
Для начала необходимо понять, откуда появились эти стандарты и что такое наши схемы. Наши схемы — это, по сути, наборы абстракций, в частности, GraphQL-абстракций, знакомых разработчикам, под нашим соусом.
GraphQL-схема описывает набор типов. Типы нужны для получения и модификации данных. Данные мы получаем из корневого типа query, изменяем данные из корневого типа mutation. Две этих типа являются входными точками в схему.
В данном примере у нас есть два поля — node
и nodes
. Как мы видим, Talent
реализует этот интерфейс, и через поля node и nodes мы можем получить Talent
. Talent
также имеет поля (nullable или не nullable в зависимости от домена, над которым мы работаем).
Надежность в runtime
В наших схемах для реализации того или иного типа мы используем абстракцию под названием Entity. Entity — это Ruby class, который делегирует вызовы к переданному объекту, добавляя GQL-специфики. Так как мы не используем статическую типизацию, на вход может прийти разный объект, что может повлечь некорректную работу реализации.
Чтобы компенсировать отсутвие статичекой типизации, а так же привнести гарантии на передаваемый объект, мы добавили специальный DSL — это object_type
. Мы проверяем, что Entity
(класс, который оборачивает структуры, в частности, ActiveRecord-модели, чтобы мы могли, например, отделить реализацию, делегирование, бизнес-логику моделей от уровня GraphQL) — объект типа Talent
класса ActiveRecord. Если это не так, мы возвращаем GraphQL-ошибку.
Следующая runtime-проверка. ID — это Base64, в который закодирован Тип и число (ID объекта в БД). Клиент может сгенерировать такой ID на своей стороне, и передать не то, что мы хотим. Нам же необходимо, чтобы клиент передал то, что объявлено в типах, например, Talent. Соответственно, мы проверяем что ID типа Talent.
Следующая гарантия, которую мы проверяем в режиме реального времени, наличие у объектов и инстансов модели свойства — Lazy context (привнесенное гемом, который мы используем, чтобы бороться с N+1). Если контекст отсутствует, есть вероятность появления проблемы N+1.
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, а про стандарты и про то, как их разрабатывать. По сути, мы автоматизировали стандартизацию кода.
Поскольку автоматизация подразумевает стандартизацию, мы пришли к выводу, что сначала необходимо разработать стандарты, а потом уже их автоматизировать. При этом, дабы не усложнить, важно четко понимать, что и зачем мы автоматизируем, а что можно оставить на уровне договоренностей.
В глобальном смысле мы не совершили какого-либо прорыва. Но то, что мы сделали, сильно облегчило нашу жизнь как разработчиков.