NodeJS — отличный инструмент для быстрой настройки небольших сервисов, таких как HTTP-серверы, благодаря простоте интеграции с другими библиотеками, такими как SSR для веб-фреймворков и базами данных NoSQL, такими как MongoDB.

Однако из-за своей интерпретируемой природы JavaScript не является подходящим инструментом для задач, интенсивно использующих память. Другие скомпилированные языки, такие как Golang, берут верх над некоторыми задачами, поскольку они оптимизированы для них. Было бы здорово, если бы мы могли получить лучшее из обоих миров, не так ли? Введите WebAssembly.

Краткий отказ от ответственности

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

Некоторый фон

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

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

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

Хотя JIT выполняет множество других конкретных улучшений, в целом его цель заключается в следующем: скомпилировать код JavaScript в оптимизированные инструкции. Благодаря JIT-компиляции такие механизмы, как браузеры, могут выполнять интерпретируемый код с почти скоростью исходного кода (ключевое слово почти). И в этом заключается преимущество таких компилируемых языков, как Go: их строго типизированный характер (помимо других особенностей языка) позволяет компиляторам выполнять все оптимизации заранее, в результате чего приложениям требуется только выполнение байт-кода.

Веб-сборка

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

Особенности ВАСМ

  • Быстро. Код, скомпилированный с помощью WASM, работает почти так же быстро, как приложения, скомпилированные в собственном коде. Это позволяет нам запускать приложения с интенсивным использованием памяти, которые обычно невозможно запустить в клиентах с низким уровнем ресурсов, таких как браузеры или устройства IoT.
  • Безопасно. Модули WASM имеют доступ только к линейному набору памяти, изолированному от остального приложения. Это снижает риск того, что вредоносный код получит доступ к данным или ресурсам, которым он не должен.
  • Портативный. WASM работает как контейнер (или виртуальная машина), что позволяет ему компилировать байт-код, который можно запускать в различных архитектурах: браузерах, мобильных устройствах и внутренних серверах; пока он поддерживает спецификацию WASM, он может выполнять код WASM.
  • Гибкий. WASM сам по себе является целью компиляции. Это означает, что потенциально вы можете использовать любой язык программирования для написания кода и создания байт-кода, который может работать практически везде.

Ограничения WASM

Та же самая изоляция и гибкость, которые обеспечивают большую часть сильных сторон WASM, являются причиной большинства его ограничений:

  • WASM не предоставляет механизм управления памятью по умолчанию (например, сборка мусора). Это означает, что в настоящее время каждый модуль WASM должен поставляться со своим кодом управления памятью. (Это одна из причин, почему Rust отлично подходит для модулей WASM, так как управление памятью встроено в язык)
  • Связь между модулем WASM и остальной частью приложения должна осуществляться в очень простых типах (байты, целые числа и числа с плавающей запятой). Сложные типы пока не поддерживаются. Вот почему большинство компиляторов WASM также предоставляют некоторый связующий код для сопоставления сложных типов, таких как строки или массивы. Интерфейс системы веб-сборки (WAS) — это постоянно развивающийся стандарт, направленный на устранение этого последнего ограничения; как только он станет зрелым, он позволит легко взаимодействовать практически с любой средой. WASI уже доступен в некоторых компиляторах и средах выполнения WSAM.

Вариант использования: лог-хвост

Большинство примеров WASM — это просто «Hello World». Для этого поста я подумал, что было бы полезно увидеть более конкретный вариант использования, проблему, которую я обнаружил в прошлом.

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

Во-первых, давайте создадим простой скрипт NodeJS с именем logger.js, чтобы имитировать создание этого файла журнала:

const fs = require('fs')
const util = require('util')

var logFile = fs.createWriteStream('test.log', { flags: 'a' })
function log() {
  // console.log(...arguments);
  logFile.write(util.format.apply(null, arguments) + '\n')
}
function randomElement(arr) {
  return arr[Math.floor(Math.random() * arr.length)]
}
function randomTime() {
  const time = [1000, 100, 3000, 0]
  return randomElement(time)
}
function randomLevel() {
  const levels = ['WARN', 'INFO', 'ERROR']
  return randomElement(levels)
}
let i = 0
function startLogger() {
  log(`${randomLevel()} Log number ${i++}`)
  setTimeout(() => {
    startLogger()
  }, randomTime())
}
startLogger()

Во-первых, мы создаем NodeJS ReadStream, используя fs.createWriteStream, чтобы открыть файл test.log в режиме добавления. Затем мы начинаем рекурсивно зацикливаться, используя setTimeout. Это имитирует приложение, которое записывает текст в файл с переменной скоростью. На каждой итерации:

  • Мы печатаем некоторый простой текст со случайным уровнем журнала от {'WARN', 'INFO', 'ERROR'} в файл журнала.
  • Мы случайным образом выбираем количество миллисекунд из {1000, 100, 3000, 0} для ожидания следующей итерации.

Чтение файла журнала в Go

Мы напишем наш модуль на Go. Go предлагает нативную компиляцию WASM без необходимости установки других библиотек или компиляторов.

Включение вывода сложных типов

Как я упоминал ранее, модули WASM не могут выводить сложные типы, поэтому, если мы хотим иметь возможность отправлять что-то отличное от чисел или байтов, нам нужен некоторый «связующий» код, который заботится о преобразовании типов, таких как строки и фрагменты, в байты. Репозиторий Go предлагает очень удобный wasm_exec.js, который позволит нашему модулю WASM общаться с внешним миром.

Скрипт wasm_exec.js имеет некоторые ограничения:

  • Он был создан для использования в браузерах. Мы можем обойти этот факт, внеся небольшие изменения в скрипт, чтобы сделать его совместимым с NodeJS. Мы определим эти изменения в следующем посте.
  • Он не поддерживает пользовательские структуры данных. Это имеет смысл, так как наш модуль WASM должен был бы экспортировать какую-то схему, чтобы тот, кто использует модуль, знал, как анализировать вывод. Однако wasm_exect.js поддерживает срезы и карты, что позволяет нам маршалировать определенные Go структуры в такие объекты, как map[string]interface{}.

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

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

Лучшим подходом является использование обратного вызова: мы определяем функцию в NodeJS, которую можно вызывать из модуля WASM каждый раз, когда новые журналы анализируются и готовы к использованию.

Код Go

Сначала мы создаем новый модуль Go:

go mod init wasm-test.com

В той же папке, где был создан файл go.mod, создаем wasm.go.

Во-первых, мы определим типы нашего вывода. Для удобства мы создадим новый тип с именем ParsedLogs, который является просто псевдонимом для map[string]interface{}.

type ParsedLogs = map[string]interface{}

Теперь давайте определим функцию, которая несет только одну ответственность: отправка вывода обратно в NodeJS. Однако, поскольку мы хотим проверить, что все работает без компиляции и развертывания модуля, пока что наш обратный вызов просто выведет проанализированные журналы в консоль:

func ModuleOutput(parsedLogs ParsedLogs) {
    fmt.Println(parsedLogs)
}
type OutputCallback = func(parsedLogs ParsedLogs)

OutputCallback — это еще один псевдоним, который мы создаем просто для удобства, поэтому нам не нужно использовать подробный func(parsedLogs ParsedLogs), когда мы передаем нашу функцию в качестве параметра основному процессу.

Теперь мы определяем две функции: main, которая является точкой входа в модуль, и Execute, которая будет содержать фактическую логику разбора журнала:

func Execute(callbackFn OutputCallback) {
    //... call callbackFn to send parsed logs
}

func main() {
    Execute(ModuleOutput)
}

Чтение файла журнала

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

func Execute(callbackFn OutputCallback) {
    file, err := os.Open("./test.log")
    if err != nil {
        log.Fatal(err)
    }
    defer file.Close()
    acc := NewAccumulator()
    defer close(acc.out)
    go func() {
        for {
            select {
            case str := <-acc.out:
                l := parse(str)
                callbackFn(l.ToMap())
            case <-time.After(2 * time.Second):
                acc.Flush()
            }
        }
    }()
    reader := bufio.NewReader(file)
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            if err == io.EOF {
                time.Sleep(500 * time.Millisecond)
                continue
            }
            log.Fatal(err)
        }
        acc.Append(line)
    }
}

Здесь происходит много вещей, поэтому давайте разберем их.

Во-первых, мы открываем наш файл журнала, убедившись, что мы отложили его функцию close:

file, err := os.Open("./test.log")
if err != nil {
    log.Fatal(err)
}
defer file.Close()

Затем мы создаем экземпляр Accumulator — структуры, которую мы еще не определили, но позаботимся об объединении журналов по мере их печати:

acc := NewAccumulator()
defer close(acc.out)

Теперь остальная часть кода делает две вещи параллельно:

  • В цикле он считывает файл журнала, по одной строке за раз, и отправляет его в Accumulator.
  • В другом цикле он проверяет содержимое Accumulator и, если есть обработанные журналы, готовые к отправке, вызывает выходной обратный вызов, передавая проанализированные журналы в качестве параметра.

Как ни странно, первый цикл можно увидеть в конце функции:

reader := bufio.NewReader(file)
for {
    line, err := reader.ReadString('\n')
    if err != nil {
        if err == io.EOF {
            time.Sleep(500 * time.Millisecond)
            continue
        }
        log.Fatal(err)
    }
    acc.Append(line)
}

Мы создаем средство чтения файлов и внутри бесконечного цикла читаем файл по одной строке за раз. Если средство чтения файлов достигает конца файла (что происходит, когда ReadString возвращает io.EOF как ошибку), мы просто приостанавливаем процесс на полсекунды, прежде чем снова проверить, есть ли в файле новые записи журнала. Этот вызов time.Sleep является критическим, иначе цикл будет выполняться на полной скорости, потребляя больше памяти, чем необходимо.

Когда ридер выводит новую строку, мы просто добавляем ее в Accumulator.

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

go func() {
    for {
        select {
        case str := <-acc.out:
            l := parse(str)
            callbackFn(l.ToMap())
        case <-time.After(2 * time.Second):
            acc.Flush()
        }
    }
}()

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

Мы объясним, что делает второй case внутри блока select в следующем разделе.

Аккумулятор

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

Мы видим, что независимые записи журнала содержат уровень журнала, например WARN или ERROR. Это помогает нам определить, является ли строка началом новой записи журнала или частью предыдущей строки.

Структура Accumulator в основном представляет собой оболочку вокруг strings.Builder:

var r, _ = regexp.Compile("(WARN|ERROR|INFO) (.+)")
type Accumulator struct {
    sb  strings.Builder
    out chan string
}
func NewAccumulator() Accumulator {
    return Accumulator{sb: strings.Builder{}, out: make(chan string, 10)}
}
func (a *Accumulator) Append(str string) {
    if r.MatchString(str) {
        a.Flush()
    }
    a.sb.WriteString(str)
}
func (a *Accumulator) Flush() {
    if a.sb.Len() > 0 {
        res := a.sb.String()
        go func(res string) {
            a.out <- res
        }(res)
        a.sb = strings.Builder{}
    }
}

Мы используем функцию Append, чтобы добавить новую строку в файл Accumulator. Внутри этой функции мы проверяем, есть ли в логе слова ERROR, WARN или INFO; если они не совпадают, мы добавляем их в построитель строк. Однако, если они совпадают, мы вызываем функцию Flush.

Функция Flush проверяет, пуст ли построитель строк. Если это не так, он преобразует его в строку и отправит на канал out go. Затем он очищает построитель строк, создавая новый экземпляр, подготавливая его к началу получения новых строк из файла журнала.

Теперь вспомните, что мы также вызывали Accumulator.Flush в бесконечном цикле, который считывает вывод канала out:

select {
case str := <-acc.out:
    l := parse(str)
    callbackFn(l.ToMap())
case <-time.After(2 * time.Second):
    acc.Flush()
}

Это связано с тем, что сам аккумулятор вызывает Flush только при получении новой строки текста. Если бы мы вызвали Flush только внутри Append, Accumulator подождал бы, чтобы вывести последнюю запись журнала, пока в файле журнала не появится новое содержимое, в результате чего последняя запись всегда будет отсутствовать в выводе.

Например, если бы наш лог-файл содержал только следующую строку:

WARN - Hello

Затем добавляется вторая строка:

WARN - Hello
ERROR - Something awful happened!

Первая строка будет отправлена ​​в NodeJS при добавлении второй. Но сама вторая строка останется внутри Accumulator. Если процесс полностью прекратит отправку журналов, код NodeJS никогда не увидит вторую строку.

Однако, поскольку наш код вызывает Flush каждые 2 секунды после того, как новые обработанные журналы не получены, вторая запись будет обработана и отправлена ​​обратно в NodeJS.

Разбор логов

Код, используемый для анализа журналов, довольно прост. Он решает простое форматирование записей журнала в нашем примере, но вы можете легко расширить его для анализа более сложных шаблонов:

Мы создаем простую структуру Log, в которой есть функция ToMap, возвращающая экземпляр ParsedLogs — псевдоним, который мы ранее определили для map[string]interface{}-.

type Log struct {
    Level string
    Msg   string
}

func (l Log) ToMap() ParsedLogs {
    m := make(map[string]interface{})
    m["level"] = l.Level
    m["msg"] = l.Msg
    return m
}

Затем мы, наконец, определяем функцию parse, которую мы вызвали в нашей горутине. Эта функция использует регулярное выражение для анализа уровня журнала из записи и текста, следующего за ней. Он создает экземпляр Log и заполняет его атрибуты значениями, проанализированными из регулярного выражения.

func parse(str string) Log {
    log := Log{}
    if !r.MatchString(str) {
        log.Msg = str
        return log
    }
    groups := r.FindStringSubmatch(str)
    log.Level = groups[1]
    log.Msg = groups[2]
    return log
}

Технически нам не нужно было определять новую структуру, так как мы можем легко вывести map[string]interface{} напрямую. Однако дополнительная абстракция дает нам заполнитель, если нам нужно выполнить дополнительную логику синтаксического анализа.

Теперь мы можем проверить работу нашего кода, запустив logger.js и wasm.go параллельно:

Теперь пришло время обновить наш код Go, чтобы он был скомпилирован как модуль WASM.

Модуль системного вызова/js

Мы импортируем модуль go "syscall/js", который определен в документации следующим образом:

Пакет js предоставляет доступ к хост-среде WebAssembly при использовании архитектуры js/wasm. Его API основан на семантике JavaScript.
Этот пакет является ЭКСПЕРИМЕНТАЛЬНЫМ. В настоящее время он предназначен только для запуска тестов, но еще не для предоставления исчерпывающего API для пользователей. На него не распространяется обещание совместимости с Go.

По сути, эта библиотека позволяет нам взаимодействовать с кодом NodeJS, который будет выполнять наш модуль WASM.

Из syscall/js мы можем получить ссылки на глобальные переменные внутри NodeJS. Затем мы можем определить функцию в NodeJS следующим образом:

globalThis.logCallback = () => {
  /* ... */
}

И затем внутри нашего кода Go мы можем получить ссылку на эту функцию следующим образом:

func ModuleOutput(parsedLogs ParsedLogs) {
    logCallback := js.Global().Get("logCallback")
    logCallback.Invoke(parsedLogs)
}

Одна вещь, которую вы заметите, это то, что syscall/js доступен только для архитектуры WASM. Если мы сейчас попытаемся запустить наш модуль Go с go run, мы увидим сообщение об ошибке, подобное следующему:

package command-line-arguments
        imports syscall/js: build constraints exclude all Go files in /usr/local/go/src/syscall/js

Как только мы импортируем syscall/js, компилятор ограничивает тип архитектуры, для которой этот модуль может быть собран.

По этой причине мы не вызывали js.Global() непосредственно внутри Execute; он отделяет большую часть кода от зависимостей, специфичных для WASM, что упрощает их повторное использование в приложениях, отличных от WASM.

Если мы хотим выполнить наш код сейчас, нам придется скомпилировать его в байт-код WebAssmebly; это можно сделать с помощью следующей команды:

GOOS=js GOARCH=wasm go build -o app.wasm

Установив для переменной среды GOARCH значение wasm, модуль теперь будет успешно компилироваться. Кроме того, переменная среды GOOS=js сообщает компилятору, что мы будем использовать этот модуль WASM в JavaScript.

Если команда go build выполнена успешно, вы должны увидеть новый файл с именем app.wasm в корневой папке. Это модуль WASM, который мы импортируем в NodeJS.

В следующем сообщении блога мы рассмотрим вторую часть этого процесса: импорт скомпилированного модуля WASM в приложение NodeJS.

Заключение

Благодаря текущим возможностям NodeJS и Golang теперь можно использовать WebAssembly как способ создания модулей кода, которые работают почти так же хорошо, как собственный код, и обеспечивают функциональность, которая исторически была непомерно высокой с точки зрения памяти и времени выполнения в интерпретируемом коде. языки, такие как JavaScript.

Мы видели, что Golang предлагает поддержку компиляции для WASM из коробки. В этом примере мы использовали нативные функции Go, такие как каналы Go и горутины, подчеркнув, что они могут хорошо работать в контексте WebAssembly.

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

Однако есть и другие, более совершенные библиотеки, такие как wasmer-go, которые обеспечивают среду выполнения и помогают нам обойти эти ограничения. В wasmer-go документации содержится хорошее описание этих проблем:

Основная проблема заключается в том, что хотя компилятор Go поддерживает WebAssembly, он не поддерживает WASI (системный интерфейс WebAssembly). Он создает ABI, тесно связанный с JavaScript, и нужно использовать файл wasm_exec.js, предоставленный цепочкой инструментов Go, который не работает за пределами хоста JavaScript.

Затем важно отметить, что наш пример работает только в NodeJS. Скомпилированный модуль WASM нельзя использовать в других средах с поддержкой WASM. Несмотря на это, мы можем взять тот же код и использовать такие инструменты, как wasmer-go или tinygo, и это ограничение можно обойти.

WebAssembly имеет многообещающее будущее в качестве стандарта для модульного взаимодействия кода. Мы можем ожидать, что будущие улучшения в спецификации (особенно с WASI) откроют двери для использования WASM для создания встраиваемых контейнеров функций, которые можно импортировать практически в любую среду, от браузера и внутренних серверов до устройств граничных вычислений, таких как устройства IoT. .