Використовуємо Golang для розробки Node.js-застосунків (Node.js: In Go We Trust)
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.
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. Далі послідовність Фібоначчі (отримуємо
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). Прошу вибачення за допущену помилку в написанні коду. Результати замірів перевірено — відхилення не мають значного впливу на дослідження вцілому і становлять ±
Дякую всім за плідне обговорення.
14 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів