company_banner

Портирование API на TypeScript как способ решения проблем

Автор оригинала: Gary Bernhardt
  • Перевод
React-фронтенд Execute Program перевели с JavaScript на TypeScript. А бэкенд, написанный на Ruby, трогать не стали. Однако проблемы, связанные с этим бэкендом, заставили разработчиков проекта задуматься о переходе с Ruby на TypeScript. Перевод материала, который мы сегодня публикуем, посвящён рассказу о портировании бэкенда Execute Program с Ruby на TypeScript, и о том, какие проблемы это помогло решить.



Пользуясь Ruby-бэкендом, мы иногда забывали о том, что некое свойство API хранит массив строк, а не простую строку. Иногда мы меняли фрагмент API, обращения к которому выполнялись в разных местах, но забывали обновить код в одном из этих мест. Это — обычные проблемы динамического языка, характерные для любой системы, код которой покрыт тестами не на 100%. (Такое, хотя и реже, происходит и в случае полного покрытия кода тестами.)

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

Я портировал бэкенд с Ruby на TypeScript в марте 2019 года примерно за 2 недели. И всё работало так, как нужно! Мы развернули новый код в продакшне 14 апреля 2019 года. Это была бета-версия, доступная ограниченному количеству пользователей. После этого ничего не поломалось. Пользователи даже ничего не заметили. Вот график, иллюстрирующий состояние нашей кодовой базы до перехода и сразу после него. По оси Х отложено время (в днях), по оси Y — количество строк кода.


Перевод фронтенда с JavaScript на TypeScript, и перевод бэкенда с Ruby на TypeScript

В процессе портирования я написал большой объём вспомогательного кода. Так, у нас имеется собственное средство для запуска тестов объёмом в 200 строк. У нас есть 120-строчная библиотека для работы с базой данных, а также — более крупная библиотека маршрутизации для API, связывающая фронтенд- и бэкенд-код.

В нашей собственной инфраструктуре самое интересное, о чём стоило бы поговорить, это маршрутизатор. Он представляет собой обёртку для Express, обеспечивая правильность применения типов, которые используются и в клиентском, и в серверном коде. Это означает, что когда меняется одна часть API, другая даже не скомпилируется без внесения в неё изменений, устраняющих расхождения.

Вот бэкенд-обработчик, возвращающий список публикаций блога. Это — один из самых простых подобных фрагментов кода в системе:

router.handleGet(api.blog, async () => {
  return {
    posts: blog.posts,
  }
})

Если мы поменяем имя ключа posts на blogPosts, то мы получим ошибку компиляции, текст которой показан ниже (тут, для краткости, опущены сведения о типах объектов.)

Property 'posts' is missing in type '...' but required in type '...'.

Каждая конечная точка определяется объектом вида api.someNameHere. Этот объект совместно используется клиентом и сервером. Обратите внимание на то, что в объявлении обработчика типы не упоминаются напрямую. Все они выводятся из аргумента api.blog.

Такой подход работает для простых конечных точек, вроде вышеописанной конечной точки blog. Но он подходит и для более сложных конечных точек. Например, конечная точка API для работы с уроками имеет глубоко вложенный ключ логического типа .lesson.steps[index].isInteractive. Благодаря всему этому теперь невозможно совершить следующие ошибки:

  • Если мы попытаемся обратиться к isinteractive на клиенте, или попытаемся вернуть такой ключ с сервера — код не скомпилируется. Имя ключа должно выглядеть как isInteractive, с заглавной I.
  • Если сервер вернёт в качестве isInteractive число — код не скомпилируется.
  • Если клиент сохранит значение isInteractive в переменной типа number, то код, опять, не скомпилируется.
  • Если мы изменим само определение API, указав, что isInteractive — это число, а не логическое значение, тогда, до тех пор, пока в код клиента и сервера не будут внесены соответствующие изменения, код, снова, не скомпилируется.

Обратите внимание на то, что всё это включает в себя генерирование кода. Делается это с использованием io-ts и пары сотен строк кода нашего собственного маршрутизатора.

Объявление типов API предусматривает выполнение дополнительных работ, но работы это несложные. При изменении структуры API нам нужно знать о том, как меняется структура кода. Мы вносим изменения в объявления API, а затем компилятор указывает нам на все места, код в которых нужно исправить.

Сложно оценить важность этих механизмов до тех пор, пока некоторое время ими не попользуешься. Мы можем перемещать большие объекты из одного места API в другое, переименовывать ключи, можем разбивать большие объекты на части, сливать маленькие объекты в один объект, разделять или объединять целые конечные точки. И всё это мы можем делать, не беспокоясь о том, что мы забыли внести соответствующие изменения в код клиента или сервера.

Вот — реальный пример. Я недавно, на четырёх выходных, потратил порядка 20 часов, занимаясь перепроектированием API Execute Program. Изменилась вся структура API. При сравнении нового клиентского и серверного кода со старым были зафиксированы десятки тысяч изменений строк. Я перепроектировал серверный код определения маршрутов (наподобие вышеописанного handleGet). Я переписал все объявления типов для API, внеся во многие из них огромные структурные изменения. И, кроме того, я переписал все части клиента, в которых вызывались изменённые API. В ходе этой работы были изменены 246 из 292 файлов с исходным кодом.

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

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

Это не означает, что статическая система типов гарантирует то, что код всегда будет корректным. Эта система не позволяет обойтись без тестов. Но она значительно упрощает рефакторинг.

Расскажу теперь об автоматическом генерировании кода. А именно, мы используем schemats для генерирования определений типов из структуры нашей базы данных. Система подключается к базе данных Postgres, анализирует типы столбцов и записывает соответствующие определения TypeScript-типов в обычный файл .d.ts, используемый приложением.

Файл с типами схемы базы данных поддерживается в актуальном состоянии нашим скриптом миграции при каждом его запуске. Благодаря этому нам не приходится вручную поддерживать эти типы. Модели используют определения типов базы данных для обеспечения того, что код приложения правильно обращается ко всему, что хранится в базе. Нет ни пропущенных таблиц, ни пропущенных столбцов, ни записи null в столбцы, не поддерживающие null. Мы не забываем правильно обрабатывать null в столбцах, поддерживающих null. И всё это статически проверяется во время компиляции.

Всё это вместе создаёт надёжную статически типизированную цепь передачи информации, простирающуюся от базы данных до свойств React-компонентов во фронтенде:

  • Если меняется тип столбца в базе данных, то другой серверный код (вроде обработчиков обращений к API) не скомпилируется до тех пор, пока всё не будет приведено в соответствие с этими изменениями.
  • Если серверные обработчики обращений к API не соответствуют клиентскому коду, вызывающему эти API, то серверный или клиентский код (или и тот и другой код) не скомпилируется.
  • Если клиентские React-компоненты не соответствуют данным, поступающим из API, код этих компонентов не скомпилируется.

Работая над этим материалом, я не смог вспомнить ни одного случая несоответствия в коде, связанном с API, который прошёл компиляцию. У нас не было сбоев в продакшне, возникших из-за того, что клиентский и серверный код, относящийся к API, имеет разные представления о форме данных. И всё это — не следствие автоматизированного тестирования. Мы, для самого API, тестов не пишем.

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

Вот как выглядит работа над проектом после перевода бэкенда на TypeScript. Как видите, с момента перехода написано много кода. У нас было достаточно времени на то, чтобы оценить последствия принятого решения.


На фронтенде и бэкенде проекта используется TypeScript

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

Уважаемые читатели! Занимались ли вы переводом на TypeScript проектов, написанных на других языках?

RUVDS.com
RUVDS – хостинг VDS/VPS серверов

Комментарии 4

    0

    Можно поподробнее, как вы связываете типы клиента и сервера?
    У вас общий репозиторий для клиента и сервера с описанием типов или из серверного кода генерируются типы для клиента?

      +1
      Мы решаем эту задачу через protobuf. С них генерируются модели (и сами сервисы, кстати, без имплементации) и на фронт и на бэк. Пресабмиты пытаются собрать сразу все, поэтому очень трудно сделать какое-то несогласованное изменение.
      0
      Смешанные впечатления статья вызывает. Я люблю тайпскрипт и это мой основной язык сейчас. Однако в отличие от фронтенда, на бэкенде есть из чего повыбирать (хотя бы даже java/go). И только лишь согласование моделей между фронтендом и бэкендом — это еще не повод, как мне кажется, для этой задачи также есть более другии решения (см. protobuf например).
        +1

        Выглядит так, что на бэкенд никакой сложной логики, тупо прокси к базе данных с той же структурой насколько возможно. Добавляешь поле в таблицу и оно уходит на фронт, причём с тем же типом, что и в базе. Никакой фильтрации, никаких преобразований типов. Различия между many-to-many и просто связанными таблицами есть?

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

        Самое читаемое