Привет, Хабр! Сегодня мы расскажем, почему мы пишем фронтенд на Haskell и компилируем его в JavaScript. Вообще говоря, подобный процесс называется транспиляцией:
Транспиляция — это процесс преобразования программы на языке X в эквивалентную программу на языке Y. В отличие от компиляции, языки X и Y находятся примерно на одном и том же уровне абстракции.
Зачем нужна транспиляция?
В общем случае можно выделить две основные цели транспиляции:
- Миграция между разными версиями одного языка. Языки программирования не стоят на месте, активно развиваются и обрастают новыми удобными фичами с каждой новой версией, которые хочется использовать. К сожалению, везде и сразу новые средства языка могут не поддерживаться, поэтому возникает вопрос об обратной совместимости версий. В данном случае такой межверсионный транспилятор производит что-то вроде "рассахаривания" (deshugaring) конструкций в более старые и обычно менее выразительные версии. Примером может служить Babel, переводящий код на JS в его подмножество, поддерживаемое браузерами. Возможно и преобразование в другую сторону, когда необходимо перевести проект на более новую версию языка, а делать вручную это долго
и лень. Например, для транспиляции кода на Python 2.x в код на Python 3 есть 2to3. - Перевод с одного ЯП на другой, исходя из требований рантайм системы и/или пожеланий разработчиков. Например, для исполнения в браузере требуется код на JS (чаще всего применяется на данный момент) или WASM (пока что менее распространён), а для разработки ставятся требования, которым лучше соответствует другой язык. Этот исходный язык может поддерживать уникальные механизмы, такие как автоматическое распараллеливание, или же вообще относиться к другой парадигме. Код, генерируемый транспайлерами, может быть как максимально похож на исходный (это упрощает отладку), так и стать неузнаваемым по сравнению с кодом на исходном языке. Существуют утилиты, позволяющие сопоставить результат транспиляции с оригинальным кодом (например, SourceMap для JS).
Приведём несколько примеров:
- Языки для фронтенд-разработки, транслируются в JS:
— TypeScript — надмножество JavaScript с опциональными аннотациями типов, которые проверяются во время транспиляции.
— CoffeeScript — более выразительный по сравнению с JS язык, в который добавлен синтаксический сахар в духе Python и Haskell.
— Elm — чисто функциональный язык со статической типизацией (и в целом похожий на Haskell), позволяющий создавать веб-приложения в декларативном стиле, который так и называется The Elm Architecture (TEA).
— PureScript — тоже чисто функциональный и статически типизированный язык с Haskell-подобным синтаксисом.
— ClojureScript — расширение языка Clojure (который, в свою очередь, диалект Лиспа) для веб-программирования на стороне клиента. - Языки описания аппаратуры:
— Bluespec — высокоуровневый функциональный язык описания аппаратуры, изначально был расширением Haskell, транспилируется в Verilog.
— Clash — также функциональный, с похожим на Haskell синтаксисом, генерирует код на VHDL, Verilog или SystemVerilog.
— Verilator — в отличие от предыдущих двух, работает в другую сторону и преобразует подмножество Verilog в C++ или SystemC. - Транспиляторы языков ассемблера для различных архитектур или под разные процессоры из одной системы архитектур (например, между 16-битным Intel 8086 и 8-битным Intel 8080).
Почему бы не вести разработку на чистом JS?
Как можно увидеть из приведённых выше примеров, разговор о транспиляции в целом неизбежно затрагивает трансляцию в JS. Давайте разберём более подробно, какие цели это преследует и какие может дать преимущества:
- Транспиляция в JS позволяет запустить приложение в веб-браузерах.
- Разработчики используют те же самые инструменты, что и для разработки бэкенда, поэтому не нужно изучать другие инфраструктуры библиотек, менеджеры пакетов, линтеры и т.п.
- Появляется возможность использовать ЯП, который ближе отвечает предпочтениям команды и требованиям проекта и получить чужеродные консервативному фронтенд-стеку механизмы, такие как строгая статическая типизация.
- Общую для фронтенда и бэкенда логику можно вынести отдельно и переиспользовать этот код. Например, подсчёт общей стоимости заказа может быть нетривиальным из-за специфики предметной области. На клиенте нужно отобразить стоимость заказа, а во время обработки запроса на сервере нужно всё заново перепроверить и пересчитать. Саму бизнес-логику подсчёта общей стоимости заказа можно написать один раз на одном языке и использовать в обоих местах.
- Используются механизмы кодогенерации и генерики, которые, например позволяют убедиться что сериализация и десериализация в JSON или даже бинарное представление будет работать без проблем. Мы использовали такой подход для ускорения разбора запросов, приводящих к большому объему парсинга, чем смогли в ряде случаев, улучшить производительность.
- Упрощается процесс отслеживания совместимости API между клиентом и сервером. При синхронной раскладке клиентского и серверного приложений, а также правильной работе с кэшами в браузерах, должны отсутствовать ситуации с несовместимостью, которые возможны при асинхронных выкладках. Например, если одна часть приложения обращается к другой по API, и API изменяется, есть шанс забыть об этих изменениях на клиенте и потерять какой-нибудь параметр запроса или отправлять тело запроса в неправильном формате. Этого можно избежать, если клиентское приложение написано на том же языке. В идеале оно даже не пройдёт компиляцию, если клиентская функция не соответствует текущей версии API.
- Разработчики одной квалификации участвуют и в бэкенд, и во фронтенд задачах, что дает дополнительную организационную гибкость для команд и увеличивает автобусный фактор. Так становится проще распределять задачи и нагрузку на каждого из членов команды. Это важно и когда нужен срочный фикс — самый "незагруженный" берёт задачу независимо от того, к какой части проекта она относится. Один и тот же человек может исправить и валидацию поля на фронтенде, и запрос к БД, и логику хендлера на сервере.
Наш опыт транспиляции в JS
При выборе инструментов для фронтенд-разработки мы принимали во внимание следующие факторы:
- Хотелось использовать язык со строгой статической типизацией.
- У нас уже существовала достаточно объёмная кодобаза для бэкенда на Haskell.
- Большинство наших сотрудников имеет серьёзный опыт промышленной разработки на Haskell.
- Мы хотели воспользоваться преимуществами одного стека.
На данный момент мы в Typeable ведём фронтенд-разработку на Haskell и используем веб-фреймворк Reflex и функциональное реактивное программирование (FRP). Исходный код на Haskell транспилируется в код на JavaScript с помощью GHCJS.
TypeScript и прочие расширения JS нам не подошли из-за недостаточно строгой типизации, не такой развитой системы типов, как в Haskell, да и в целом эти языки слишком радикально отличаются от привычных для нашей команды.
Reflex мы предпочли таким вариантам как Elm и PureScript в первую очередь из-за желания использовать тот же стек разработки, что и для бэкенда. Кроме того, Reflex позволяет не следовать определённой архитектуре приложений и в какой-то степени является более гибким и "низкоуровневым". Подробнее про сравнение Elm и Reflex можно прочитать в нашем посте на эту тему.
Выводы
Нам удалось получить те преимущества транспиляции в JS, о которых мы рассказали выше:
- Разработка всех частей проекта ведётся с использованием одного стека, а участники команды являются "универсальными" программистами.
- Упрощённо структура проекта представляет собой несколько пакетов: описание API, описание бизнес-логики, бэкенд и фронтенд. Первые два из них являются общими частями для фронтенда и бэкенда, значительная часть кода переиспользуется.
- Мы используем библиотеку
servant
, которая позволяет описать API на уровне типов и проверить во время компиляции, что, как обработчики на сервере, так и функции для отправки запросов на клиенте, используют правильные параметры нужных типов и соответствуют актуальной версии API (забыли поменять клиентскую функцию на фронденде — он просто не соберётся). - Функции для сериализации и десериализации в JSON, CSV, бинарное представление и т.п. генерируются автоматически и одинаково на бекенде и фронтенде. Про API слой можно практически не думать.
Разумеется, есть и определённые трудности:
- Всё ещё приходится использовать вставки на чистом JS для работы с внешними плагинами.
- Отладка становится сложнее, особенно пошаговый режим. Однако такое требуется крайне редко, большинство ошибок оказывается в логике реализации.
- Меньшее количество документации по сравнению с фреймворками на JS.
Соавтор: Катерина Галкина
Другие интересные статьи в нашем блоге:
- Сильные стороны функционального программирования
- Как мы выбираем языки программирования в Typeable
- Сравнение Elm и Reflex
- Создаем веб-приложение на Haskell с использованием Reflex. Часть 1
Версия на английском языке: https://typeable.io/blog/2021-04-05-js-transpilation.html