Почему ваш следующий проект Node.js должен быть написан на функциональном языке: пример использования статических возможностей ReasonML.

Я давно являюсь поклонником OCaml, поэтому, когда я открыл для себя BuckleScript и ReasonML, я естественно очень обрадовался :) Почему? Потому что эти инструменты позволяют перенаправить проверенный в боях компилятор OCaml для создания читаемого JavaScript, который может работать на Node.js и в браузере.

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

Если вы плохо разбираетесь в функциональных языках и, в частности, в OCaml, я рекомендую вам прочитать OCaml для масс Ярона Мински из Jane Street Capital. Для углубленного изучения я настоятельно рекомендую книгу Real World OCaml, которую вы можете бесплатно прочитать в Интернете.

ReasonML немного адаптирует синтаксис OCaml, чтобы сделать язык более естественным для людей с опытом работы с JavaScript. И сообщество обращает внимание: ReasonML получил Prediction Award в Опросе о состоянии JavaScript в 2018 году. Также сообщалось, что Facebook, создатель ReasonML, уже перевел 50 процентов своего веб-приложения для обмена сообщениями на ReasonML, используя привязки к популярной библиотеке React. ReasonML имеет первоклассную поддержку JSX, что делает интеграцию React очень естественной.

В этой статье мы сосредоточимся на серверной части и, в частности, на ReasonML как языке для разработки приложений Node.js без необходимости писать программы на JavaScript. Конечно, экосистема все еще незрелая, и обычно требуется некоторое взаимодействие с библиотеками, написанными на JavaScript. К счастью, BuckleScript предоставляет хорошо спроектированный интерфейс внешних функций (FFI), и после небольшой работы все работает довольно хорошо.

Теперь вы можете задаться вопросом, почему бы просто не использовать OCaml напрямую, если мы должны оставаться в бэкэнде? В конце концов, изначально скомпилированный OCaml работает очень быстро и имеет хорошие реализации цикла обработки событий. Фактически, также можно скомпилировать байт-код OCaml в JavaScript, используя js_of_ocaml, так что это еще один способ использования OCaml для разработки полного стека. Этот метод более надежен, поскольку он позволяет использовать типизированные библиотеки OCaml повсюду во внешнем интерфейсе, вместо того, чтобы прибегать к интерфейсу внешней функции для нетипизированного кода. С другой стороны, этот подход на самом деле не способствует постепенному переходу для существующих кодовых баз JavaScript, он не поддерживает JSX и React, поэтому он может быть нежелательным для большинства проектов. См. Эту статью для дальнейшего обсуждения перехода на ReasonML.

Предварительные условия

Если вы хотите поэкспериментировать с кодом, убедитесь, что у вас установлена ​​относительно последняя версия Node. Я все протестировал на 8+, все работает нормально. В любом случае мы используем очень небольшую поверхность Node API.

Для BuckleScript у вас есть два варианта: установить глобально или локально только для этого проекта. Для глобальной установки откройте терминал и введите npm install -g bs-platform. В качестве альтернативы вы можете просто создать новый каталог и использовать npm install --save-dev bs-platform для локальной установки.

Теперь перейдите в выбранный каталог проекта и введите bsb -init channels -theme basic-reason в терминале. Это загрузит проект в новый каталог channels с разумными настройками по умолчанию, которые работают "из коробки". Весь код, который мы представим, должен находиться в подкаталоге src/.

На весь исходный код, представленный в этой статье, распространяется лицензия MIT.

Статические возможности с использованием фантомных типов

Цель этой статьи - продемонстрировать мощный аспект системы типов OCaml: возможность кодировать и применять определенные возможности как типы. Затем компилятор гарантирует, что эти возможности соблюдаются повсюду в нашей программе. Другими словами, мы получаем возможности бесплатно, без каких-либо затрат времени выполнения.

Техника, которую мы будем использовать, основана на фантомных типах, часто используемых для кодирования статического контроля доступа; см. этот пост для хорошего введения. Для более глубокого понимания статических возможностей см. Статью Легкие статические возможности. Обратите внимание, что этот метод может быть применен к большинству языков с параметрическим полиморфизмом; см. например Фантомные типы в Scala.

Чтобы проиллюстрировать концепции, мы разделим все на три этапа:

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

Основные каналы

Начнем с представления интерфейса разумной абстракции канала в ReasonML. В этом контексте под интерфейсом мы подразумеваем тип модуля, который является основным механизмом абстракции в OCaml / ReasonML. Модуль, в свою очередь, можно рассматривать как набор типов и значений. Каждый файл определяет модуль (или интерфейс) с тем же именем.

В нашем случае:

Файл Channel.re станет модулем Channel; Channel.rei будет ограничивать видимый извне интерфейс Channel внутри любого модуля, который импортирует его с помощью open Channel. Обратите внимание, что внутри файла Channel.re компилятор продолжит видеть неограниченные, наиболее общие типы.

Вот наша первая попытка, Channel.rei, которую нужно поместить в папку src/.

/* Channel.rei */

type t('a)  
/* Internal representation of Channel type, polymorphic on type variable 'a. */

let create: unit => t('a)  
/* Create a new channel that carries values of type 'a. */

let send: t('a) => 'a => t('a)  
/* Send a value of type 'a on a channel of type t('a) */

let recv: t('a) => ('a => unit) => t('a)  
/* Receive a value of type 'a, handled by a function of type 'a => unit. */ 

let listen: t('a) => ('a => unit) => unit  
/* As above, but continue to listen for messages. */

let recv_sync: t('a) => option('a)  
/* Receive if there is a value, synchronously. */

Некоторые комментарии по порядку. Во-первых, тип t('a) - абстрактный. Мы не знаем, как это будет реализовано, и у нас нет возможности создавать или манипулировать им за пределами интерфейса. Эта граница абстракции является основным методом инкапсуляции, предоставляемым модулями в OCaml / ReasonML. Часть 'a - это переменная типа, аналогичная той, что есть в других языках с параметрическим полиморфизмом, например в Java. После того, как мы создадим канал, переменная этого типа может быть инстанциирована для любого типа.

Приведем пример, предполагая, что у нас есть некоторая реализация вышеуказанного интерфейса в файле с именем Channel.re. Мы реализуем этот модуль в ближайшее время, но сейчас нас не интересуют детали, потому что мы хотим запрограммировать на интерфейс.

/* File Example.re; this is a comment. */ 

open Channel 
/* Make available all the visible definitions of module Channel. */

let chan = create()
/* Apply Channel.create to a unit argument (), and 
   return a value of type t('a) which will be thereafter 
   available as chan. The type 'a is not yet instantiated. */

send(chan, 5)
/* Apply Channel.send to chan of type t('a) and 5 of type int, 
   with the intended semantics of sending 5. 
   The type unification algorithm will introduce the equation 
   'a=int to satisfy the signature of send, and the channel 
   now has type t(int). */

recv(chan, x => Js.log(x + 5))
/* Receive a value from chan and use it to print on the console;  
   because of the send above, x will be constrained to have the type 
   int, since chan has type t(int). The callback x => Js.log(x + 5) 
   has type int => unit, which matches 'a => unit in the interface. */

Обратите внимание, что значения, возвращаемые Channel.create, являются полиморфными: мы можем создать его и использовать его для передачи любого типа сообщения, если мы делаем это последовательно. Итак, если мы добавим send(chan, "Hello") выше, проверка типов завершится неудачно, потому что тип string несовместим с t(int). Кроме того, мы не писали никаких типов в Example.re: компилятор может вывести их таким образом, чтобы гарантировать, что они являются как можно более общими. Таким образом, нет необходимости иметь интерфейс для модуля, если мы не хотим контролировать то, что отображается.

Большинство функций в нашем интерфейсе возвращают канал типа t('a), который позволяет связывать операции. Это очень полезно, но может сбивать с толку: представьте, что вам нужно написать send(send(chan, 4), 5) для отправки сначала 4, а затем 5. В качестве альтернативы мы можем следовать более подробному стилю и писать let _ = send(chan, 4); let _ = send(chan, 5), что немного лучше, но полностью отказывается от цепочки. (Как обычно, _ - это имя переменной, обычно используемое для игнорируемых значений.)

Вот два способа сделать текст более читабельным:

  • Мы можем использовать стандартный оператор обратного приложения |>, который позволяет записать x |> f |> g вместо g(f(x)). BuckleScript дополнительно позволяет поместить заполнитель _ и направить параметр в определенную позицию, поэтому мы можем написать chan |> send(_, 5) |> send(_, 6). BuckleScript преобразует send(_, 5) в x => send(x, 5).
  • Мы можем использовать оператор fast pipe |. (или чаще -> в ReasonML). Это разработано для вышеупомянутого варианта использования, когда переданное значение вводится перед другими параметрами. Конкретно chan |. send(5) и chan -> send(5) эквивалентны send(chan, 5). Теперь мы можем написать chan |. send(5) |. send(6), что нам и нужно. Оператор остается ассоциативным, что означает, что предыдущий пример интерпретируется как (chan |. send(4)) |. send(5).

Теперь вернемся к двум последним функциям, объявленным в интерфейсе. Первый, listen, означает делать recv вечно, а не один раз. Второй, recv_sync, позволит сразу получить значение, если оно есть. Это синхронная версия recv. В этом случае обратный вызов отсутствует, и предполагается использование let msg = recv_sync(chan). Встроенный тип option('a) указывает, что msg будет либо значением Some(m) типа Some('a), где m имеет тип 'a, либо None с типом None. Такой тип называется вариант.

Вот как обычно используется recv_sync:

let msg = recv_sync(chan); 
switch(msg) {
    Some(m) => ... /* Do something with m. */ 
  | None => ... /* Handle the no message case. */ 
}

Теперь мы готовы улучшить интерфейс Channel с помощью возможностей.

Разрешенные каналы

Вот новый интерфейс Channel.rei:

/* Channel.rei */

type t('a, 'r, 's)  
/* Internal representation of Channel type.
   Type parameters: 
   'a: type of value transmitted.
   'r: receive permission.
   's: send permission. */

type can_receive 
type cannot_receive
type can_send
type cannot_send

let create: unit => t('a, can_receive, can_send)  
/* Or: => t('a, 'r, 's) */

let send: t('a, 'r, can_send) => 'a => t('a, cannot_receive, can_send)  
/* Or: => t('a, 'r, can_send) */

let recv: t('a, can_receive, 's) => ('a => unit) => t('a, can_receive, cannot_send)  
/* Or: => t('a, can_receive, 's) */ 

let listen: t('a, can_receive, 's) => ('a => unit) => unit 
/* Or: => t('a, can_receive, 's) */

let recv_sync: t('a, can_receive, 's) => option('a)

let to_read_only: t('a, can_receive, 's) => t('a, can_receive, cannot_send)
/* Remove the send capability. */

let to_write_only: t('a, 'r, can_send) => t('a, cannot_receive, can_send) 
/* Remove the receive capability. */

Давайте посмотрим на ключевые моменты один за другим:

  • Мы добавили еще две переменные типа к типу t, который теперь равен t('a, 'r, 's). Новые переменные являются заполнителями для разрешений на получение ('r) и отправку ('s).
  • Мы определили 4 новых типа, которые будут использоваться в качестве разрешений: can_receive, cannot_receive, can_send и cannot_send. Предполагаемое использование - создать экземпляр 'r с can_receive или cannot_receive и 's с can_send или cannot_send. (Акцент на предполагаемом, но давайте не будем слишком подробно рассказывать.)
  • В комментариях мы показываем альтернативные типы результатов, которые мы могли бы определить, и компилятор остался бы доволен. В качестве небольшого примера, chan |. send(5) |. recv(x => ...) не проверяет тип, даже если это было бы совершенно безопасно, потому что это может скрыть тот факт, что 5 будет получен немедленно. С другой стороны, let _ = chan |. send(5); let _ = chan |. recv(x => ...) в порядке.
  • Мы добавили функции для получения дескрипторов только для приема и отправки для канала. Это отражается в изменении разрешений.

Время для примера:

/* Example1.re */

open Channel

let chan = create()  /* chan: t('a, can_receive, can_send) */

let chan_ro = to_read_only(chan)  /* chan_ro: t('a, can_receive, cannot_send) */

chan_ro |. send(5)  /* Rejected by the compiler. */

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

Реализация интерфейса в модуле Channel.re

Начнем с использования FFI для импорта функции process.nextTick как spawn. (Если нам нужно более портативное решение, мы могли бы использовать Promises, которые также работают в современных браузерах.)

/* Channel.re part 1 of 7 */

/* Node.js Event Loop Externals */
[@bs.scope "process"] [@bs.val] 
external spawn : (unit => unit) => unit = "nextTick";

В следующем фрагменте мы определяем типы. Обратите внимание, что возможности отправки и получения остаются абстрактными: мы должны предоставить их, потому что они присутствуют в Channel.rei, но от нас не требуется их конкретное определение. Далее мы определяем полиморфный тип записи для каналов. Это обеспечивает доступ к двум очередям: одной для ожидающих обратных вызовов ввода и одной для ожидающих сообщений. Точнее, входная очередь будет содержать кортежи (логическое значение, обратный вызов): если логическое значение истинно, то вход является сервером (он повторяется); если оно ложно, ввод может быть выполнен только один раз (или ноль раз). Наконец, тип t, который мы снова обязаны определить, идентифицируется с channel. Поскольку переменные 'r и 's не появляются в channel('a), отсюда следует, что для любых типов a, r, s компилятор знает, что t(a, r, s) == channel(a). Следовательно, разрешения не имеют значения внутри модуля Channel.

/* Channel.re part 2 of 7 */

type can_receive 
type cannot_receive
type can_send
type cannot_send

type channel('a) = {
    inputs: Queue.t((bool, 'a => unit)),
    messages: Queue.t('a)
}

/* type t('a) = channel('a)  */ /* Original definition. */
type t('a, 'r, 's) = channel('a)  /* Phantom type definition. */

С этого момента нам не нужны никакие аннотации типов, и фактически код такой же, как тот, который мы написали бы для простого интерфейса канала без разрешений. Сначала мы определяем create, который возвращает новый канал, то есть запись. OCaml найдет определение channel('a) и сделает вывод, что может присвоить его возвращаемому значению. В результате внутри модуля create есть тип unit => channel('a). Однако вне модуля он имеет тип возвращаемого значения, объявленный в интерфейсе, то есть ему присваивается тип unit => Channel.t('a, can_receive, can_send).

/* Channel.re part 3 of 7 */

let create = () => {
    inputs: Queue.create(),
    messages: Queue.create()
}

Теперь мы реализуем коммуникации. Здесь я адаптирую то, что известно как абстрактная машина Тернера. Это способ реализации пи-исчисления, которое можно понимать как лямбда-исчисление параллелизма. Но оставим теорию на другой день. Стоит упомянуть одну деталь - это rec часть: для определения взаимно рекурсивных функций мы пишем let rec f1 = ... and f2 = ... and fn = ..., чтобы помочь средству проверки типов.

Основная идея send заключается в следующем: если есть некоторый ввод, ожидающий приема на том же канале, выполните обмен данными; в противном случае поместите сообщение в очередь канала до появления ввода, принимающего его.

/* Channel.re part 4 of 7 */

let rec 
send = (channel, msg) => {
    let {inputs, messages} = channel 
    if (Queue.length(inputs) > 0) {
        let (is_replicated, receiver) = Queue.take(inputs) 
        channel |> communicate(_, msg, receiver, is_replicated)
    } else {
        Queue.push(msg, messages)
    }
    channel
}
and

Затем мы реализуем recv', который является основой как для recv, так и для listen. Работает двойственно с send: если есть сообщение, бери его; иначе поместите обратный вызов в очередь входных данных, чтобы в конечном итоге он мог соответствовать некоторым выходным данным.

/* Channel.re part 5 of 7 */

recv' = (channel, receiver, is_replicated) => {
    let {inputs, messages} = channel
    if (Queue.length(messages) > 0) {
        let msg = Queue.take(messages) 
        channel |> communicate(_, msg, receiver, is_replicated)
    } else {
        let input = (is_replicated, receiver)
        Queue.push(input, inputs)
    }
    channel
} 
and

Когда может произойти коммуникация, что означает, что у нас есть и сообщение, и обратный вызов получателя на канале, вызывается communicate. Мы полагаемся на spawn (т.е. process.nextTick) для задержки выполнения, как это обычно бывает в потоке управления для примитивов параллелизма; Обещания также делают что-то в этом направлении. Есть, конечно, более фундаментальная причина: если мы немедленно запускаем обратный вызов с сообщением, мы можем голодать цикл обработки событий.

В случае listen значение is_replicated будет true, поэтому мы также снова создадим ввод. (Обратите внимание, что communicate и recv' не появляются в Channel.rei, поэтому они закрыты для модуля.)

Что касается run_safe, он улавливает любые исключения, возникающие в обратном вызове для сообщения, и печатает их; обычно мы могли бы поставить сюда лучшую механику, но пока это не важно. См. Документацию для получения подробной информации об обработке исключений.

/* Channel.re part 6 of 7 */

communicate = (channel, message, receiver, is_replicated) => {
    spawn( () => run_safe(receiver, message) ) 
    if (is_replicated) 
        spawn( () => recv'(channel, receiver, is_replicated) |. _ => () )  
} 
and 
run_safe = receiver => message => {
    try (receiver(message)) {
        | Js.Exn.Error(e) => Js.log({j|JS Error: $e|j})
        | e => Js.log({j|Error: $e|j})
    }
}

let recv = (channel, receiver) => recv'(channel, receiver, false)

let listen = (channel, receiver) => recv'(channel, receiver, true) |. _ => ()

let recv_sync = channel => {
    let {messages} = channel
    Queue.length(messages) > 0 ? Some(Queue.take(messages)) : None
}

Наконец, мы реализуем преобразования, чтобы ограничить возможности. Это так просто, как кажется, даже если на первый взгляд это не имеет смысла. Как это возможно, что функция идентификации имеет разные типы аргументов и возвращаемых значений? Уловка заключается в том, что разрешенные типы идентифицируются с типом без разрешений channel внутри модуля и поэтому считаются равными. Например, компилятору необходимо доказать, что to_read_only может быть присвоен тип t('a, can_receive, 's) => t('a, can_receive, cannot_send), что внутри модуля эквивалентно доказательству того, что он имеет тип channel('a) => channel('a).

/* Channel.re part 7 of 7 */

let to_read_only = channel => channel  

let to_write_only = channel => channel

Готовы запустить несколько примеров? Создайте файл Example.re, open модуль канала и вперед! Рабочий процесс состоит из запуска npm run build, затем node src/Example.bs.js для запуска кода JS, сгенерированного из вашего Example.re модуля. Также неплохо изучить сгенерированные *.bs.js файлы, они очень читабельны.

Вот что вам нужно для начала:

/* Example2.re */

open Channel

let end' = _ => ()  /* = ignore from Pervasives */

let filter = (source, pred) => {
    let target = create()
    source |. listen( m => 
        pred(m) ? target |. send(m) |. end' : ()
    ) 
    to_read_only(target)
}

let chan = create() 

chan |. filter(i => i > 0) 
     |. listen(i => Js.log(i)) 
     |. end'

chan |. send(1) 
     |. send(-1) 
     |. end' 

chan |. send(2) |. end'

/* Output:
   1 
   2
*/

Чтобы выполнить вышеуказанное, сначала выполните npm run build, а затем node src/Example2.bs.js.

Теперь попробуйте то же самое с Example1.re, и компилятор немедленно сообщит вам, что у вас есть ошибка! Результат должен быть примерно таким:

9 │ chan_ro |. send(5)  /* Rejected by the compiler. */
  
This has type:
Channels.Channel.t('a, Channels.Channel.can_receive, 
                       Channels.Channel.cannot_send)
    ...
But somewhere wanted:
Channels.Channel.t('a, Channels.Channel.can_receive, 
                       Channels.Channel.can_send)
    ...
  
The incompatible parts:
Channels.Channel.cannot_send 
vs
Channels.Channel.can_send

Довольно понятно, правда?

Вывод

Компилятор OCaml делает за нас много тяжелой работы по поиску, а также объяснению ошибок. Это помогает устранить большой класс потенциальных ошибок и осуществлять точный контроль над нашими абстракциями. Тем не менее, типы на самом деле не мешают: в большинстве случаев мы можем избегать их написания, что приводит к чистому и удобочитаемому коду.

В будущей статье мы представим полный набор утилит для реактивного / потокового программирования, используя Channel в качестве основного строительного блока. Этот новый модуль может включать в себя большую часть функций, имеющихся в таких фреймворках, как RxJS. Фактически, он обеспечивает лучшие статические гарантии с реализацией, которая на порядок меньше. Быть в курсе!

Автор Димитрис Мостроус | Технический руководитель Cleverti

Первоначально опубликовано на www.cleverti.com.