Використовуємо Golang для розробки Node.js-застосунків (Node.js: In Go We Trust)

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Harmonica: [до одного з трьох чоловіків] Ти Френк?
Snaky: Френк прислав нас.
Harmonica: Ви привели для мене коня?
Snaky: Хіба схоже... хіба схоже, що у нас є зайвий кінь?
Harmonica: Ви навіть двох зайвих привели.
(Once Upon a Time in the West, 1968)

Мене звати Олексій Новохацький, я — Software Engineer. Зараз працюю над архітектурою високонавантажених систем, проводжу технічні співбесіди, втілюю в життя власні проєкти.

Як відомо, Node.js добре справляється з I/O intensive завданнями. А от для вирішення CPU bound ми маємо декілька варіантів — child processes/cluster, worker threads. Також є можливість використати іншу мову програмування (C, C++, Rust, Golang), як окремий сервіс/мікросервіс або через WebAssembly скрипти.

У даній оглядовій статті будуть описані підходи до використання Golang в розробці Node.js-застосунків для запуску деяких CPU intensive завдань (простої суми чисел, послідовності Фібоначчі, а також для таких хеш-функцій, як md5 та sha256).

Які ми маємо варіанти?

1. Спробувати вирішити CPU bound завдання лише за допомогою Node.js.

2. Створити окремий сервіс, написаний на Golang, та «спілкуватись» з нашим застосунком за допомогою запитів/брокера повідомлень тощо (в даній статті будуть використанні звичайні http-запити).

3. Використати Golang для створення wasm файлу, що дасть можливість використати додаткові методи в Node.js.

Швидкість та гроші

Я — фан старих добрих спагетті-вестернів, особливо «Хороший, поганий, злий». У цій
статті — три підходи до вирішення завдань, а в цьому фільмі три зовсім різні герої, які дуже добре характеризують ці підходи.

Тож давайте зануримось в атмосферу тих часів, коли швидкість та гроші вирішували все... Дикий Захід.

Node.js (The Good)

Переваги:

1. Одна і та ж мова (JavaScript) на фронтенді та бекенді.

2. Майстер I/O операції — має супершвидкий event loop.

3. Найбільший арсенал зброї — npm.

Golang (The Bad)

Переваги:

1. Розроблений в Google.

2. Підтримується майже на всіх OS.

3. Горутини — спеціальні функції Golang, які відпрацьовують конкурентно з іншими функціями та методами (добре справляються з CPU bound завданнями).

4. Простий синтаксично — має лише 25 ключових слів.

nodejs-golang/WebAssembly (The Ugly)

Переваги:

1. Доступний скрізь.

2. Доповнює JavaScript.

3. Дає можливість писати код на різних мовах та використовувати .wasm-скрипти в JavaScript.

Трохи докладніше про останній підхід

Код, написаний на Golang, може бути перетворений на .wasm-файл за допомогою наведеної нижче команди, якщо встановити Operating System як «js» і Architecture як «wasm» (список можливих значень GOOS і GOARCH знаходиться тут):

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

Для запуску скомпільованого Go коду необхідно зв’язати його через спеціальний інтерфейс wasm_exec.js. Вміст за посиланням:

${GOROOT}/misc/wasm/wasm_exec.js

Для використання WebAssembly я використав @assemblyscript/loader і створив модуль nodejs-golang (до речі, @assemblyscript/loader — це єдина залежність даного модуля). Цей модуль допомагає створювати, білдити та запускати окремі wasm-скрипти або функції, які можуть бути використані в JavaScript-коді.

require('./go/misc/wasm/wasm_exec');
const go = new Go();
...
const wasm = fs.readFileSync(wasmPath);
const wasmModule = await loader.instantiateStreaming(wasm, go.importObject);
go.run(wasmModule.instance);

До речі, інші мови аналогічно можуть бути використані для створення .wasm-файла.

C: emcc hello.c -s WASM=1 -o hello.html

C++: em++ hello.cpp -s WASM=1 -o hello.html

Rust: cargo build —target wasm —release

Давайте перевіримо, хто у нас найшвидший на Дикому Заході

«Ви не можете сказати, наскільки хороший
чоловік або кавун, поки по ньому не постукаєте»
Roy Bean

Для цього ми створимо два простих сервери:

1. Golang сервер

package main

import (
    ...
    "fmt"
    ...
    "net/http"
    ...
)

func main() {
    ...
    fmt.Print("Golang: Server is running at http://localhost:8090/")
    http.ListenAndServe(":8090", nil)
}

2. Node.js сервер

const http = require('http');
...
(async () => {
  ...
  http.createServer((req, res) => {
    ...
  })
  .listen(8080, () => {
    console.log('Nodejs: Server is running at http://localhost:8080/');
  });
})();

Ми будемо вимірювати час виконання кожного завдання — для Golang сервера це буде час безпосереднього виконання функції + мережева затримка запиту. В той час як для Node.js і WebAssembly — це буде лише час виконання функції.

Фінальна дуель

1. «ping» (просто перевіримо, скільки часу піде на виконання запиту).

Node.js

const nodejsPingHandler = (req, res) => {
  console.time('Nodejs: ping');

  const result = 'Pong';

  console.timeEnd('Nodejs: ping');

  res.statusCode = 200;
  res.setHeader('Content-Type', 'application/json');
  res.write(JSON.stringify({ result }));
  res.end();
};

Golang

// golang/ping.js

const http = require('http');

const golangPingHandler = (req, res) => {
  const options = {
    hostname: 'localhost',
    port: 8090,
    path: '/ping',
    method: 'GET',
  };

  let result = '';

  console.time('Golang: ping');

  const request = http.request(options, (response) => {
    response.on('data', (data) => {
      result += data;
    });
    response.on('end', () => {
      console.timeEnd('Golang: ping');

      res.statusCode = 200;
      res.setHeader('Content-Type', 'application/json');
      res.write(JSON.stringify({ result }));
      res.end();
    });
  });

  request.on('error', (error) => {
    console.error(error);
  });

  request.end();
};

// main.go

func ping(w http.ResponseWriter, req *http.Request) {
    fmt.Fprintf(w, "Pong")
}

nodejs-golang

// nodejs-golang/ping.js

const nodejsGolangPingHandler = async (req, res) => {
  console.time('Nodejs-Golang: ping');

  const result = global.GolangPing();

  console.timeEnd('Nodejs-Golang: ping');

  res.statusCode = 200;
  res.setHeader('Content-Type', 'application/json');
  res.write(JSON.stringify({ result }));
  res.end();
};

// main.go

package main

import (
    "syscall/js"
)

func GolangPing(this js.Value, p []js.Value) interface{} {
    return js.ValueOf("Pong")
}

func main() {
    c := make(chan struct{}, 0)

    js.Global().Set("GolangPing", js.FuncOf(GolangPing))

    <-c
}

Результат:

2. Наступним завданням буде просто сума двох чисел.

Node.js

const result = p1 + p2;

Golang

func sum(w http.ResponseWriter, req *http.Request) {
    p1, _ := strconv.Atoi(req.URL.Query().Get("p1"))
    p2, _ := strconv.Atoi(req.URL.Query().Get("p2"))

    sum := p1 + p2

    fmt.Fprint(w, sum)
}

nodejs-golang

func GolangSum(this js.Value, p []js.Value) interface{} {
    sum := p[0].Int() + p[1].Int()
    return js.ValueOf(sum)
}

Результат:

3. Далі послідовність Фібоначчі (отримуємо 100000-е число).

Node.js

const fibonacci = (num) => {
  let a = BigInt(1),
    b = BigInt(0),
    temp;

  while (num > 0) {
    temp = a;
    a = a + b;
    b = temp;
    num--;
  }

  return b;
};

Golang

func fibonacci(w http.ResponseWriter, req *http.Request) {
    nValue, _ := strconv.Atoi(req.URL.Query().Get("n"))

    var n = uint(nValue)

    if n <= 1 {
        fmt.Fprint(w, big.NewInt(int64(n)))
    }

    var n2, n1 = big.NewInt(0), big.NewInt(1)

    for i := uint(1); i < n; i++ {
        n2.Add(n2, n1)
        n1, n2 = n2, n1
    }

    fmt.Fprint(w, n1)
}

nodejs-golang

func GolangFibonacci(this js.Value, p []js.Value) interface{} {
    var n = uint(p[0].Int())

    if n <= 1 {
        return big.NewInt(int64(n))
    }

    var n2, n1 = big.NewInt(0), big.NewInt(1)

    for i := uint(1); i < n; i++ {
        n2.Add(n2, n1)
        n1, n2 = n2, n1
    }

    return js.ValueOf(n1.String())
}

Результат:

4. Давайте перейдемо до старих добрих хеш-функцій. Спочатку — md5 (10k рядків).

Node.js

const crypto = require('crypto');

const md5 = (num) => {
  for (let i = 0; i < num; i++) {
    crypto.createHash('md5').update('nodejs-golang').digest('hex');
  }
  return num;
};

Golang

func md5Worker(c chan string, wg *sync.WaitGroup) {
    hash := md5.Sum([]byte("nodejs-golang"))

    c <- hex.EncodeToString(hash[:])

    wg.Done()
}

func md5Array(w http.ResponseWriter, req *http.Request) {
    n, _ := strconv.Atoi(req.URL.Query().Get("n"))

    c := make(chan string, n)
    var wg sync.WaitGroup

    for i := 0; i < n; i++ {
        wg.Add(1)
        go md5Worker(c, &wg)
    }

    wg.Wait()

    fmt.Fprint(w, n)
}

nodejs-golang

func md5Worker(c chan string, wg *sync.WaitGroup) {
    hash := md5.Sum([]byte("nodejs-golang"))

    c <- hex.EncodeToString(hash[:])

    wg.Done()
}

func GolangMd5(this js.Value, p []js.Value) interface{} {
    n := p[0].Int()

    c := make(chan string, n)
    var wg sync.WaitGroup

    for i := 0; i < n; i++ {
        wg.Add(1)
        go md5Worker(c, &wg)
    }

    wg.Wait()

    return js.ValueOf(n)
}

Результат:

5. І нарешті sha256 (10k рядків).

Node.js

const crypto = require('crypto');

const sha256 = (num) => {
  for (let i = 0; i < num; i++) {
    crypto.createHash('sha256').update('nodejs-golang').digest('hex');
  }
  return num;
};

Golang

func sha256Worker(c chan string, wg *sync.WaitGroup) {
    h := sha256.New()
    h.Write([]byte("nodejs-golang"))
    sha256_hash := hex.EncodeToString(h.Sum(nil))

    c <- sha256_hash

    wg.Done()
}

func sha256Array(w http.ResponseWriter, req *http.Request) {
    n, _ := strconv.Atoi(req.URL.Query().Get("n"))

    c := make(chan string, n)
    var wg sync.WaitGroup

    for i := 0; i < n; i++ {
        wg.Add(1)
        go sha256Worker(c, &wg)
    }

    wg.Wait()

    fmt.Fprint(w, n)
}

nodejs-golang

func sha256Worker(c chan string, wg *sync.WaitGroup) {
    h := sha256.New()
    h.Write([]byte("nodejs-golang"))
    sha256_hash := hex.EncodeToString(h.Sum(nil))

    c <- sha256_hash

    wg.Done()
}

func GolangSha256(this js.Value, p []js.Value) interface{} {
    n := p[0].Int()

    c := make(chan string, n)
    var wg sync.WaitGroup

    for i := 0; i < n; i++ {
        wg.Add(1)
        go sha256Worker(c, &wg)
    }

    wg.Wait()

    return js.ValueOf(n)
}

Результат:

І підсумковий результат

Що ми сьогодні довідались:

  • Існує Node.js, який добре виконує свою роботу.
  • Існує Golang, який добре виконує свою роботу.
  • Існує WebAssembly (а тепер і мій модуль nodejs-golang), який добре виконує свою роботу.
  • Golang можна використовувати як: самостійний додаток, сервіс / мікросервіс, джерело для wasm-скрипта (який потім можна використовувати в JavaScript).
  • Node.js і Golang мають готові механізми використання WebAssembly в JavaScript.

Висновки

«Швидкість — це добре, але точність — це все»
Wyatt Earp
  • Не запускати CPU-bound завдання з Node.js (якщо є можливість).
  • Насправді краще не робити жодного завдання, якщо це можливо.
  • Якщо вам потрібно запустити CPU-bound завдання у Node.js-застосунку — спробуйте зробити це за допомогою Node.js. (це буде не так погано).
  • Між продуктивністю (з використанням інших мов) і зручністю читання (продовжуючи зберігати лише код JavaScript у команді, яка підтримує лише JavaScript) краще вибрати читабельність.
  • «Хороший архітектор відштовхується від того, що рішення ще не ухвалене, і формує систему таким чином, що ці рішення досі можуть бути змінені або відкладені якомога на довше. Хороший архітектор максимізує кількість не ухвалених рішень» — Clean Architecture by Robert C. Martin.
  • Краще «тримати окремe окремо». Створіть сервіс/мікросервіс для важких обчислень — за потреби буде легко масштабуватись.
  • WebAssembly корисний насамперед для браузерів. Бінарник Wasm менший і простіший для парсингу, ніж код JS тощо.

Дякую за прочитання. Сподіваюсь, вам сподобалось.

Підписуйтесь на Medium та My-Talks, щоб не пропустити нових статей та виступів.

Для додаткової інформації і можливості перевірити результати, будь ласка, відвідайте мій github.

Модуль, що був написаний спеціально для статті, — nodejs-golang.

До нових пригод!

*Примітка:
Завдяки уважним читачам в тексті було виявлено декілька неточностей. Зокрема виправлено виклик синхронних функцій (md5/sha256) за допомогою async/await (Node.js). Прошу вибачення за допущену помилку в написанні коду. Результати замірів перевірено — відхилення не мають значного впливу на дослідження вцілому і становлять ± 5-10% для вищезазначених функцій. Повної статистичної оцінки не було проведено, так як це виходило за рамки мети даної статті — показати різницю між використанням Golang для написання окремого сервісу і для створення wasm файлу.
Дякую всім за плідне обговорення.

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

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

я слышал у голанга есть компилятор в jvm как котлин и скала только голанг

Интересное сравнение. А как насчет Go vs Rust через wasm в мобильном броузере для задачи типа валидации и генерации RSA- и ECDSA-подписей и шифрования /дешифрования. Дает ли Rust значимый прирост по скорости для таких задач, заметно ли отсутствие сборки мусора в нем, т.е. кто из них больше Good для такой задачи?

Дякую за відгук.

Так задача дійсно цікава. Відповіді наразі не маю.

Разрешите дорефакториться :)
1) Синтаксис порочащий Node при сравнении с Go :)
— require вместо import
— рудиментарное async IIFE

const http = require(’http’);
...
(async () => {
...
http.createServer((req, res) => {
...
})
.listen(8080, () => {
console.log(’Nodejs: Server is running at http://localhost:8080/’);
});
})();

2) Абсолютно бессмысленное и беспощадное замедление кода при использовании синхронной функции как асинхронной, с протаскиванием через event-loop, тем более в контексте бенчмарка. Может просто ошибка с API т.к.

digest
 — синхронная соответственно не имеет колбека, а значит не может возвращать промис. Ну или всовывание await везде обусловлено непониманием асинхронной природы node.
Эти бенчмарки ноды можно сразу выкинуть, так как там на порядок результат убит.
const crypto = require(’crypto’);

const md5 = async (num) => {
for (let i = 0; i < num; i++) {
await crypto.createHash(’md5’).update(’nodejs-golang’).digest(’hex’);
}
return num;
};

3) сравнение одного потока ноды без воркеров и нескольких в Go... ну такое... В ноде эти хеши не используют внутренний пул потоков

Дякую.

З основним тезами згоден.
Використання синхронної функції мінімально вплинуло на результат замірів (перевірив).
Щодо воркерів — так, була думка порівняти багатопоточності, але в даній статті таких цілей не ставив.

Ну вообще вопросов мелких по node коду там много- в ноде значит будем строку в JSON пихать

res.write(JSON.stringify({ result }));

В Go почему то голую строку отправляем в ответ и довольны:

fmt.Fprintf(w, «Pong»)

И сравниваем при этом производительность. Оно все копейки, но объективность теряется.
Так что

res.write("Pong");
Використання синхронної функції мінімально вплинуло на результат замірів (перевірив).

Зависит сколько там псевдо асинхронных вызовов в сек. На текущем тест кейсе разница в 25% при 10к итерациях на моем железе. А если такое делать кучу таких await на быстрых синхронных функциях, то тормоза будут куда заметнее.

const crypto = require('crypto');

const md5Async = async (num) => {
  while (num-- > 0) {
    await crypto.createHash('md5').update('nodejs-golang').digest('hex');
  }
  return num;
};

const md5 = (num) => {
  while (num-- > 0) {
    crypto.createHash('md5').update('nodejs-golang').digest('hex');
  }
  return num;
};

(async () => {
  console.time('pseudo-async');
  await md5Async(10000);
  console.timeEnd('pseudo-async');

  console.time('sync');
  md5(10000);
  console.timeEnd('sync');
})();

Тайминги:
async: 79.088ms
sync: 48.611ms
Можете переписать Фибоначчи добавив ненужный await, то разница там будет колоссальной для n=1000.
Ну и бенчмарках для ноды есть потенциальная проблема, что там не прогревается код перед запуском замеров простым console.time. То есть оптимизирующий компилятор еще не понял, что функция горячая и ее надо максимально заоптимизировать. Там не так просто адекватно это померить голыми руками.

Це досить типова помилка розробників, які не вникли в суть роботи EL. Await не виконає синхронний код асинхронно.

Плюсану. Для вирішення CPU bound задач в ноді є більш ніж 1 спосіб.
nodejs.org/...​ont-block-the-event-loop

Займатись таким пінг-понгом боляче, особливо коли можна одразу написати на Go.

Дякую за відгук.

Саме ця теза і є одним з висновків до даної статті.

Ми будемо вимірювати час виконання кожного завдання — для Golang сервера це буде час безпосереднього виконання функції + мережева затримка запиту. В той час як для Node.js і WebAssembly — це буде лише час виконання функції.

А чому так?

Також, чи я вірно розумію, що судячи по бенчмарках, використання зв’язки нода+web assembly не має змісту, бо навіть сама нода швидша?
Чи є варіанти, коли нода+web assembly буде швидше?

Дякую за інтерес до статті.

Golang в рамках даного дослідження використовувався як мікросервіс, на який йшов запит від умовного Node.js (core) застосунку. Відповідно сам Node.js та WebAssembly були частиною цього застосунку. Тому мережева затримка була присутня лише у випадку з Golang.

Так, в даних конкретних випадках використання WebAssembly не мало значного сенсу, так як Node.js (а точніше С-шна його частина) справляється навіть краще.

Щодо WebAssembly, то імовірно виграш буде у випадках великих проектів/бібліотек по типу tensorflow.js або Google Earth. Але таке дослідження, на жаль, точно не могло увійти в рамки статті.

Підписатись на коментарі