Создатель Node.js Райан Даль в одном из своих интервью заявил, что для написания серверов предпочёл бы Go. Стали даже появляться заявления о скорой кончине Node.js. Упомянутое интервью Даля состоялось в 2017 году, Go с тех пор укрепил свои позиции, а сам Даль потом переходил на Rust и даже написал еще одну среду для выполнения серверного JS - Denо. Однако и сейчас можно увидеть статьи о переходе с Ноды на Голанг. Мне же представляется, что вопрос о переходе между этими технологиями не стоит вообще – эти решения для разных ниш. Прикладные API-сервера, для которых чаще всего используют Ноду устроены таким образом, что получить выигрыш производительности переходом на Go почти невозможно, а вот сильно замедлить разработку очень легко.
REST, API, RPC, BFF ... чем на самом деле заняты наши сервера
Когда я говорю REST я на самом деле подразумеваю прикладные API сервера. Корпоративные, отраслевые или нишевые, в большинстве случаев они заняты тем, что хостят какое-то веб приложение в виде сайта или одностраничного приложения и для этого приложения раздают API. Это называют BFF – Back For Front. Этот API может быть устроен как REST – это когда имена методов совпадают с http-методом, а модель данных – это обычно путь, типа DELETE /api/users/123
. Можно организовать API в стиле RPC – это когда всё происходит по одному адресу, а названия методов и аргументы передаются в теле запроса, например: POST /rpc
body: {method: deleteUser, userId:123}
. Данные обычно сериализуются в JSON, а хранятся в БД.
По сути, сервер занимается извлечением или записью данных в БД и сериализацией данных в JSON. Очень редко он что-то сложное вычисляет. Но даже когда приходится обрабатывать фото/видео/аудио то чаще используется ПО написанное на C/C++, например ffmpeg и это ПО вызывается из JavaScript/Java/Go кода
В этой схеме основную нагрузку несёт система управления базой данных (СУБД). Скорость приёма запросов незначительно влияет на результат и зависит от архитектуры веб сервера. Она может быть низкая как у «голого» apache+php, это когда сервер при каждом запросе читает с диска php-скрипт и выполняет его (чтобы решить эту проблему придуманы различные надстройки, типа Fast CGI). Но у Ноды и Go с этим и так всё в порядке. Они висят в оперативке, Go параллелит запросы, а Нода благодаря асинхронному подходу не ожидает ответа от БД, используя библиотеку libuv выполняет асинхронные операции ввода/вывода и не блокирует основной поток выполнения.
Процесс на Ноде занимает существенно больше оперативки, но это всё равно не много для современных серверов, и её потребление остаётся стабильным месяцами. А скорость выполнения запросов практически идентичная с Go. Наше исследование, конечно, не назовёшь полноценным, мы просто переписали один из Node серверов на Go, запустили его на той же машине и сравнивали параметры в течение недели.
Для того, чтобы ощутить разницу нужно иметь гигантский трафик, добиться которого гораздо сложнее, чем переписать сервер с Ноды на Го.
Объём кода и удобство разработки
На Go написать http-сервер, конечно, проще, чем на «плюсах», но всё-таки это то место, где Node имеет однозначное преимущество. В нашем примере JS-код в 4,5 раза короче чем Go. Когда Node только появился, код бывал запутанным из-за многочисленных колбеков, но сейчас с async/await код гораздо проще и чище.
Сериализация в JSON для Ноды естественна и не вызывает никаких вопросов, как будто её и нет вовсе. Всё, что угодно можно заJSONить. Ещё бы, ведь JSON — это Java Script Object Notation:
let user = {name: "Василий", age: 23} // создали любой объект
let json = JSON.stringify(user) // сериализовали в строку
На Go выйдет немного побольше:
import (
"encoding/json" // нужно явно импортировать пакет
"fmt" // не обязательно, это для примера
)
type User struct { // нужно создать структуру, зеркалящую JSON
Name string json:"name" // поля должны начинаться с большой буквы, иначе не будут сериализованы
Age int json:"age" // А чтобы в JSONе были с маленькой, для каждого нужно прописать тэг json
}
user := User{Name: "Василий", Age: 23}
jsonData, err := json.Marshal(user) // «маршаллинг» – это такой старый термин
// err??? Что-то надо делать с err, но об этом позже
fmt.Println(string(jsonData)) // это так, не обязательно.
// Просто jsonData – это не строка, а массив байт
// если нужна именно строка, то надо явно приводить
Обработка ошибок в Ноде организована через Exceptions – вы можете их ловить там, где это удобно. В REST серверах никак особо их обрабатывать не нужно – если случилось исключение – запрос должен просто упасть с сообщением об ошибке. В результате вы как бы пишете только «правильный» код. Можете целиком завернуть его в try-catch и в конце вывести сообщение об ошибке, в случае с API вам всё равно в какой строке произошла ошибка, что-то выполнять дальше не имеет смысла — можно вываливаться в catch. В случае с библиотекой Fastify даже этого делать не надо – она сама поймает исключение, завернёт в JSON и отправит 500й ответ, ну или какой вы укажете.
В Go решили сделать совершенно по-другому. Функция может возвращать несколько результатов, один из которых очень часто – объект ошибки. Вообще, Go не объектный язык, поэтому правильно говорить не «объект», а «структура», но «структура ошибки» — это что-то совсем не то, поэтому я пишу «объект ошибки». Каждый раз её надо отдельно обрабатывать. Т.е. в примере выше, после json.Marshal(user) нужно добавить
if err != nil {
http.Error(ResponseWriter, err.Error(), http.StatusInternalServerError)
return
}
И делать так надо каждый раз, когда функция может вернуть ошибку.
По этой причине организовать централизованное логирование ошибок крайне проблематично. В результате код сервера вырос в несколько раз.
Примеры одного и того же REST-контроллера написанные по одинаковой схеме:
// broadcast.controller.ts
/** трансляции на LED-табло, в зависимости от идентификатора табло */
app.get('/api/broadcast', async (req, reply) => {
let boardExtId = Number((req.query as any).id); // вытаскиваем идентификатор из запроса
if(!boardExtId) throw new Error(Не указан идентификатор табло);
let str = await BroadcastService.screenShedule5minus(boardExtId); // получаем какой-то текст для табло
await BoardsRepo.saveLastPing(boardExtId); // логируем в БД
// прошивка панели требует устаревшую кодировку
reply.header("Content-Type", "text/html; charset=windows-1251")
return iconv.encode(str, 'win1251'); // кодируем как нравится LED-экрану
})
// broadcast.controller.go
mux.HandleFunc("GET /api/broadcast", func(w http.ResponseWriter, r *http.Request) {
dto, err := dto.NewScreenRequestDto(r.URL.Query())
if err != nil {
http.Error(w, "Неверные параметры запроса: "+err.Error(), http.StatusBadRequest)
fmt.Printf("\t%d\t%s\n", http.StatusBadRequest, err) // log status and error
return
}
str, err := service.ScreenShedule5minus(dto.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
fmt.Printf("\t%d\t%s\n", http.StatusInternalServerError, err)
return
}
encoder := charmap.Windows1251.NewEncoder()
encoded, err := encoder.Bytes([]byte(str))
if err != nil {
http.Error(w, "Ошибка кодирования", http.StatusInternalServerError)
fmt.Printf("\t%d\t%s\n", http.StatusInternalServerError, err) // log status and error
return
}
err = repo.SaveLastPing(dto.ID)
if err != nil {
fmt.Println("ERROR: не удалось записать last ping %w", err)
return
}
w.Header().Set("Content-Type", "text/html; charset=windows-1251")
w.Write(encoded)
})
И из этого кода ещё выкинуто логирование, оно было на уровне функции т.к. централизованное логирование в Go организовать сложнее, но в данном случае оно не важно.
Инфраструктура разработки для Ноды тоже выглядит удобнее – dev режимы запуска, в которых не требуется транспиляция, автоматическая перезагрузка страниц, автоматический запуск тестов при изменении файлов – всё это привычное для node-разработчика окружение едва ли доступно для компилируемого языка. В каких-то резких случаях код можно поправить прямо на сервере.
Иными словами, чтобы начать писать REST сервер на Go нужно иметь очень веские основания в виде имеющегося трафика, про который уже известно, что Нода на вашем железе его не потянет.
О чём на самом деле сказал Райан Даль
Буквально цитата: «Node не лучшая система для массивных веб-серверов, я бы использовал Go для этого … если вы пишете распределённый DNS сервер, я бы не выбирал Node». Собственно, и разработчики Go позиционируют язык как предназначенный для распределённых систем. И это скорее положительное суждение — Node-сервера здесь сравнивают с высоконагруженными инфраструктурными элементами, так что ваш API сервер для Node скорее всего будет лёгкой задачей.
Выводы, для чего Go, а для чего Node
Где удобнее применять Go? Если исключить параллелизм, для которого, собственно, и предназначен язык, то вероятно его удобнее использовать для сети микросервисов – скомпилированные процессы мало весят, не требуют установленных в системе сред и зависимостей. Особенно если надо работать с бинарными данными, например ProtoBuf. Если вы и так пишете на Go, то сделать http-сервер не сложно. Но если вам приходится «клепать» REST сервера не реже раза в месяц, то, пожалуй, на Ноде будет быстрее даже с учётом её изучения.
Node идеальное решение для JSON-API серверов. Поскольку это специализированное и достаточно производительное решение, которое давно развивается в этом направлении. Простая JSON сериализация и обработка исключений. Возможность пользоваться типизацией через TypeScript или игнорировать её для быстрого прототипирования. Внятный асинхронный код благодаря async/await. JavaScript — язык неустранимый из веб-разработки и хотя фронт-ендеры не «заточены» под бэк, они смогут внести кое-какие изменения при необходимости. Эта среда продолжает активно развиваться (в сторону ускорения и строгой типизации).