Так случилось, что ваша программа написана на скриптовом языке — например, на Ruby — и встала необходимость переписать ее на Golang.
Резонный вопрос: зачем вообще может понадобится переписывать программу, которая уже написана и нормально работает?
Во-первых, допустим, программа связана с определённой экосистемой — в нашем случае это Docker и Kubernetes. Вся инфраструктура этих проектов написана на Golang. Это открывает доступ к библиотекам, которые используют Docker, Kubernetes и прочие. С точки зрения сопровождения, развития и доработки вашей программы выгоднее использовать ту же инфраструктуру, что используют основные продукты. В этом случае все новые фичи будут доступны сразу и не придется реализовывать их повторно на другом языке. Только этого условия в нашей конкретной ситуации было достаточно для принятия решения как о необходимости смены языка в принципе, так и о том, что за язык это должен быть. Есть, впрочем, и другие плюсы…
Во-вторых, простота установки приложений на Golang. Не требуется ставить в систему Rvm, Ruby, набор gem'ов и пр. Надо скачать один статический бинарный файл и использовать его.
В-третьих, скорость работы программ на Golang выше. Это не существенный системный прирост скорости, который получается при использовании правильной архитектуры и алгоритмов на любом языке. Но это такой прирост, который ощущается при запуске вашей программы из консоли. Например, --help
на Ruby может отрабатывать за 0.8 сек, а на Golang — 0.02 сек. Это просто заметно улучшает user experience использования программы.
NB: Как могли догадаться постоянные читатели нашего блога, статья основывается на опыте переписывания нашего продукта dapp, который теперь — пока ещё даже не совсем официально(!) — известен как werf. Небольшие подробности о нём см. в конце материала.
Хорошо: можно просто взять и сесть за написание нового кода, абсолютно изолированного от старого скриптового кода. Но сразу же всплывают некоторые сложности и ограничения по ресурсам и времени, выделяемым на разработку:
- Текущая версия программы на Ruby постоянно нуждается в доработках и исправлениях:
- Баги возникают по мере использования и должны быть исправлены оперативно;
- Заморозить добавление новых фич на полгода нельзя, т.к. эти фичи зачастую требуются клиентам/пользователям.
- Поддерживать 2 кодовые базы одновременно — сложно и дорого:
- Команды из 2-3 человек мало, если учесть наличие других проектов, помимо этой программы на Ruby.
- Внедрение новой версии:
- Не должно быть значительных деградаций по функциям;
- В идеале это должно быть незаметно и бесшовно.
Необходимо организовать непрерывный процесс портирования. Но как такое провернуть, если версия на Golang разрабатывается как отдельная программа?
Пишем сразу на двух языках
А что, если переносить на Golang компоненты снизу вверх? Начинаем с низкоуровневых вещей, затем идём вверх по абстракциям.
Представим, что ваша программа состоит из таких компонентов:
lib/
config.rb
build/
image.rb
git_repo/
base.rb
local.rb
remote.rb
docker_registry.rb
builder/
base.rb
shell.rb
ansible.rb
stage/
base.rb
from.rb
before_install.rb
git.rb
install.rb
before_setup.rb
setup.rb
deploy/
kubernetes/
client.rb
manager/
base.rb
job.rb
deployment.rb
pod.rb
Портировать компонент с функциями
Простой случай. Берем существующий компонент, который достаточно изолирован от остальных — например, config
(lib/config.rb
). В данном компоненте определена только функция Config::parse
, которая принимает путь к конфигу, читает его и выдаёт заполненную структуру. За его реализацию будет отвечать отдельный бинарник на Golang config
и соответствующий package config
:
cmd/
config/
main.go
pkg/
config/
config.go
Бинарник на Golang получает аргументы из JSON-файла и выдаёт результат в JSON-файл.
config -args-from-file args.json -res-to-file res.json
Допускается, что config
может выводить сообщения в stdout/stderr (в нашей программе на Ruby вывод всегда идет в stdout/stderr, поэтому такая возможность не параметризуется).
Вызов бинарника config
равнозначен вызову какой-то функции компонента config
. В аргументах через файл args.json
указывается имя функции и её параметры. На выходе через файл res.json
получаем результат работы функции. Если функция должна вернуть объект какого-то класса, то данные объекта данного класса возвращаются в сериализованном в JSON виде.
Например, для вызова функции Config::parse
укажем такой args.json
:
{
"command": "Parse",
"configPath": "path-to-config.yaml"
}
Получаем результат в res.json
:
{
"config": {
"Images": [{"Name": "nginx"}, {"Name": "rails"}],
"From": "ubuntu:16.04"
},
}
В поле config
получаем сериализованное в JSON состояние объекта Config::Config
. Из этого состояния на вызывающей стороне в Ruby необходимо сконструировать объект Config::Config
.
В случае возникновения предусмотренной ошибки бинарник может вернуть такой JSON:
{
"error": "no such file path-to-config.yaml"
}
Поле error
должна обработать вызывающая сторона.
Вызываем Golang из Ruby
Со стороны Ruby превращаем функцию Config::parse(config_path)
в обертку, которая вызывает наш config
, получает результат, обрабатывает все возможные ошибки. Приведем примерный псевдокод на Ruby с упрощениями:
module Config
def parse(config_path)
call_id = get_random_number
args_file = "#{get_tmp_dir}/args.#{call_id}.json"
res_file = "#{get_tmp_dir}/res.#{call_id}.json"
args_file.write(JSON.dump(
"command" => "Parse",
"configPath" => config_path,
))
system("config -args-from-file #{args_file} -res-to-file #{res_file}")
raise "config failed with unknown error" if $?.exitstatus != 0
res = JSON.load_file(res_file)
raise ParseError, res["error"] if res["error"]
return Config.new_from_state(res["config"])
end
end
Бинарник мог упасть с ненулевым непредусмотренным кодом — это исключительная ситуация. Либо с предусмотренными кодами — в этом случае смотрим файл res.json
на наличие полей error
и config
и в итоге возвращаем объект Config::Config
из сериализованного поля config
.
С точки зрения пользователя функции Config::Parse
ничего не поменялось.
Портировать компонент-класс
Для примера возьмём иерархию классов lib/git_repo
. Там есть 2 класса: GitRepo::Local
и GitRepo::Remote
. Имеет смысл совместить их реализацию в едином бинарнике git_repo
и, соответственно, package git_repo
в Golang.
cmd/
git_repo/
main.go
pkg/
git_repo/
base.go
local.go
remote.go
Вызов бинарника git_repo
соответствует вызову какого-либо метода объекта GitRepo::Local
или GitRepo::Remote
. У объекта есть состояние и оно может поменяться после вызова метода. Поэтому в аргументах мы передаем текущее состояние, сериализованное в JSON. А на выходе всегда получаем новое состояние объекта — тоже в JSON.
Например, для вызова метода local_repo.commit_exists?(commit)
укажем такой args.json
:
{
"localGitRepo": {
"name": "my_local_git_repo",
"path": "path/to/git"
},
"method": "IsCommitExists",
"commit": "e43b1336d37478282693419e2c3f2d03a482c578"
}
На выходе получаем res.json
:
{
"localGitRepo": {
"name": "my_local_git_repo",
"path": "path/to/git"
},
"result": true,
}
В поле localGitRepo
получено новое состояние объекта (которое может не поменяться). Это состояние мы должны проставить в текущий Ruby-объект local_git_repo
в любом случае.
Вызываем Golang из Ruby
Со стороны Ruby превращаем каждый метод классов GitRepo::Base
, GitRepo::Local
, GitRepo::Remote
в обертки, которые вызывают наш git_repo
, получают результат, устанавливают новое состояние объекта класса GitRepo::Local
или GitRepo::Remote
.
В остальном всё аналогично вызову простой функции.
Как быть с полиморфизмом и базовыми классами
Проще всего не делать поддержку полиморфизма со стороны Golang. Т.е. сделать так, чтобы вызовы бинарника git_repo
всегда были явно адресованы к конкретной реализации (если в аргументах указали localGitRepo
, то вызов прилетел из объекта класса GitRepo::Local
; если указали remoteGitRepo
— тогда из GitRepo::Remote
) и обойтись копированием небольшого количества boilerplate-кода в cmd. Ведь всё равно этот код будет выкинут так скоро, как переезд на Golang будет закончен.
Как менять состояние другого объекта
Бывают ситуации, когда объект получает параметром другой объект и вызывает ему метод, который неявно меняет состояние этого второго объекта.
В этом случае необходимо:
- Передавать при вызове бинарника помимо сериализованного состояния объекта, которому вызывают метод, сериализованное состояние всех объектов-параметров.
- После вызова переустанавливать состояние объекта, которому вызвали метод, и также переустанавливать состояние всех объектов, которые передавались как параметры.
В остальном всё аналогично.
Что получается?
Берем компонент, портируем на Golang, выпускаем новую версию.
В случае, когда нижележащие компоненты уже портированы и переносится более высокоуровневый компонент, который их использует, — этот компонент может «забрать в себя» эти нижележащие. В этом случае соответствующие лишние бинарники могут быть уже удалены за ненадобностью.
И так продолжается до тех пор, пока мы не доберёмся до самого верхнего слоя, который склеивает все нижележащие абстракции. На этом будет закончен первый этап портирования. Верхний слой — это CLI. Он всё ещё может пожить на Ruby некоторое время перед полным переходом на Golang.
Как распространять этого монстра?
Хорошо: теперь у нас есть подход, чтобы постепенно портировать все компоненты. Вопрос: как распространять такую программу на 2-х языках?
В случае Ruby программа по-прежнему устанавливается как Gem. Как только дело доходит до вызова бинарника, она может скачать эту зависимость по определенному URL (он захардкожен) и закэшировать ее локально в системе (где-нибудь в служебных файлах).
Когда делаем новый релиз нашей программы на 2-х языках, мы должны:
- Собрать и загрузить все бинарные зависимости на некий хостинг.
- Создать Ruby Gem новой версии.
Бинарники для каждой последующей версии собираются отдельные, даже если какой-то компонент не поменялся. Можно было бы сделать отдельное версионирование всех зависимых бинарников. Тогда было бы не обязательно собирать новые бинарники для каждой новой версии программы. Но мы в своём случае исходили из того, что у нас нет времени делать что-то сверхсложное и оптимизировать временный код, поэтому для простоты собирали отдельные бинарники для каждой версии программы в ущерб экономии места и времени на скачивание.
Недостатки подхода
Очевидно, создаются накладные расходы на постоянный вызов внешних программ через system
/exec
.
Сложно сделать кэширование каких-либо глобальных данных на уровне Golang — ведь все данные в Golang (например, переменные package'ей) создаются при вызове какого-то метода и умирают после завершения. Это надо всегда иметь в виду. Однако кэширование всё же возможно на уровне экземпляров классов или при явной передаче параметров во внешний компонент.
Надо не забывать передавать состояние объектов в Golang и корректно восстанавливать его после вызова.
Бинарные зависимости на Golang занимают много места. Одно дело, когда имеется единственный бинарник на 30 Мб — программа на Golang. Другое дело, когда вы портировали ~10 компонентов, каждый из которых весит по 30 Мб — получаем 300 Мб файлов на каждую версию. Из-за этого быстро уходит место на хостинге бинарников и на хост-машине, где работает и постоянно обновляется ваша программа. Однако проблема не существенна, если периодически удалять старые версии.
Также учтите, что при каждом обновлении программы будет уходить некоторое время на скачивание бинарных зависимостей.
Преимущества подхода
Несмотря на все указанные минусы, данный подход позволяет организовать непрерывный процесс портирования на другой язык и обойтись одной командой разработчиков.
Самое главное преимущество — возможность получить быструю обратную связь по новому коду, протестировать и стабилизировать его.
При этом можно, между делом, добавлять новые фичи в вашу программу, исправлять баги в текущей версии.
Как сделать окончательный переворот на Golang
На момент, когда все основные компоненты будут обращены в Golang и уже протестированы в production, останется только переписать верхний интерфейс вашей программы (CLI) на Golang и выкинуть весь старый Ruby-код.
На данном этапе остаётся лишь решать проблемы совместимости вашего нового CLI со старым.
Ура, товарищи! Революция свершилась.
Как мы переписали dapp на Golang
Dapp — это утилита, разработанная в компании «Флант» для организации процесса CI/CD. Она была написана на языке Ruby по историческим причинам:
- Большой опыт разработки программ на Ruby.
- Использовали Chef (рецепты для него пишутся на Ruby).
- Инертность, сопротивление использованию нового для нас языка для чего-то серьёзного.
Описанный в статье подход был применен для переписывания dapp на Golang. На приведенном графике видна хронология борьбы добра (Golang, синее) со злом (Ruby, красное):
Количество кода в проекте dapp/werf на языках Ruby vs. Golang с течением релизов
На данный момент вы можете скачать alpha-версию 1.0, в которой нет Ruby. Также мы переименовали dapp в werf, но это совсем другая история… Ждите полноценного релиза werf 1.0 уже скоро!
В качестве дополнительных плюсов данной миграции и иллюстрации интеграции с пресловутой экосистемой Kubernetes отметим, что переписывание dapp на Golang дало нам возможность создать другой проект — kubedog. Так мы смогли выделить код для слежения за ресурсами K8s в отдельный проект, который может быть полезен не только в werf, но и в других проектах. Для этой же задачи существуют и иные решения (подробнее см. в нашем недавнем анонсе), но «конкурировать» с ними (в смысле популярности), не имея в своей основе Go, вряд ли бы стало возможным.
P.S.
Читайте также в нашем блоге: