Використовуємо Golang для розробки Node.js-застосунків (Node.js: In Go We Trust)
Повна версія
Використовуємо 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 файлу.
Дякую всім за плідне обговорення.