Примечание. Первоначально эта публикация была опубликована в блоге Freshworks.

Ruby on Rails обладает потрясающим набором функций, которые помогают Freshworks вносить различные усовершенствования продукта в кратчайшие сроки. Сегодня самая большая проблема, с которой мы сталкиваемся, — это масштабирование сервисов на основе Ruby и не отставание от экспоненциально растущей клиентской базы. Становится важным найти альтернативный язык с хорошей производительностью, который будет продолжать помогать нам быстро разрабатывать функции. На данный момент, когда мы пытаемся сломать наш монолитный код и извлечь из него микросервисы, кажется уместным начать рассматривать различные альтернативные языки.

Требования

Ниже приведены соображения, которыми мы руководствовались при выборе языка.

Сравнение вариантов использования

  • RESTful API. (Желательно с возможностью генерации кода сервера из спецификации Swagger)
  • Использование сообщений из одной из очередей/потоков, таких как Kafka, SQS

Работа со следующими ресурсами

  • MySQL
  • Потребление и создание сообщений от/к Kafka в больших объемах
  • Потребление и создание сообщений из/в SQS
  • Редис
  • ДинамоДБ
  • Вызов других внутренних и внешних API RESTful. Предпочтительно генерировать клиентский код из спецификации Swagger.
  • SDK для других сервисов AWS

Производительность

  • Задержка для сервисов RESTful API
  • Пропускная способность для потребителя потока
  • Объем памяти

DevOps

  • Мониторинг
  • Докеризация

Производительность разработчиков

  • Особенности языка
  • Доступный кадровый резерв и обучаемость
  • IDE
  • Форматирование
  • Статические анализаторы кода
  • Читаемость и ремонтопригодность существующего кода
  • Цикл проверки кода

После первоначального обсуждения Golang и Java стали двумя последними претендентами.

Сравнение Go и Java

Сравнение вариантов использования

Сервер RESTful API

И Java, и Go имеют хорошо поддерживаемую платформу для разработки RESTful API. Проект swagger-codegen и несколько других инструментов могут генерировать серверные заглушки как для Java, так и для Go.

Библиотеки REST API в Java в основном используют аннотации для настройки различных аспектов API, таких как конечная точка, которую обслуживает метод, формат запроса/ответа и тому подобное. С другой стороны, библиотеки Go используют явный код для определения маршрутизации. Следовательно, Go потребует больше кода для настройки конечных точек. Тем не менее, можно утверждать, что HTTP-маршрутизация в одном месте гораздо проще для понимания новым разработчиком, чем определение каждым классом своего ответственного пути. Точно так же аннотации можно использовать для проверки запросов в Java, в то время как Go требует, чтобы они были написаны вручную.

Учитывая, что мы, скорее всего, будем генерировать серверные заглушки из спецификации Swagger, весь этот код будет сгенерирован автоматически в обоих сценариях. Следовательно, принимая во внимание этот вариант использования, вы окажете минимальное влияние. Тем не менее, развитые библиотеки Java дают ему здесь небольшое преимущество.

Использование сообщений из одной из очередей/потоков, таких как Kafka, SQS

И в Java, и в Go есть множество библиотек для использования в каждой из популярных реализаций очередей/потоков.

Учитывая, что Java существует уже давно, у него есть множество возможностей для параллельного программирования. При наличии потоков в ядре доступно множество утилит для управления потоками, распределения работы между ними, очень гибких вариантов синхронизации и так далее. С их помощью можно создавать чрезвычайно сложные решения. Однако, учитывая сложность, которую привносят эти библиотеки/утилиты, многие приложения очень часто сильно страдают от состязаний и/или условий гонки. Кроме того, потребуется много времени, чтобы понять и эффективно применять различные доступные варианты. Обычно синхронизация и параллельное программирование являются одними из самых проблемных областей для кандидатов на Java во время собеседований. Учитывая сложности и принимая во внимание, что потоки довольно дороги, большинство программистов предпочитают писать большие фрагменты логики, работающие в одном потоке, если в этом нет крайней необходимости.

С другой стороны, в Golang есть очень простая модель параллелизма, основанная на горутинах (облегченных потоках) и каналах (блокирующей очереди). В то время как стандартная библиотека Go поддерживает взаимное исключение, использование горутин и каналов является идиоматическим способом программирования в Go в большинстве случаев. Эта модель настолько проста и эффективна, что я ожидаю, что программисты будут чаще использовать ее и создавать приложения, состоящие из множества небольших фрагментов работы, выполняемых горутинами. Это также гарантирует, что результаты будут более предсказуемыми и менее глючными.

Хотя похожая модель может быть построена на Java, она редко используется. Следовательно, Go превосходит Java в этой области с большим отрывом.

Работа с хранилищами данных/другими REST API

Оба языка имеют огромную коллекцию библиотек для взаимодействия со всеми популярными хранилищами данных.

Как правило, библиотеки Java пытаются скрыть детали базовой реализации с помощью пользовательского интерфейса. Например, JPA определила свой собственный язык запросов, который предоставляет ассоциации через поля объекта, тогда как библиотеки Go обычно представляют собой простые оболочки поверх базовой системы. Это означает, что разработчикам необходимо изучить детали базовой системы (например, SQL).

Это весьма спорно. С одной стороны, библиотеки Java могут скрывать сложности за счет сложного интерфейса (ассоциации в JPA — это простой доступ к полю объекта). Однако, если необходимо изменить поведение библиотеки (например, заставить JPA поддерживать сегментирование MySQL), борьба с библиотекой может оказаться мучительным испытанием. Точно так же, если что-то работает не так, как мы ожидали, отладка может быть очень сложной. Кроме того, кривая обучения может быть намного круче.

Учитывая более длительный срок службы Java, библиотеки Java, как правило, лучше документированы. Поскольку библиотеки Go довольно просты, просмотреть код и понять, что происходит, довольно просто. Это может быть предпочтительным в некоторых случаях. Клиент/SDK для многих используемых нами сервисов (Kafka и все AWS SDK) сначала реализованы на Java, а библиотеки Go выпускаются позже.

Это соображение очень субъективно и зависит от личных предпочтений.

Представление

Поскольку JVM существует уже более 20 лет, она претерпела много изменений, что, в свою очередь, обеспечило нам потрясающую производительность. Это также дает нам много вариантов на выбор. Например, мы можем выбрать сборщик мусора, настроенный на работу в режиме реального времени с малой задержкой; другой для фоновой работы с высокой пропускной способностью; еще один для маломощного одноядерного сервера; и так далее. Большинство из них используют схему памяти поколений, которая очень эффективна для большинства случаев использования. Практически все (размер каждого поколения, ожидаемая пауза, максимальная/минимальная память и т. д.) можно настроить с помощью различных ручек в зависимости от используемого сборщика мусора. Хотя базовые настройки обеспечивают приличную производительность, выжимание каждой унции сока из JVM может быть пугающим процессом.

Go, с другой стороны, имеет единственный алгоритм сборщика мусора, оптимизированный для очень низкой задержки с паузами GC порядка микросекунд. Если мы посмотрим на наш собственный сервер уведомлений в реальном времени на основе Go, он ежедневно обрабатывает миллионы сообщений со сквозной задержкой около 5 мс при 95-м процентиле. Это впечатляет, учитывая, что каждое сообщение проходит через 4 службы с постоянством между ними. Конечно, это не серебряная пуля и имеет свою цену. По мере того, как Go становится все более зрелым, мы можем ожидать, что будут реализованы лучшие сборщики для других вариантов использования. Команда Go уже работает над новым коллектором под названием Коллектор, ориентированный на запросы, который оптимизирован для рабочих нагрузок, подобных веб-серверу.

Как правило, объем памяти Go гораздо меньше, чем у Java. На нашем сервере уведомлений в реальном времени на основе Go некоторые службы работают с примерно 70 МБ памяти на рабочий процесс. С Java почти ничего не работает при объеме памяти менее 512 МБ. Это особенно полезно с докером, позволяющим запускать несколько служб на одном компьютере.

Что касается пропускной способности, то каждый язык выигрывает различные личные баталии примерно в равное время. Похоже, что Go со временем завоевывает все больше позиций и может склонить чашу весов в свою пользу по мере своего развития.

DevOps

Java очень зрелая и имеет множество библиотек и инструментов для мониторинга. У нас достаточно внутренней экспозиции. NewRelic предлагает отличную поддержку для мониторинга различных аспектов приложения, таких как HTTP-запросы и запросы к БД.

NewRelic также поддерживает Go, который охватывает HTTP-запросы и запросы к БД. Однако, поскольку Go все еще довольно молод, неясно, насколько всесторонней является поддержка и какие фреймворки/библиотеки охватываются. В Go есть библиотеки, которые могут отправлять стандартные метрики времени выполнения Go во многие доступные системы мониторинга, такие как StatsD, InfluxDB и т. д.

Для клиентов хранилищ данных, у которых нет поддержки NewRelic, некоторые из них уже имеют интеграцию с этими библиотеками метрик и могут быть легко использованы. Если они недоступны, нам, возможно, придется реализовать собственный сборщик метрик, который будет передавать необходимую информацию в библиотеку метрик.

Докеризация обоих приложений довольно проста. Однако здесь Java имеет явное преимущество.

Продуктивность разработчиков

Языковые функции

Авторы Go весьма преднамеренно ограничивают возможности языка и сохраняют очень простой синтаксис языка. Это означает, что синтаксис Go и его основные концепции можно изучить довольно быстро. В последнее время Java добавила огромное количество функций, синтаксического сахара, библиотек параллелизма и многого другого.

Следовательно, сложность изучения языка возросла. Как упоминалось ранее, изучение всех тонкостей реализации правильной параллельной обработки требует больших усилий. Кроме того, другие фреймворки, в основном де-факто, такие как Spring, Hibernate, SpringMVC/Jersey, Jetty/Tomcat, заставляют учиться очень долго. С другой стороны, как только эти фреймворки/библиотеки/функции будут изучены, они значительно повысят производительность, так как решают множество сложных задач.

Возможно, самая большая функция, отсутствующая в Go, — это дженерики (также известные как шаблоны в C++). Из-за этого многие служебные функции (например, array.Contains() ) необходимо реализовывать для каждого типа повторно. Учитывая, что Go поощряет определение новых типов практически для всего (например, AccountID, который, вероятно, является просто целым числом, может быть новым типом), эти служебные функции приходится повторять слишком много раз.

В Go есть инструмент для генерации кода, который может в некоторой степени помочь в этом. Авторы Go говорят, что на данный момент нет никакой срочности во внедрении дженериков, поскольку это не является обязательной функцией (лагерь Java говорил то же самое, пока не были реализованы дженерики). Я надеюсь, что нам не придется ждать слишком долго.

Другой спорный выбор языка Go связан с обработкой ошибок. Ошибки возвращаются как часть возвращаемого значения метода. Вызывающий должен проверить ошибку и обработать ее или распространить дальше. Это приводит к большому количеству шаблонов «if err != nil» повсюду. Однако опытные разработчики Go утверждают, что это обеспечивает гораздо лучшую обработку ошибок по сравнению с исключениями в Java. Я полагаю, что у меня еще не было этого «Вау» момента. Вдобавок ко всему, в Go тоже есть паники, которые, если их не обработать, остановят весь процесс. Рекомендуется допустить сбой процесса, не обрабатывая паники. Если одно событие имеет редкий ввод, который не обрабатывается должным образом, должен ли весь конвейер останавливаться, пока код не будет исправлен? Это звучит страшно.

Как упоминалось ранее, самым большим преимуществом Go является простота параллельного программирования.

Лямбда-реализация Java в лучшем случае хакерская. Go поддерживает функции как объекты первого класса, поэтому передача функций в качестве параметров и определение анонимных функций кажутся естественными. В Go считается, что любой тип, реализующий все методы, определенные в интерфейсе, реализует этот интерфейс. Это блестящий подход, который позволяет найти множество элегантных решений.

Кадровый пул, доступный для Java, намного больше, чем для Go. Однако простота языка Go очень поможет в обучении новых разработчиков и может компенсировать нехватку доступных разработчиков.

Это очень субъективно. Я лично считаю, что простота языка Go перевешивает большинство преимуществ небольшого прироста производительности синтаксических сахаров. Я чувствую, что дженерики — это исключение, и надеюсь, что скоро они будут включены в Go. В целом, код Go выглядит очень простым и понятным, чем код Java, особенно для новичков.

IDE и инструменты

Оба языка имеют высококачественные сопоставимые IDE. Java-разработчики в основном используют IntelliJ и Eclipse, оба из которых существуют уже давно и являются очень зрелыми. GoLand (среда разработки для Go от той же компании, что и IntelliJ) и VS Code, кажется, лидируют, когда дело доходит до Go. Я давно пользуюсь IntelliJ, и мне это нравится. Использование GoLand кажется очень естественным и отточенным для такого молодого продукта. Конечно, у IntelliJ гораздо больше намерений и вариантов рефакторинга, которые GoLand быстро догоняет.

Цепочку инструментов Go очень сложно подобрать. По сравнению с этим инструменты сборки для Java, такие как Gradle, требуют гораздо больше усилий для настройки. Управление зависимостями в Go значительно улучшилось за последний год. Однако у него все еще есть некоторые пробелы в работе с транзитивными зависимостями. С другой стороны, инструменты сборки для Java поддерживают надежные схемы управления зависимостями, хотя их немного сложно укротить.

В Java есть статические анализаторы кода, которые следят за соблюдением правил кодирования и отмечают некоторые потенциальные ошибки, которые могут вызывать исключения, такие как NullPointerException. Go делает это намного лучше. Например, компиляция завершится ошибкой, если переменная определена, но не используется. Компилятор выполняет множество таких проверок. Многие другие проверки доступны через другие линтеры, которые очень легко интегрируются с go toolchain. Go IDE имеет возможность автоматически форматировать код при сохранении. Даже если разработчик не использует IDE, такое же форматирование доступно через командную строку. Мне лично нравится такой подход, так как он заставляет всех использовать одинаковое форматирование и позволяет избежать байкшеринга.

Читаемость и ремонтопригодность существующего кода

Учитывая, что в Go приветствуется прямой код с небольшим количеством магии, его, как правило, очень легко читать и рассуждать. Все явно выражено в Go. Очень простые конструкции параллелизма снова упрощают понимание того, что происходит, даже когда несколько вещей происходят одновременно. Как правило, Java-код тоже неплох. Однако, если в обработке параллелизма используются расширенные конструкции или когда интенсивное использование отражений используется для введения магии (внедрение зависимостей Spring, многие не столь очевидные вещи, сделанные JPA и т. д.), разработчики без хорошего понимания этих библиотек/фреймворков можно было оставить в темноте.

Определенно Го выигрывает в этом безоговорочно.

Цикл тестирования кода

Когда разработчик разрабатывает функцию, цикл, связанный с изменением кода и проверкой функциональности, должен быть очень быстрым. Опять же, во время TDD Red-Green-Refactor в значительной степени зависит от компиляции кода и быстрого запуска тестов.

Создание кода Go происходит невероятно быстро. Сборки достаточно больших кодовых баз обычно выполняются менее чем за 2 секунды. Запуск приложения/теста также происходит очень быстро. Задержки начальной загрузки не возникает, поскольку Go компилируется в машинный код. Однако компиляция кода Java выполняется значительно медленнее. Компиляция только измененных и зависимых файлов, выполняемая IDE, немного помогает, но все же может быть медленной. Запуск набора приложений/тестов займет пару секунд, пока JVM загрузится. Опять же, здесь может помочь HotSwap (перезагрузка измененных классов без перезапуска JVM), хотя применимость этого очень ограничена (перезагружать можно только изменения тела метода). В целом, запуск тестов/перезапуск приложений на Java может быть довольно медленным по сравнению с молниеносной компиляцией+запуском приложений Go. Я использую инструмент для наблюдения за файлами (CompileDaemon), который автоматически создает и перезапускает приложение при каждом сохранении файла. Приложение готово обслужить запрос еще до того, как я переключусь на клиент Postman из IDE, и это очень удобно!

Вывод

Go — очень простой язык со многими преимуществами. Учитывая, что язык очень прост в изучении, а библиотеки, как правило, просты, привлечь людей с других языков должно быть довольно легко. Чрезвычайно простые конструкции параллелизма также уменьшают барьер для новичков. Поскольку мы ожидаем, что большинство микросервисов будут простыми и одноцелевыми, это хорошо подходит для этих вариантов использования. Всю команду можно легко обучить Go, и она может очень быстро стать продуктивной.

Если сервис сложный с большим количеством таблиц БД и многочисленными конечными точками API, мы считаем, что повышение производительности за счет более богатого синтаксиса языка Java и абстрагирования сложностей с помощью таких библиотек, как JPA, может быть достаточно значительным, чтобы окупить усилия по обучению. В таких ситуациях можно рассматривать Java в зависимости от варианта использования.

Об авторах

Картикеян Марудхачалам — главный архитектор Freshdesk. Вы можете связаться с ним в LinkedIn.

Кавьяприя Сетху — рассказчик, который пишет о том, что происходит внутри Freshworks. Поздоровайтесь с ней в LinkedIn.