Featured image of post От урока - к проду

От урока - к проду

Чему учим — на том и работаем: Haskell на семинаре, бэкенд в проде — контракт тот же, ответственности чуть больше

Автор: Степан Курячий 

Чему учим - на том и работаем

Когда студенты садятся за «Функциональное программирование», вопрос обычно один и тот же: «А это вообще где-то реально используют, или это просто про типы и чистые функции ради тренировки?» В ЭРЦИТ и в ИТИ ответ простой: да, используют. И не как эксперимент, а как часть производственного стека.

Программа дисциплины «Функциональное программирование» выстраивается так, что студенты осваивают ровно те инструменты, на которых затем пишутся производственные системы. Не «академический Haskell ради понимания типов», а Haskell, на котором работает бэкенд. Не «Elm ради знакомства с функциональным фронтендом», а Elm, на котором собирается форма регистрации проектов.

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

В этот раз реальная задача нашлась сама — система регистрации проектов для форума «Грани Будущего». Это межрегиональный молодёжный форум креативного мышления и изобретательства, который проводят совместно ИТИ ХГУ, Российская инженерная академия и Хакасское региональное отделение ВОИР. В этом году форум получил статус международного — в нём участвуют коллеги из других стран. Вместе с охватом выросли и требования к цифровой части: заявок больше, стран больше, данные разнороднее. В этом году уже с индексом 2026 (без малого четвёртый год!), а значит и в стеке тоже нужно «подрасти».

ЭРЦИТ здесь — технологический партнёр: проектирование, разработка и поддержка цифровых сервисов форума. Система регистрации проектов — один из таких. И написан он на том самом Haskell, который студенты разбирают на занятиях. Это не отдельный случай, а обычный порядок вещей: учебный процесс устроен так, что инструменты, которые студенты видят на занятиях, — те же самые, на которых делаются рабочие проекты. Круг задач общий — от учебных, до сервисов, которыми пользуются люди. Система регистрации проектов — ещё один шаг в ту же сторону – чему учим — на том и работаем.

От паттернов к языку

В мейнстрим-разработке паттерны проектирования часто компенсируют ограничения языка, а не решают предметные задачи. Питер Норвиг в докладе «Design Patterns in Dynamic Languages» (1996) показал, что из 23 паттернов «банды четырёх» 16 исчезают или становятся тривиальными в языках с более мощной абстракцией1. С тех пор этот тезис только подтверждался. В «Fluent Python» Лучано Рамальо (2-е издание, O’Reilly, 2022), в главе 10 «Design Patterns with First-Class Functions», на примере паттернов Strategy и Command показано, как функции первого класса заменяют целые иерархии классов меньшим количеством кода. Критика паттернов как «заплаток для отсутствующих фич языка» стала общим местом.

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

Речь именно об абстракциях, а не о синтаксисе. Дело не в том, что в Haskell другой способ писать циклы или объявлять переменные. Дело в том, какие средства язык даёт для сборки логики. Функции как значения — то есть функция может быть аргументом, результатом, элементом списка. Алгебраические типы данных — то есть тип собирается из вариантов: «или то, или это», — и компилятор требует обработать каждый. Параметрический полиморфизм — то есть функция работает для любого типа, не зная о нём ничего конкретного. Именно поэтому паттерны, которые в мейнстриме существуют как отдельные сущности, здесь растворяются в обычном коде. Strategy сводится к аргументу функции. Command — к значению, потому что команда и есть функция. Норвиг в своём докладе прямо сгруппировал паттерны по тому, чем именно их заменяет язык: функции первого класса снимают Command, Strategy, Template-Method, Visitor; комбинация методов — Mediator и Observer.

Практический смысл в том, что программист не тратит время на конструкции, которые язык мог бы дать бесплатно. В языке без алгебраических типов «функция может не вернуть результат» выражается через null, исключение или sentinel-значение — и каждый раз это риск, что кто-то забудет проверить. В Haskell это как минимум Maybe и компилятор не даст вам этого забыть. Когда абстракции встроены в язык, паттерн перестаёт быть отдельной сущностью и становится частью обычного кода.

Именно поэтому в основе курса — Haskell: не как экзотика ради расширения кругозора, а как язык, в котором средства для таких систем встроены в саму модель программирования. Как этот выбор выглядит в реальном проекте — покажем на примере системы регистрации. Но прежде — очевидный вопрос: почему не привычный стек?

Почему не Django, не Rails, не ASP.NET и не Spring

Он и возникает первым у любого, кто услышит про систему регистрации на Haskell и Elm. Привычный стек — Python с Django, Ruby on Rails, C# с ASP.NET Core или Java со Spring Framework. Всё это зрелые, проверенные инструменты. Но у них есть общая черта, которая для нас стала решающей: в каждом из них безопасность приходится покупать. Либо платой оказываются compile-time гарантии — как в динамических языках, — либо простота и прозрачность — как в платформенных фреймворках. Третьего варианта в привычном стеке нет.

Динамические языки (Python, Ruby) ловят ошибки в момент выполнения. Django и Rails дают высокую скорость разработки, но цена — отсутствие compile-time гарантий из коробки. Опечатка в имени поля, несоответствие типов, забытая ветка в обработке ошибок — всё это проявляется в лучшем случае на тестах, в худшем — в продакшене. Дополнительные проверяльщики — mypy и django-stubs для Python, Sorbet для Ruby — частично закрывают разрыв, но это опциональные внешние инструменты: их нужно отдельно внедрять и поддерживать, а без них проект живёт без статических проверок вовсе. Для системы, где данные формы уходят одновременно в виртуальную галерею, в базу для печатной продукции, в рассылки и в карточки для жюри, «поймать в проде» означает испорченный буклет или потерянные заявки. Кроме того, первые версии нашего регистратора уже были написаны на Python с Django. Решение получилось сложнее, чем хотелось: неявности и отдельные ошибки усложняли логику пользовательского интерфейса, а исправления приходилось вносить без полного понимания внутренней работы фреймворка.

Платформенные фреймворки (ASP.NET Core, Spring Framework) статически типизированы, но платят за это сложностью. Конфигурационный слой, dependency injection, многослойная архитектура, аннотации — типичный Spring-проект обрастает конфигурацией раньше, чем появляется первая бизнес-логика; на смену XML пришли аннотации и application.yml, но сам слой никуда не делся. ASP.NET Core проще, однако остаётся в парадигме «сначала настрой фреймворк, потом пиши код». Важнее другое: null-безопасность ни в ASP.NET Core, ни в Spring не является свойством системы типов — это опция. В C# с версии 8 она даёт лишь предупреждения компилятора, которые можно подавить или отключить. В Java защита от null остаётся делом внешних анализаторов и аннотаций. Целые классы ошибок — неполная обработка вариантов, несогласованность контрактов между модулями — и там, и там остаются вне контроля компилятора, как и та степень композиции, которая естественна для функциональных языков.

Haskell предлагет другой путь. Порог входа при этом выше — функциональная парадигма требует привыкать к мышлению без мутабельного состояния, к рекурсии вместо циклов, к композиции вместо наследования. Экосистема меньше — в Hackage десятки тысяч пакетов, а не миллионы, как в npm, и не сотни тысяч, как в PyPI.

Но взамен — три вещи, которые в мейнстриме существуют как опции, а здесь являются фундаментом.

  • Компилятор как обязательная страховка. Статическая типизация есть в C#, Kotlin, Rust, TypeScript, Go. Но там её можно ослабить, отключить или обойти. В Haskell статическая система типов гарантирует, что все ошибки типов обнаруживаются на этапе компиляции. Если тип сходится — код компилируется. Если не сходится — не компилируется.
  • Отсутствие null как части языка. В Haskell нет null-ссылок, как в Java или C#. Нет значения, которое можно присвоить переменной любого ссылочного типа и получить «ничего». Если функция может не вернуть результат, это видно в типе: Maybe a, Either e a. Компилятор заставит обработать оба случая при pattern matching — но не помешает использовать fromJust или undefined, если программист решит обойти систему. В Rust (Option), Kotlin (nullable-типы с ?), Swift (optionals) эта проблема тоже решена на уровне языка. Разница в том, как именно. В Haskell Maybe — это не встроенное ключевое слово, а обычный алгебраический тип данных из стандартной библиотеки. Нет особого синтаксиса, нет специальной семантики. Это позволяет работать с ним теми же средствами, что и с любым другим типом: композиция через Functor/Applicative/Monad, обобщённые функции — и не требует отдельных конструкций в языке.
  • Композиция как основной способ сборки логики. C# LINQ, Java Stream API, JavaScript pipe/compose — всё это композиция функций. Разница в том, как она устроена. В Haskell и Elm композиция — это идиома, к которой прибегают постоянно, а не библиотечная возможность. Бизнес-логика собирается из маленьких чистых функций, каждая из которых делает что-то одно. Их можно тестировать отдельно, комбинировать по-разному, переиспользовать.

Ничего из перечисленного само по себе не создаёт систему: из чистых функций и явных типов ещё нужно собрать API, хранение данных и интерфейс. Система регистрации проектов — тот случай, когда все три принципа применены к конкретной инженерной задаче, и выбор каждого инструмента диктовался именно ими. Начнём с бэкенда.

Haskell Servant + Warp: строгий контракт и надёжный транспорт

Для бэкенда выбран стек Haskell Servant + Warp.

Servant описывает API как типы: маршрут, метод, тело запроса, формат ответа — всё это часть типа. Если тип сходится, контракт между фронтендом и бэкендом формально согласован. Для системы регистрации, где важна полнота данных, это страховка от «тихих» ошибок.

Warp — быстрый и лёгкий HTTP-сервер для WAI. В независимых замерах Warp — самый быстрый из Haskell-серверов2, а Mighttpd2, построенный на Warp, по описанию пакета в Debian «в тесте httperf Ping-Pong быстрее nginx»3. Для системы регистрации проектов, где пиковая нагрузка — несколько десятков заявок в час, это не критично. Но это показывает класс инструмента: мы не платим производительностью за типобезопасность.

Но почему именно эта комбинация? В экосистеме есть несколько зрелых вариантов.

Scotty — самый простой. Он вдохновлён Ruby-фреймворком Sinatra и даёт минимум церемоний. Для прототипов и небольших сервисов это удобный выбор. Но API в нём описывается набором функций-обработчиков, а не одним контрактом: нет типа, который связывал бы маршруты, тела запросов и форматы ответов в одно целое. Пока сервис маленький — это незаметно. Когда фронтенд и бэкенд должны сходиться в каждом поле, разрозненное описание становится слабым местом.

Есть и более общий вопрос. Scotty повторяет идеологию Sinatra — а Sinatra написана на Ruby, языке без статической типизации и без функций как полноценных значений в том смысле, в каком они есть в Haskell. То, что хорошо работает в Ruby, — минимализм, отсутствие церемоний, маршруты как обычные блоки кода — в Haskell переносится один в один, но при этом перестаёт быть преимуществом. В Ruby за эту простоту платят отсутствием проверок на этапе компиляции, и там это осознанный размен. В Haskell та же простота означает, что мы не используем то, ради чего взяли язык.

Yesod — полноценный фреймворк «под ключ». Opinionated, навязывающий мнение. Он даёт шаблоны, маршрутизацию, работу с БД, аутентификацию — всё сразу. Yesod активно использует Template Haskell и QuasiQuotes: маршруты описываются внутри Haskell-кода через специальные DSL-конструкции. Это работает, но цена — жёсткая привязка к парадигме фреймворка. Для нас это было бы избыточно: у нас Elm на фронте, JSON API, и мы не хотим, чтобы фреймворк диктовал, как нам организовывать наш код.

Servant занимает особую позицию. Его главная идея — описание API на уровне типов. Вы пишете не набор функций-обработчиков, а тип, который описывает весь API — маршруты, методы, параметры, тела запросов, форматы ответов. Из этого типа компилятор выводит всё остальное: проверку соответствия обработчиков, автоматическую сериализацию JSON, генерацию клиентов и документации. 4

Ключевое преимущество Servant для нас — композиция и декларативность. API описывается как один тип, а не как разрозненные куски кода. Если нужно добавить эндпоинт, вы добавляете его в тип. Если нужно поменять формат ответа — меняете тип, и компилятор показывает все места, которые нужно поправить. Это способ держать контракт между фронтендом и бэкендом в одном месте, где его невозможно случайно нарушить.

Что это даёт на практике: Django REST Framework и Servant

Чтобы показать, почему выбран именно Servant, сравним его с самым популярным инструментом для API на Python — Django REST Framework. Задача одна: принять контактные данные из формы регистрации проекта. Проверяем email и phone. У email — длина не больше 200 символов и формат вида name@domain.tld. У phone — только длина, не больше 30 символов (формат не проверяем: пользователи из разных стран). Плюс правило: хотя бы одно из двух полей должно быть заполнено.

Django REST Framework

1
2
3
# urls.py
path('api/projects/', ProjectListCreateView.as_view()),
path('api/projects/<int:pk>/', ProjectDetailView.as_view()),
1
2
3
4
# views.py
class ProjectListCreateView(generics.ListCreateAPIView):
    queryset = Project.objects.all()
    serializer_class = ProjectSerializer
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# serializers.py
from django.core.validators import EmailValidator

class ProjectSerializer(serializers.ModelSerializer):
    email = serializers.CharField(
        max_length=200, required=False, allow_blank=True,
        validators=[EmailValidator(
            message='step6.email: неправильный формат',
        )],
    )

    class Meta:
        model = Project
        fields = ['email', 'phone']

    def validate(self, attrs):
        email = attrs.get('email', '')
        phone = attrs.get('phone', '')
        if not email and not phone:
            raise serializers.ValidationError(
                {'non_field_errors': 'step6: email or phone required'}
            )
        return attrs

Маршрут живёт в urls.py. Класс представления — в views.py. Поля и правила — в serializers.py. В Django REST Framework существует чёткое разделение ответственности: модели, сериализаторы, представления и URL-конфигурация логически разделены по своим модулям. Три файла, между ними нет языковой связи: views.py ссылается на ProjectSerializer по имени, urls.py ссылается на ProjectListCreateView по имени. Переименуй класс — получишь ошибку не при сборке, а при импорте.

В DRF имена методов validate и validate_<field_name> не проверяются при загрузке кода. DRF ищет их динамически, во время выполнения. Если в имени есть опечатка, метод просто не будет найден. Это не приведёт к немедленной ошибке, но и валидация не сработает. В результате в систему могут попасть некорректные данные, что создаст проблемы позже — при обработке, печати или в других сервисах. Это не «тихий пропуск», а отложенная ошибка, которая может быть опаснее.

В Servant такая ситуация исключена. Контракт между фронтендом и бэкендом описан одним типом. Компилятор проверяет соответствие обработчика этому типу до запуска. Если правило валидации не применяется, это видно в коде: guards либо есть, либо их нет. Промежуточного состояния — «метод написан, но не вызывается» — здесь не существует.

Внутри serializers.py правила тоже заданы разными механизмами: длина — параметр max_length у поля; формат — объект EmailValidator в списке validators; «хотя бы одно поле» — императивный метод validate. Стандартный сериализатор DRF выполняет валидацию в три этапа: проверка на уровне поля (required, blank, validators), кастомная проверка на уровне поля (метод validate_<fieldname>()) и общая проверка (метод validate()). Порядок проверок определяется DRF, а не тем, как они записаны в коде.

Haskell + Servant

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
data ContactInput = ContactInput (Maybe Text) (Maybe Text)
data Contact = Contact Int (Maybe Text) (Maybe Text)

-- API — это тип. Маршрут, метод, тело запроса, формат ответа.
type ContactAPI =
       "api" :> "contacts"                       -- путь /api/contacts
         :> ReqBody '[JSON] ContactInput         -- тело запроса: ContactInput, JSON
         :> Post '[JSON] Contact                 -- ответ на POST: Contact, JSON

badRequest :: Text -> Handler a
badRequest msg = throwError $ err400 { errBody = encodeUtf8 msg }

-- Обработчик. Вход разбирается прямо в заголовке.
-- Пять guards — пять правил, сверху вниз.
createContact :: ContactInput -> Handler Contact
createContact (ContactInput email phone)
    | Nothing <- email, Nothing <- phone    = badRequest "step6: email or phone required"
    | Just e <- email, T.length e > 200     = badRequest "step6.email: размер превышен"
    | Just e <- email, not (isValidEmail e) = badRequest "step6.email: неправильный формат"
    | Just p <- phone, T.length p > 30      = badRequest "step6.phone: размер превышен"
    | otherwise                             = pure (Contact 1 email phone) -- единичка тут "просто какой-то 

-- Тип сервера ВЫВОДИТСЯ из типа API.
-- createContact обязан ему соответствовать. Не совпадёт — код не соберётся.
server :: Server ContactAPI
server = createContact

ContactAPI — один тип. Он описывает весь контракт: путь /api/contacts, метод POST, входной JSON ContactInput, выходной JSON Contact. Это не «конфигурация» и не «декоратор» — это полноценный Haskell-тип. Строковые литералы "api" и "contacts" — сегменты пути. :> — оператор, соединяющий части API в цепочку. ReqBody '[JSON] ContactInput — тело запроса, парсится в ContactInput. Post '[JSON] Contact — метод POST, ответ сериализуется в JSON из Contact.

Server ContactAPI — type family. Это не «одна из реализаций» — это type family, функция, которая принимает типы как входные данные и возвращает типы. Компилятор вычисляет из ContactAPI тип обработчика. createContact обязан этому типу соответствовать. Не совпадёт — сборка упадёт. Не «тест не пройдёт», не «в проде вылезет» — вообще не соберётся.

Все пять правил — это пять guards, идущих подряд, легко читаются, самодокументированы. Каждая строка: условие → результат. Just e <- email, T.length e > 200 читается слева направо: «если email не пустой и длиннее 200». badRequest определён один раз и применяется четыре раза. Последняя строка — otherwise: всё прошло, возвращаем Contact. Убери строку — правило исчезнет и это видно сразу.

Разница в одной таблице

Django Servant
Где живёт контракт в трёх файлах, связанных по именам в одном типе ContactAPI
Кто проверяет соответствие вручную, утилиты, тесты, если повезёт компилятор
Правила валидации три разных механизма в одном классе пять guards подряд
Опечатка в имени метода неясно, где выстрелит ошибка сборки
Изменение поля email правится вручную в нескольких местах компилятор показывает все места

Разница в целом здесь не в том, что «Haskell строже». Разница в том, где живёт контракт. В Django он распределён по файлам и поддерживается вручную. В Servant он описан одним типом и проверяется компилятором до запуска. Всё остальное — следствие этого.

Контракт, который проверяется компилятором, меняет и требования к остальным частям стека: каждый следующий выбор делается по той же логике — не «что привычнее», а « что даёт гарантии при минимальной цене». Следующая часть системы — хранение данных.

SQLite вместо PostgreSQL: осознанный минимализм

PostgreSQL — стандарт для больших нагрузок, параллельных записей и репликации. PostgreSQL — это полноценный сервер, предназначенный для сложных запросов и многопользовательских сред. У нас нагрузка — регистрация проектов на форум. Применять полноценную СУБД было бы довольно расточительно, поэтому мы использовали один известный, встраиваемый движок… Вы конечно же ничего о нём не знаете, потому что называется он — SQLite5 😉

SQLite — вероятно, один из самых тщательно протестированных программных проектов в мире. Это не маркетинговое утверждение, а измеренная величина.

  • Соотношение тестового кода к коду библиотеки — 590 к 1 (для версии 3.42.0)6.
  • 100% покрытие ветвлений (branch coverage) и 100% покрытие MC/DC (Modified Condition/Decision Coverage) для ядра. MC/DC — это стандарт, применяемый в авионике по DO-178B6.
  • Четыре независимых набора тестов, каждый разрабатывается отдельно: TCL Tests, проприетарный TH3, SQL Logic Test (сравнение с PostgreSQL, MySQL, SQL Server и Oracle) и фаззер dbsqlfuzz7.
  • Перед каждым релизом прогоняется soak-тест TH3 — порядка 248,5 миллиона тестов7.
  • 7,2 миллиона запросов в SQL Logic Test на 1,12 ГБ тестовых данных8.

Когда мы кладём данные регистрации проектов в SQLite, мы кладём их в базу, проверенную строже, чем большинство коммерческих СУБД. Разумеется, SQLite не универсальна: одновременная запись от множества процессов, горизонтальное масштабирование, сложные административные сценарии — не её территория. Для нашей задачи — десятки заявок в час, один процесс, predictable-нагрузка — это не ограничения, а несуществующие требования. Остаётся фронтенд — и там та же логика выбора действует с той же последовательностью.

Фронтенд: Elm для логики и W3 CSS для представления

Если бэкенд отвечает за надёжность обработки данных, то фронтенд — за предсказуемую модель состояния. Принцип здесь тот же, что и на бэкенде: нужен язык с теми же свойствами — чистый, функциональный, со строгой типизацией, — но живущий в браузере. Им стал Elm: разработанный для фронтенда язык, построенный на тех же основах, что и Haskell, — неизменяемость, чистые функции, явная обработка ошибок. Для формы регистрации это означало компилятор на границе, где ошибки всплывают чаще всего: во взаимодействии пользователя с интерфейсом. Здесь у нас был выбор не только между «Elm или что-то на JavaScript», но и между двумя принципиально разными подходами: Elm, компилируемый в JavaScript или Haskell, компилируемый в WebAssembly. Pure-Javascript мы отвергли принципиально, Javascript — это огромный тропический лес, в котором полным-полно известных и неизвестных видов «растений» и «животных». Тем не менее, Javascript-код у нас есть и мы им пользуемся, почему — смотрите дальше.

Elm задаёт чёткие правила: состояние, обновление, представление — всё разделено и явно описано. Это The Elm Architecture (TEA) — паттерн управления состоянием, заимствованный из функционального языка Elm, который обеспечивает строгий однонаправленный поток данных и предсказуемое обновление состояния. Приложения на Elm обязаны следовать TEA — простому паттерну проектирования, который подразумевает разделение кода на три части: Model, View и Update. Model — это данные, которые используются в приложении. View — это функция, преобразующая Model в пользовательский интерфейс. Update — это функция, ответственная за обновление состояния: она принимает Model и сообщение, и возвращает обновлённую Model. Логика каждой программы в Elm разбивается на три чётко разделённые части: модель, обновление и отображение.

Главное преимущество Elm перед React — отсутствие runtime-исключений на практике. Elm использует вывод типов, чтобы находить проблемы на этапе компиляции9. Официальная формулировка: «No runtime exceptions in practice»9. В Elm не бывает undefined is not a function или Cannot read property of null. В React даже с TypeScript ошибки времени выполнения остаются возможными — и это не редкость, а обычное дело. В Elm такой категории ошибок просто нет.

Но у этой строгости есть обратная сторона: Elm не может напрямую вызывать JavaScript-код. Для этого в языке предусмотрен единственный официальный механизм — ports. Это не лазейка и не костыль, а осознанное решение: любой обмен данными с внешним миром проходит через явно объявленный канал с проверкой типов на обоих концах. Порт, по которому данные уходят из Elm в JavaScript, объявляется как port sendMessage : String -> Cmd msg, а порт для приёма данных — как port messageReceiver : (String -> msg) -> Sub msg. На стороне JavaScript эти порты выглядят как app.ports.sendMessage.subscribe(...) и app.ports.messageReceiver.send(...). Типы проверяются компилятором Elm, а данные сериализуются в JSON и обратно. Именно через ports в реальных проектах подключают WebSockets, localStorage, сторонние библиотеки и всё, что находится за пределами Elm. В нашем случае через них же проходит и интеграция с серверной частью, где данные формы уходят в Haskell-бэкенд. Также, на Javascript пришлось экстренно делать некоторые элементы UI, но мы клятвенно обещаем, что в последствии перетащим их на Elm.

Теперь про WebAssembly и Haskell: GHC умеет компилировать Haskell в WebAssembly, и это направление продолжает развиваться10. Существует production-ready фреймворк Miso — библиотека для фронтенд-приложений на Haskell с компиляцией как в JavaScript, так и в WebAssembly11.

Почему же мы выбрали Elm, а не Haskell + WASM?

  1. Зрелость инструментов. Elm создан специально для фронтенда, с первого дня. GHC WASM backend — сравнительно молодая функция, которая продолжает развиваться. Для production-системы регистрации проектов мы не можем позволить себе зависеть от экспериментальной сборки.
  2. Взаимодействие с DOM. WebAssembly не может напрямую обращаться к DOM — нужен «клей» на JavaScript, и эти переходы могут съесть выигрыш. Для форм регистрации, где основная работа — ввод текста, чекбоксы, загрузка файлов, WASM не даёт преимуществ.
  3. Простота поддержки. Elm компилируется в чистый JavaScript, который работает в любом браузере без дополнительных требований9. WASM требует настройки сборки и поддержки на уровне браузера.

Elm даёт нам то же главное преимущество, что и Haskell на бэкенде, — компилятор как страховку. Только здесь это касается состояния интерфейса, а не бизнес-логики. И Elm построен на тех же концепциях функционального программирования, что и Haskell: стек остаётся в рамках одной экосистемы, а не собирается из разнородных частей.

W3 CSS: минимализм не как компромисс, а как принцип

Остаётся представление. Логику интерфейса ведёт Elm, данные — Haskell, и следующий выбор подчиняется тому же принципу: минимизировать императивный слой. CSS по своей природе декларативен — он описывает, как выглядит элемент, а не как этого добиться. Но CSS-фреймворк с JavaScript-частью — как Bootstrap до версии 5, требовавший jQuery, — добавляет в слой представления внешнюю функциональность: её нужно поддерживать, сопровождать и интегрировать с собственной логикой интерфейса. Поэтому для стилизации выбран W3.CSS — «фреймворк» из семейства pure-CSS, без JavaScript.

Философия W3 CSS — «предоставить инструменты, а не диктовать архитектуру». W3 CSS даёт классы для типовых задач: сетки, карточки, формы, таблицы, навигация, типографика. Вы используете только то, что нужно и не «грузите» в проект ничего лишнего.

Сетка и адаптивность: W3 CSS поддерживает 12-колоночную адаптивную сетку с mobile-first подходом. Колонки перестраиваются автоматически при изменении размера экрана: на большом экране — три колонки, на маленьком — одна12.

Дизайн-язык: W3 CSS вдохновлён Google Material Design: плоские элементы, чёткие границы, минималистичные тени, продуманная типографика. Шрифты — читаемые, с хорошим межбуквенным интервалом. Всё это делает интерфейс предсказуемым и одинаково понятным на разных экранах — от смартфона до десктопа.

Что это даёт нам? В системе регистрации проектов W3 CSS отвечает за всё представление: формы, карточки проектов, модальные окна, сетку галереи, уведомления. Elm управляет состоянием и логикой, W3 CSS — отображением. Такое разделение прозрачно: логика не смешивается со стилями, стили не зависят от JavaScript, а вёрстка остаётся читаемой и переиспользуемой.

На этом описание стека завершено: контракт — Servant, транспорт — Warp, хранение — SQLite, логика интерфейса — Elm, представление — W3.CSS. Но у этой истории есть второй слой. Все перечисленные инструменты входят в учебную программу ИТИ, и это не совпадение: один и тот же принцип выбора действует и в рабочих проектах, и в курсе.

Как учебный предмет становится производственным инструментом

Принцип этот звучит так: инструмент, попадающий в курс, по возможности не должен существовать только в курсе. Он либо уже используется в рабочих задачах, либо входит в них к моменту, когда студенты начинают его осваивать. Разрыв между «учебным» и «производственным» в таком случае часто не успевает возникнуть — чаще всего это одна и та же среда.

Мы стараемся преподавать то, на чём работаем, — а если рабочий инструмент меняется, за ним следует и курс. Не «академический язык ради понимания типов», а Haskell, на котором собран бэкенд форума. Не «знакомство с функциональным фронтендом», а Elm, на котором собирается форма регистрации. Не «обзор систем виртуализации», а OpenNebula, которая обслуживает и учебный процесс, и наши собственные проекты, и IT-инфраструктуру. Студент видит не абстрактный пример из методички, а код, который завтра пойдёт в продакшен, — и это меняет отношение к предмету сильнее многих слов о важности образования.

Мы ошибаемся. Наступаем на грабли. Что-то переделываем, что-то выбрасываем, что-то осваиваем с нуля — и делаем это не за закрытой дверью, а вместе со студентами. Программа дисциплины пересматривается каждый год не потому, что так положено, а потому, что за год меняются задачи, инструменты и сами подходы. Кто-то из студентов видит, как рождается решение, которое потом уходит в реальный сервис, — и это не «стажировка после диплома», а обычный учебный процесс. Мы узнаём новое вместе с ними: не «сначала выучим, потом применим», а «разбираемся, потому что это нужно прямо сейчас». Теория перестаёт быть теорией в тот момент, когда её результат виден на экране и им пользуются люди. Именно это, а не количество часов в учебном плане, делает предмет производственным инструментом.


  1. Peter Norvig, «Design Patterns in Dynamic Languages» (1996). — https://norvig.com/design-patterns/ ↩︎

  2. HttpArena — независимые замеры Haskell-серверов, Alpha Round. — https://www.http-arena.com/frameworks/lang/haskell/ ↩︎

  3. Debian: mighttpd2 — package description. — https://packages.debian.org/forky/mighttpd2 ↩︎

  4. Generating Javascript functions to query an API — документация Servant, https://docs.servant.dev/en/latest/tutorial/Javascript.html ↩︎

  5. SQLite — встраиваемый движок базы данных. — https://www.sqlite.org/whentouse.html ↩︎

  6. SQLite: соотношение тестового кода к коду библиотеки 590:1 (для версии 3.42.0), 100% покрытие ветвлений и 100% MC/DC (набор TH3). — https://www.sqlite.org/testing.html ↩︎ ↩︎

  7. SQLite: четыре независимых набора тестов (TCL, TH3, SQL Logic Test, dbsqlfuzz); soak-тест TH3 перед релизом — около 248,5 млн тестов; SLT — сравнение с PostgreSQL, MySQL, SQL Server и Oracle. — https://www.sqlite.org/testing.html ↩︎ ↩︎

  8. SQLite: SQL Logic Test — 7,2 млн запросов на 1,12 ГБ данных. — https://www.sqlite.org/testing.html ↩︎

  9. Elm — функциональный язык для фронтенда; вывод типов, «No runtime exceptions in practice». — https://elm-lang.org/ ↩︎ ↩︎ ↩︎

  10. GHC WebAssembly backend. — https://ghc.gitlab.haskell.org/ghc/doc/users_guide/wasm.html ↩︎

  11. Miso — фреймворк для Haskell с компиляцией в JavaScript и WebAssembly. — https://hackage.haskell.org/package/miso ↩︎

  12. W3.CSS — документация: адаптивная сетка (W3.CSS 12-Columns), типографика в духе Material Design (W3.CSS Material). — https://www.w3schools.com/w3css/ ↩︎

Создано при помощи Hugo / Катается на OpenBSD
Тема Stack, дизайн Jimmy