PureScript и Haskell в Lumi

Примечание. Этот пост и наш новый инженерный блог можно найти на lumi.dev.

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

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

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

Потребность в типах во внешнем интерфейсе

Мы получаем много преимуществ от использования Haskell на сервере Lumi. Все наши API-серверы и клиенты написаны на Haskell с использованием Servant, и мы используем esqueleto и persistent (с большим количеством настраиваемых DSL) для написания наших SQL-запросов. Мы крайне редко сталкиваемся с ошибкой времени выполнения в производственной среде на наших серверах, и когда мы это делаем, обычно это разновидность ошибки бизнес-логики. Трудно переоценить ценность наличия типов повсюду в серверной части.

Наш интерфейс - это большое веб-приложение, написанное с использованием React, со своей собственной логикой. Так почему же тогда, если типы так полезны для нас, мы использовали нетипизированный JavaScript с React во внешнем интерфейсе, когда JavaScript предлагает сравнительно мало гарантий? Есть несколько ответов:

  1. Когда наш продукт был новым, было доступно меньше вариантов, а варианты, которые мы могли выбрать, были незрелыми.
  2. Каждый в команде мог эффективно писать JavaScript, но не все были знакомы с языками компиляции в JavaScript. Теперь у нас достаточно разработчиков, и нам не о чем беспокоиться, но изначально всем было полезно иметь возможность легко вносить свой вклад.
  3. Первоначально JavaScript был подходящим выбором для небольшого приложения, позволяя нам быстро выполнять итерацию, но теперь он менее подходит, когда проект расширился. Создать новый код на JavaScript не особенно сложно, но сложно поддерживать большое приложение JavaScript, требующее обширных тестов для вещей, которые могут быть покрыты типами.

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

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

Решение использовать PureScript

Я лично широко использовал типизированные языки интерфейса (TypeScript и PureScript), и другие члены команды имели аналогичный опыт работы с другими вариантами, такими как Flow и Elm. Как первоначальный разработчик компилятора PureScript, я был явно заинтересован в том, чтобы его приняли в Lumi, но я также знал, что это может быть не оптимальный выбор языка интерфейса пользователя по нескольким причинам:

  • Другим членам команды может не понравиться его использование так, как мне, или они могут оказаться менее продуктивными.
  • Использование относительно необычного языка программирования может затруднить найм разработчиков.
  • Без поддержки на уровне языка таких вещей, как JSX и CSS, нам нужно было бы найти какой-то другой способ работы с нашей командой дизайнеров, которая раньше могла при необходимости напрямую изменять наши компоненты React.
  • Экосистема библиотеки относительно небольшая, и нам нужно будет создать некоторые собственные вещи, чтобы быть продуктивными.

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

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

  • Процесс настройки должен быть простым и ненавязчивым. Создание среды для быстрой проверки идей должно быть тривиальным делом.
  • Язык должен плавно интегрироваться с JavaScript, его библиотеками и инструментами сборки.
  • Система типов должна быть выразительной, поддерживать такие вещи, как типы сумм, полиморфизм строк, классы типов и типы более высокого порядка.
  • Создавать простые решения должно быть легко, но можно экспериментировать с более продвинутыми идеями. Как выразился Джастин Ву, язык должен иметь культуру небо - предел и не должен ограничивать ваше творчество.
  • Разработка самого языка должна быть достаточно открытой, чтобы мы могли изменить любую часть цепочки инструментов, если в этом возникнет необходимость.

Начиная

В конце концов, мы решили попробовать PureScript, заменив один из наших существующих компонентов React на основе JSX. Мы выбрали простой, чистый компонент без побочных эффектов или вызовов API. После настройки компилятора PureScript в нашей существующей сборке на основе Webpack мы взяли хороший старт.

Меня немного беспокоила пригодность существующих привязок React для наших целей (они были слишком сложными, поскольку они пытались поддерживать полный API React, который нам не нужен), поэтому я сколотил упрощенный набор Привязки React для нас. С тех пор мы доработали и выпустили эти привязки в виде отдельной библиотеки под названием purescript-react-basic.

Теперь, когда мы доказали, что этот подход может работать, мы начали переносить все больше и больше наших чистых компонентов на react-basic. Однако мы знали, что хотим иметь возможность заменить вызовы API и компоненты уровня страницы. Для этого мы решили использовать генерацию кода, поскольку наш API большой и достаточно часто меняется, и мы хотели обеспечить как можно больше корректности.

Генерация типов

Первым шагом было создание полного набора типов PureScript, соответствующих типам, которые мы использовали в нашем Haskell API. Чтобы решить эту проблему, мы обратились к поддержке GHC Haskell для программирования обобщенных типов данных. Мы создали упрощенное представление типов данных PureScript (PursTypeConstructor) и класс типов для типов записей Haskell, которые будут преобразованы в типы PureScript (ToPursType):

data PursRecord = PursRecord
  { recordFields :: [(Maybe Text, PursType)]
  }
data PursTypeConstructor = PursTypeConstructor
  { name      :: Text
  , dataCtors :: [(Text, PursRecord)]
  }
class ToPursType a where
  toPursType :: Tagged a PursTypeConstructor
  default toPursType 
    :: ( Generic a
       , GenericToPursType (Rep a)
       )
    => Tagged a PursTypeConstructor
  toPursType = retag $ genericToPursType @(Rep a) id

Член toPursType класса типа создает представление класса типа, помеченное исходным типом Haskell, от которого он произошел.

Было бы утомительно и чревато ошибками записывать эти экземпляры вручную, поэтому мы предоставляем default реализацию класса типов для типов записей, которые реализуют интерфейс Generic. К счастью, GHC создаст для нас Generic экземпляров, если мы включим -XDeriveGeneric расширение, поэтому создание этих представлений наших типов Haskell практически бесплатно.

Когда у нас есть список PursTypeConstructor структур, мы можем довольно легко преобразовать их в код PureScript - здесь ничего особенного не требуется, просто создание простых строковых шаблонов. Для удобства мы также генерируем небольшой дополнительный код, чтобы наши сгенерированные типы можно было использовать на стороне PureScript: шаблон сериализации (сам получен с использованием собственной версии программирования, основанного на типах данных PureScript!), Lenses и Isos для всех полей и конструкторов данных, функции для отладка и так далее.

Создание клиентов API

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

Библиотека servant-foreign идеально подходит для решения этой проблемы, поскольку она генерирует необходимые нам структуры данных, включая списки конечных точек API с разумными именами и всеми типами параметров запроса, тела запроса и тела ответа. Оттуда остается только собрать код PureScript из этих структур данных.

Единственная сложность - предоставить необходимые HasForeignType экземпляры, которые необходимы для преобразования имен типов Haskell в имена типов PureScript. Мы решили повторно использовать одни и те же имена, а затем немного магии Template Haskell - это все, что нужно, чтобы пройти по графу типов и сгенерировать все необходимые экземпляры, благодаря невероятно полезной библиотеке th-reify-many:

$(do names <- reifyManyWithoutInstances ''HasForeignType
       [ ''Order
       , -- ... a list of other top-level types goes here
       ] (const True)
     let toInstance nm =
           let tyCon = TH.ConT nm
               nmLit = TH.LitE (TH.StringL (TH.nameBase nm))
           in [d| instance HasForeignType
                             Purs
                             PursType
                             $(pure tyCon)
                    where
                      typeFor _ _ _ = PursTyCon $(pure nmLit)
                |]
     concat <$> mapM toInstance names
 )

Это требует небольшого пояснения:

  • reifyManyWithoutInstances просматривает граф типов в поисках типов без HasForeignType экземпляров
  • Для каждого найденного типа функция toInstance превращает его имя в экземпляр типа HasForeign с помощью сборки Template Haskell. Узлы tyCon и nmLit находятся в области видимости, поэтому мы можем использовать антицитирование $(...), чтобы использовать их в соединении.

Типы как инструмент

Как я упоминал ранее, хорошая система шрифтов должна повышать продуктивность за счет сокращения загруженности. Помимо того, что наши клиенты API не содержат шаблонов, мы смогли повысить продуктивность в ряде других областей с момента внедрения PureScript. Я надеюсь, что в будущем смогу подробно рассказать о каждом из них:

  • Мы создали коллекцию полностью общих компонентов пользовательского интерфейса поверх наших типизированных клиентов API. Например, у нас есть один компонент таблицы, который параметризуется API, который предоставляет его данные и возможности поиска. Если мы изменим API, компилятор напомнит нам обновить таблицу!
  • Мы создали библиотеку комбинаторов для сборки форм, совместимых с нашими типами API. Используя линзы и несколько основных функций, мы можем создавать типобезопасные формы за гораздо меньшее время, чем вручную.
  • Мы также создали DSL на уровне типов в стиле Servant для создания форм из типов для определенных задач сбора данных. Так же, как Servant позволяет нам перепрофилировать наши определения API уровня типов для создания клиентов API и документации, мы можем повторно использовать наши описания форм на уровне типов для всех видов вещей, таких как хранение в базе данных, индексация и запросы.

В будущем мы планируем реализовать больше инструментов, ориентированных на типы:

  • Как я описал в своем сообщении в блоге о нашей миграции на Postgres, мы реализовали полностью универсальное серверное решение поверх нашей базы данных Postgres, включая фильтрацию, поиск и вычисляемые поля. Мы работаем над тем, чтобы реализовать такой же уровень возможности повторного использования во внешнем интерфейсе, абстрагируясь от общих клиентских шаблонов API.
  • Теперь, когда наши клиенты API представлены во внешнем интерфейсе, мы хотели бы найти новые способы представления наших вызовов API и найти способы реализации таких функций, как пакетная обработка и кеширование, в общем виде.

Если такая работа кажется привлекательной, мы нанимаем!

Заключение

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

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

- Брэди

[У меня был] такой положительный опыт, с небольшими умственными затратами и полным доверием компилятору. Я реализовал целую страницу со списком данных, фильтрами, поиском и разбивкой на страницы, которые сработали в первый раз.

- Брэндон

Я был поклонником «свободного» стиля Javascript; но по мере того, как приложения, над которыми я работал, становились все больше, страх перед «null» или «undefined» начал преобладать. Использование PureScript гарантирует, что любая неправильная логика будет обнаружена или ограничена. Я могу чувствовать себя более уверенно в коде, который выкладываю.

- Кими

После нескольких месяцев в PureScript необходимость редактировать случайный файл JavaScript наполняет меня тревогой - опечатка или неверное предположение где-либо может сломать половину сайта! В PureScript, даже если я где-то ошибся с бизнес-логикой, проблема всегда сохраняется.

- Мэдлин