Сегодня мы хотим поделиться с вами переводом их рассказа о сильных и слабых сторонах платформ Node и Crystal, и о том, почему в Duo всё больше серверных проектов переводится на Crystal.
Node
?Ожидания
Мы перешли на Node несколько лет назад. Мы тогда были маленькой компанией с горсткой разработчиков, и нам было нужно, чтобы наши сотрудники были как можно более универсальными. Позволять разработчикам специализироваться на фронтенде или бэкенде было для нас недостижимой роскошью. Если на нас сваливалась куча работы над серверными или клиентскими частями приложений, нам нужны были люди, способные, независимо от их специализации, помочь разобраться с делами.
Node тогда показался нам совершенно очевидным выбором. Если мы брали в штат разработчика, который знал JavaScript, это означало для нас, что он смог бы работать и на клиентских, и на серверных проектах. Инструменты, синтаксис и зависимости перекрывались бы, и все совершенствовали бы свои навыки, так как любая задача подразумевала бы использование JavaScript.
?Реальность
У серверного и клиентского кода совершенно разные цели, эти виды кода требуют знания очень разных приёмов работы. Обычно клиентский код — это взаимодействие с пользователем, обновление интерфейса, выполнение запросов данных с сервера. Наши разработчики обычно работали с webpack или browserify для упаковки кода, разрабатывали интерфейсы на React и использовали CSS-фреймворки для упрощения разметки страниц.
На сервере программист имеет дело с SQL-запросами к базам данных, с ORM, с чтением и записью файлов, организует взаимодействие со сторонними API. Потоки данных на сервере подчиняются модели «запрос — ответ». Между запросами все задачи должны обслуживать ответы, и всё это нужно делать специфическим образом. Если некий шаг полагается на результаты, полученные на предыдущем шаге, соответствующие процессы должны выполняться по порядку, если нет — их можно выполнять параллельно.
?Стандартная асинхронность
Node спроектирован так, что каждую задачу он выполняет асинхронно. Это означает, что если вы предложите Node выполнить 5 задач, он попытается сделать всё это одновременно. Несколько последних лет основным средством для поддержки такой модели работы были промисы. Если в двух словах, то промисы позволяют программисту объединять в цепочки наборы асинхронных задач, которые представляют собой последовательности шагов, выполняемых друг за другом.
На сервере стандартное использование параллельных задач может показаться весьма эффективной идеей. В реальности же для большинства задач, с которыми мы сталкиваемся, требуются данные, полученные от предыдущих задач. Даже если мы можем выполнять задачи параллельно, ресурсы системы могут быть довольно быстро истощены, то есть, выполнение множества параллельных запросов к базе данных может исчерпать пул соединений и снизить число пользователей, которых можно обслуживать одновременно.
За годы использования Node создание цепочек из промисов стало для нас нормой. Половина написанного кода была направлена на то, чтобы превратить асинхронные задачи в задачи, решаемые последовательно. Эти цепочки промисов тяжело тестировать, отлаживать, код получается не особенно вразумительным. Часто бывает так, что просто глядя на код тяжело понять даже то, в каком порядке выполняются задачи и подзадачи.
Роаль Даль, создатель Node, весьма удачно описал эту ситуацию, сравнивая Node и Go в интервью:
Но интерфейс, который эта система предоставляет программисту, блокирующий, и я думаю, на самом деле, что это — более удачная модель программирования. Использование блокирующего подхода позволяет, во многих ситуациях, гораздо лучше видеть суть выполняемых действий. Скажем, если есть куча последовательных действий, весьма полезна возможность сообщить системе примерно следующее: «Реши задачу А, подожди ответа, возможно — выдай ошибку. Реши задачу B, подожди ответа или выдай ошибку». И в Node, из-за необходимости постоянно «прыгать» между вызовами функций, достичь такого гораздо сложнее.
?Динамические типы
Любой, кто регулярно программирует на JavaScript, рано или поздно познакомится с ошибкой «undefined is not an object». Эта ошибка возникает, когда вы пытаетесь обратиться к методу или свойству переменной, которую вы считаете объектом, но в которую записано значение null. Недостаточно контролировать то, какие данные передаются между асинхронными участками кода, необходимо ещё и быть в курсе того, что творится с типами в любом месте кода приложения. Каждый раз, когда приложение получает данные от одного процесса и передаёт их другому, может произойти сбой. Если вы не предусматриваете возможность обработки всех возможных значений, сервер выдаст ошибку, или, что гораздо хуже, сделает что-нибудь неожиданное.
Crystal
Во время работы с Node я исследовал множество других языков и платформ, в том числе — Python, PHP, Ruby и Go. Они, как правило, либо были медленнее, чем Node, либо не так удобны для целей разработки. Скорость и синтаксис — это две вещи в языке, которые можно оптимизировать лишь до определённого предела.
Затем, в прошлом году, я прочитал статью о языке Crystal. Он — из нового поколения языков, которые компилируются в машинный код через LLVM. Его синтаксис похож на Ruby (мне это нравится), но работает он так же быстро, как Go (а этому зверю скорости не занимать!).
Crystal всё ещё очень молод, но я решил переделать на нём некоторые серверные части нашей системы управления контентом. Получилось просто замечательно. Вот, что я выяснил в ходе работы:
- Crystal отличается высокой производительностью. Для моих задач он оказался в 2 раза быстрее Node.
- Он использует очень мало памяти. Crystal обычно надо менее чем 5 Мб на процесс, а Node — более 200 Мб.
- У него имеется отличная стандартная библиотека, в результате для решения типичной задачи нам понадобилось лишь 12 зависимостей, в сравнении с сотней зависимостей Node.
- Код, по умолчанию, выглядит синхронным, он использует, как и Node, цикл событий, но для организации параллелизма применяются легковесные потоки (fibers), взаимодействие организовано через каналы, как у Go. Это упрощает понимание кода.
- Crystal статически типизирован, поэтому об ошибках можно узнать при компиляции.
- Crystal выводит типы, в результате, его системой типов легко пользоваться, так как не приходится слишком часто использовать аннотации типов.
Мне так понравилось программировать на Crystal, что мы переписали весь бэкенд нашей CMS на этом языке. Его API совместимо с нашей CMS, основанной на Node, в результате вебсайты можно перевести на новую систему, или вернуть обратно на старую, затратив сравнительно мало усилий. Это важно, так как Crystal — всё ещё молодой проект и нам нужна страховка.
После того, как наша DuoCMS была полностью переписана на Crystal, мне понадобилось протестировать её в продакшне. Собственно говоря, оригинал этого материала размещён на сайте, который работает на Crystal.
?Сравнение кода на Node и Crystal
Ниже, для сравнения, приведена слегка упрощённая версия кода контроллера, написанного на Crystal и Node.
Вот контроллер на Node (используется фреймворк Express).
const express = require('express')
const app = express()
const bodyParser = require('body-parser')
const UserService = require('user-service')
app.use(bodyParser.json())
app.get('/', function (req, res) {
res.send('Hello World!')
})
app.post('/api/users', function (req, res) {
if(request.body){
UserService.save(request.body)
.then(function(){
res.send('user saved')
})
.catch(function(err){
res.send(err)
})
}else{
res.send("no user provided")
}
})
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})
Вот — контроллер на Crystal (используется фреймворк Kemal).
require "kemal"
require "user"
require "user-service"
get "/" do
"Hello World!"
end
post "/api/users" do |ctx|
if (json = ctx.request.body)
user = User.from_json(json)
UserService.new.save(user)
"user saved"
else
"no user provided"
end
end
Kemal.run
Несложно заметить, что структура этих двух примеров очень похожа. Однако, когда отпала необходимость в промисах, общий объем кода уменьшился. При написании более крупных приложений это заметно ещё сильнее. Серверный код DuoCMS 5 состоит из примерно 15609 строк на JavaScript. Объём кода DuoCMS 6 близок к 10186 строкам. На данный момент DuoCMS 6 имеет больше возможностей, для реализации которых потребовалось на 30% меньше кода. При этом, благодаря отсутствию промисов, этот код гораздо легче читать и поддерживать.
Чего не хватает в Crystal?
Разработчики называют текущий релиз Crystal альфа-версией. Тут надо сказать, что мне приходилось использовать гораздо менее проработанные фреймворки, предназначенные для продакшна. В худшем случае я сказал бы, что Crystal сейчас в состоянии бета-версии. Однако, я могу понять осторожность разработчиков. Они говорят об альфа-версии, так как это даёт им пространство для манёвра, для внесения изменений, даже для того, чтобы поломать какое-нибудь API, и так далее.
Я использую Crystal уже примерно год и столкнулся лишь с немногими изменениями, которые объясняются развитием проекта и тем, что это — всё ещё альф-версия. У меня было больше проблем с обновлением React на фронтенде. Кроме того, стоит сказать, что Crystal написан на Crystal, то есть, если что-то окажется нерабочим, вы можете вносить исправления в язык и в стандартную библиотеку (я так и поступал).
На данный момент основными недостающими возможностями Crystal можно назвать следующие:
- Всё ещё нет поддержки Windows (меня это не беспокоит, работаю я на Mac, код разворачиваю на Linux).
- До сих пор нет настоящего параллелизма (в Node его тоже нет).
- Нет инкрементной компиляции (это было бы очень удобно, так как сейчас, для компиляции нашей системы после внесения изменений в код, требуется около 8 секунд).
- Существует не так много хорошо поддерживаемых опенсорсных библиотек для Crystal, но здесь всё придёт в порядок, когда начнётся использование Crystal в серьёзных проектах.
Для нас ни один из перечисленных недостатков не стал причиной отказаться от Crystal. Мне понадобилось внести несколько дополнений, реализующих отсутствующие возможности, в используемые нами библиотеки, но при работе с Node такое тоже случалось. В итоге могу сказать, что я весьма доволен Crystal.
Итоги
Стоит ли вам попробовать Crystal? Да, стоит! Штука это действительно замечательная. На Crystal приятно программировать, код просто читать и править. И, кстати, чем больше людей будет пользоваться Crystal и вносить вклад в разработку этого языка — тем лучше он будет становиться. Хотите увидеть всё своими глазами? Вот инструкции по установке. Вот — сайт проекта. А это — чат Crystal, если что — пишите мне на @crisward.
Если вы спросите — следует ли вам использовать Crystal в продакшне, ну — это как хотите. Лично я думаю, что единственный способ сделать что-либо пригодным к практической эксплуатации — попробовать это на тех задачах, на которых допустимы сбои, а потом постепенно переходить на это в более масштабных проектах. Мы не используем Crystal везде, например — на проектах с очень высоким трафиком, или на критически важных участках. Мы мониторим все наши сайты и регулярно их бэкапим, кроме того, Node всегда на подхвате — на тот случай, если с Crystal вдруг что-нибудь приключится.
Уважаемые читатели! Планируете ли вы попробовать Crystal в своих проектах?
Комментарии (47)
beduin01
15.09.2017 16:51Я бы советовал на D обратить внимание в сочетании с vibed.org
vintage
16.09.2017 17:02+1Присоединяюсь.
Crystal отличается высокой производительностью. Для моих задач он оказался в 2 раза быстрее Node.
Да что угодно компилируемое быстрее ноды :-)
Он использует очень мало памяти. Crystal обычно надо менее чем 5 Мб на процесс, а Node — более 200 Мб.
Ну это как приложение напишешь.
У него имеется отличная стандартная библиотека, в результате для решения типичной задачи нам понадобилось лишь 12 зависимостей, в сравнении с сотней зависимостей Node.
На D возможно хватило бы и одной — vibe.d
Код, по умолчанию, выглядит синхронным, он использует, как и Node, цикл событий, но для организации параллелизма применяются легковесные потоки (fibers), взаимодействие организовано через каналы, как у Go. Это упрощает понимание кода.
В D аналогично, более того..
До сих пор нет настоящего параллелизма (в Node его тоже нет).
В D он мало того, что есть, так ещё некоторым контролем времени компиляции. Файберы могут баланситься на воркеры. Есть и реализация каналов как в го.
Crystal статически типизирован, поэтому об ошибках можно узнать при компиляции.
D тоже, кроме того имеет мощные возможности метапрограммирования. Например, шаблоны могут транслироваться в машинные коды при компиляции.
Crystal выводит типы, в результате, его системой типов легко пользоваться, так как не приходится слишком часто использовать аннотации типов.
D тоже их выводит, но более строго. Например, не пихает алгебраические типы где ни попадя, как ниже в комментах.
Всё ещё нет поддержки Windows (меня это не беспокоит, работаю я на Mac, код разворачиваю на Linux).
А меня беспокоит, ибо работаю под виндой. И D работает и под линуксом и под виндой. И тоже умеет компилироваться через LLVM.
Нет инкрементной компиляции (это было бы очень удобно, так как сейчас, для компиляции нашей системы после внесения изменений в код, требуется около 8 секунд).
А в D она есть, из-за чего компиляция идёт очень быстро. Настолько быстро, что на нём даже скрипты писать можно с компиляцией налету.
Существует не так много хорошо поддерживаемых опенсорсных библиотек для Crystal, но здесь всё придёт в порядок, когда начнётся использование Crystal в серьёзных проектах.
D уже довольно зрелый, с богатым репозиторием библиотек и своим пакетным менеджером.
Riim
17.09.2017 17:45Для D есть аналог Passport.js? Пока удалось найти только https://github.com/thaven/oauth, но хотелось бы стабильную версию, больше готовых провайдеров и хоть какую-то активность в репозитории.
lega
17.09.2017 18:27Passport.js можно использовать как микросервис, без разницы на чем остальной бекенд.
Riim
17.09.2017 18:38Если я правильно понимаю, так сессию прийдётся хранить в редисе, нода будет создавать сессию, а в D можно будет подцеплять её, для моих задач пока хватает хранения в памяти или в куках, не хотелось бы усложнять.
vintage
17.09.2017 21:02Там последний коммит пару месяцев назад. Дополнительных провайдеров добавить вроде не сложно.
Leg3nd
15.09.2017 16:57+3Эти цепочки промисов тяжело тестировать, отлаживать, код получается не особенно вразумительным. Часто бывает так, что просто глядя на код тяжело понять даже то, в каком порядке выполняются задачи и подзадачи.
async/await через babel еще 2 года назад уже отлично работали
Dreyk
15.09.2017 17:14мне Crystal нравится (потому что я рубист), но вся проблема в том, что язык еще не релизнулся: могут быть баги, обратно-несовместимые изменения и прочие прелести
valentinmk
15.09.2017 20:20А если взять какую-то ruby библиотеку и тупо компильнуть кристалом — взлетит? Или там такой игривый руби-лайк синтаксис, сейчас я руби, а сейчас нет? (я не рубист, если что)
akzhan
15.09.2017 21:01+2в 98% случаях не взлетит, потребует хотя бы рихтовки напильником.
Crystal просто основан на некоторых идеях из Ruby.
Но вообще это статически-типизируемый язык с type unions, редкая штука.
То есть тип Int32 — не может быть nilable, а Int32 | Nil может (другая запись — Int32?).
И это очень сильно и удобно (и да, можно Int64 | String, например, но вообще типы он сам выведет при компиляции, если явно не писать).
Tiberiumk
15.09.2017 23:23Int64 | String — это же обычные дженерики, нет?
akzhan
16.09.2017 05:26Нет, метод может вернуть type union Int64 | String.
При этом по факту у вас есть две автоматически созданные ветви исполнения по типу возвращаемого результата.
def iors : Int64 | String Math.rand > 0.5 ? 5 : "s" end r = iors if r.is_a?(String) puts "wow #{r}" end
здесь для разных типов будут созданы/скомпилированы разные ветви исполнения, так что потеря времени компиляции только на определение типа возвращаемого значения (одна проверка только один раз после получения результата, если это необходимо)
akzhan
16.09.2017 05:31более корректно код писать как:
def iors Random.rand > 0.5 ? 5 : "s" end r = iors if r.is_a?(String) puts "wow #{r}" end
buldo
18.09.2017 13:01Наверное тут имеются в виду алгебраические типы данных. То есть, например, метод может вернуть или int32 или string, а потом в зависимости от того, что по факту вернулось (с помощью if или какого pattern matching) будет своя ветка исполнения.
Ещё один пример — функция возщает или ответ или ошибку
valentinmk
16.09.2017 10:57Слушай (это гипотетическая идея только из теоритического интереса), а ведь можно изобрести новый колбэк хелл.
Из примера ниже
def iors : Int64 | (puts "Welcome to hell" > Nil ? String : Nil) Math.rand > 0.5 ? 5 : "s" end
akeinhell
15.09.2017 17:19+4Несложно заметить, что структура этих двух примеров очень похожа. Однако, когда отпала необходимость в промисах, общий объем кода уменьшился. При написании более крупных приложений это заметно ещё сильнее. Серверный код DuoCMS 5 состоит из примерно 15609 строк на JavaScript. Объём кода DuoCMS 6 близок к 10186 строкам. На данный момент DuoCMS 6 имеет больше возможностей, для реализации которых потребовалось на 30% меньше кода. При этом, благодаря отсутствию промисов, этот код гораздо легче читать и поддерживать.
Можно и порефакторить этот js и получим тоже — читаемый код и уменьшение кодовой базы
const router = require('express').Router(); app.get('/', (req, res) => res.send('Hello World!')); app.post('/api/users', async (req, res) => { const {body} = request; if (!body) { return res.send("no user provided") } await UserService.save(request.body); res.send('user saved'); })
Leopotam
15.09.2017 20:12+2К тому же на crystal нет обработки ошибки сохранения пользователя — замечательное сравнение.
akaluth
16.09.2017 10:02Скорее всего, обработка исключений происходит внутри фреймворка (как в sinatra), так что всё ок
Leopotam
16.09.2017 10:45Так какая разница, где оно обрабатывается? Как я узнаю, что произошла нештатная ситуация? Наружу ничего не передается и, судя по всему, даже не кидается исключение.
akaluth
16.09.2017 20:45Исключение может кидаться внутри сервиса, создающего юзеров или в модели. Так как js-версия ничего не делает, кроме как выводит ошибку в браузер, код примерно равнозначен: тут это исключение обработает фреймворк, отдав корректный 500-ый статус (чего, кстати, не сделает js-вариант)
akzhan
15.09.2017 21:17+1Ну на деле Kemal, — это просто первая ласточка, сейчас есть и иные каркасы, типа https://ambercr.io
Во-вторых, да, async/await — прекрасная концепция.
А в остальном, — Crystal надежнее и удобнее в силу своей статической типизации, тут его ближайший конкурент все-таки —
tsnodeGolang.
vladfaust
15.09.2017 17:57Я — активный фанат кристала и мне больно смотреть, как в него приходят js'еры и рубисты и начинают буквально ср*ть в опенсорс, ни разу не используя преимущества языка. Они думают, что они всё ещё в динамическом ЯП.
Я против кемала и amber, потому что кристал самодостаточен, и можно быстро написать быстрое приложение, используя только стандартную библиотеку. Я в принципе против фреймворков.
А ещё автор забыл упомянуть макросы. Метапрограммирование есть и в кристале, и это одна из самых главных его особенностей.
Leopotam
16.09.2017 10:46+2А почему тогда не nim? Явно быстрее crystal, есть то же самое метапрограммирование через макросы и прочее.
Druu
16.09.2017 11:17> А ещё автор забыл упомянуть макросы. Метапрограммирование есть и в кристале, и это одна из самых главных его особенностей.
Макросы на строках, без гигиены, в негомоиконном языке — это не метапрограммирование, это курам на смех.
raveclassic
15.09.2017 18:10Если вам по душе ruby-like синтаксис, то почему тогда не Elixir?
Блин, перевод же
Fen1kz
15.09.2017 19:12+1Я думаю проблема в том что автор пытался на ноде кодить синхронно.
достаточно написать простейшую обертку для методов контроллера которые принимает промисы и ловит эксепшены, например в своего проекте на работе я бы написал так:
class UserController { requests(router) { // router уже держит /api router.post('/users', this.handleRequest(this.addUser)) } addUser(req) { // вообще у нас прикручен валидатор на запросы, но ладно уж if (!req.body) throw new ServerError("no user provided"); // addUser исполняется в промисе, так что ошибка вывалится в catch return UserService.save(req.body) .then(() => 'user saved'); } }
SirEdvin
15.09.2017 21:33Они, как правило, либо были медленнее, чем Node, либо не так удобны для целей разработки.
Интересно, python медленее nodejs (если все-таки использовать асинхронность) или менее удобен?)
А так crystal наконец-то похож на язык, который как python по гибкости, только компилируется. Надо будет глянуть.
akzhan
15.09.2017 22:16пока желательно только для инфраструктуры внутренней.
наружу раньше февраля не стоит.
Extrapolator
16.09.2017 12:18а что планируется в феврале?
akzhan
16.09.2017 12:29+1Жесткого плана нет (есть карта).
Ранее была амбициозная цель к 2018 году создать Crystal 1.0, готовый к промышленной эксплуатации.
Сейчас очевидно, что для этого пока не хватает ресурсов, но к февралю мы все же получим гораздо более стабильный/удобный язык.
Мне вот не хватает только иногопоточности для некоторых применений.
ReklatsMasters
16.09.2017 00:48+2Мне так понравилось программировать на Crystal, что мы переписали весь бэкенд нашей CMS на этом языке.
Я правильно понял, что автор только-только изучил новый язык и сразу принялся на нём писать в прод? Крайне сомнительная и не профессиональная затея. Возможно, стоило отрефакторить старый код, убрать лишние зависимости. Уверен, это дало бы прирост CPU / памяти.
unabl4
16.09.2017 01:50+1Я как рубист на кристал давно засматриваюсь. Попробовать очень хочу, но пока никак.
Senyaak
18.09.2017 13:02+1называйте меня как хотите, но сравнивать языки со скобками и языки с «end» это…
alek_sys
Эта статья будет не полной без самого популярного комментария из обсуждения на Reddit:
justboris
Кому-то же нужно пробовать новые технологии. Очень хорошо, что кто-то потратил свое время и деньги, а не наше.
akzhan
так всегда начинается :-) у меня один продакшн-сервис для работы с кредитными картами написан на Crystal, но он однозначно недоступен снаружи :)