Хабрахабр

Как победить дракона: переписываем вашу программу на Golang

Так случилось, что ваша программа написана на скриптовом языке — например, на Ruby — и встала необходимость переписать ее на Golang.

Резонный вопрос: зачем вообще может понадобится переписывать программу, которая уже написана и нормально работает?

Вся инфраструктура этих проектов написана на Golang. Во-первых, допустим, программа связана с определённой экосистемой — в нашем случае это Docker и Kubernetes. С точки зрения сопровождения, развития и доработки вашей программы выгоднее использовать ту же инфраструктуру, что используют основные продукты. Это открывает доступ к библиотекам, которые используют Docker, Kubernetes и прочие. Только этого условия в нашей конкретной ситуации было достаточно для принятия решения как о необходимости смены языка в принципе, так и о том, что за язык это должен быть. В этом случае все новые фичи будут доступны сразу и не придется реализовывать их повторно на другом языке. Есть, впрочем, и другие плюсы…

Не требуется ставить в систему Rvm, Ruby, набор gem'ов и пр. Во-вторых, простота установки приложений на Golang. Надо скачать один статический бинарный файл и использовать его.

Это не существенный системный прирост скорости, который получается при использовании правильной архитектуры и алгоритмов на любом языке. В-третьих, скорость работы программ на Golang выше. Например, --help на Ruby может отрабатывать за 0. Но это такой прирост, который ощущается при запуске вашей программы из консоли. 02 сек. 8 сек, а на Golang — 0. Это просто заметно улучшает 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). Простой случай. За его реализацию будет отвечать отдельный бинарник на Golang config и соответствующий package config: В данном компоненте определена только функция Config::parse, которая принимает путь к конфигу, читает его и выдаёт заполненную структуру.

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, поэтому такая возможность не параметризуется).

В аргументах через файл args.json указывается имя функции и её параметры. Вызов бинарника config равнозначен вызову какой-то функции компонента config. Если функция должна вернуть объект какого-то класса, то данные объекта данного класса возвращаются в сериализованном в JSON виде. На выходе через файл res.json получаем результат работы функции.

Например, для вызова функции Config::parse укажем такой args.json:

{ "command": "Parse", "configPath": "path-to-config.yaml"
}

Получаем результат в res.json:

, {"Name": "rails"}], "From": "ubuntu:16.04" },
}

Из этого состояния на вызывающей стороне в Ruby необходимо сконструировать объект Config::Config. В поле config получаем сериализованное в JSON состояние объекта Config::Config.

В случае возникновения предусмотренной ошибки бинарник может вернуть такой JSON:

{ "error": "no such file path-to-config.yaml"
}

Поле error должна обработать вызывающая сторона.

Вызываем Golang из Ruby

Приведем примерный псевдокод на Ruby с упрощениями: Со стороны Ruby превращаем функцию Config::parse(config_path) в обертку, которая вызывает наш config, получает результат, обрабатывает все возможные ошибки.

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 ничего не поменялось.

Портировать компонент-класс

Там есть 2 класса: GitRepo::Local и GitRepo::Remote. Для примера возьмём иерархию классов lib/git_repo. Имеет смысл совместить их реализацию в едином бинарнике 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,
}

Это состояние мы должны проставить в текущий Ruby-объект local_git_repo в любом случае. В поле localGitRepo получено новое состояние объекта (которое может не поменяться).

Вызываем Golang из Ruby

Со стороны Ruby превращаем каждый метод классов GitRepo::Base, GitRepo::Local, GitRepo::Remote в обертки, которые вызывают наш git_repo, получают результат, устанавливают новое состояние объекта класса GitRepo::Local или GitRepo::Remote.

В остальном всё аналогично вызову простой функции.

Как быть с полиморфизмом и базовыми классами

Т.е. Проще всего не делать поддержку полиморфизма со стороны Golang. Ведь всё равно этот код будет выкинут так скоро, как переезд на Golang будет закончен. сделать так, чтобы вызовы бинарника git_repo всегда были явно адресованы к конкретной реализации (если в аргументах указали localGitRepo, то вызов прилетел из объекта класса GitRepo::Local; если указали remoteGitRepo — тогда из GitRepo::Remote) и обойтись копированием небольшого количества boilerplate-кода в cmd.

Как менять состояние другого объекта

Бывают ситуации, когда объект получает параметром другой объект и вызывает ему метод, который неявно меняет состояние этого второго объекта.

В этом случае необходимо:

  1. Передавать при вызове бинарника помимо сериализованного состояния объекта, которому вызывают метод, сериализованное состояние всех объектов-параметров.
  2. После вызова переустанавливать состояние объекта, которому вызвали метод, и также переустанавливать состояние всех объектов, которые передавались как параметры.

В остальном всё аналогично.

Что получается?

Берем компонент, портируем на Golang, выпускаем новую версию.

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

На этом будет закончен первый этап портирования. И так продолжается до тех пор, пока мы не доберёмся до самого верхнего слоя, который склеивает все нижележащие абстракции. Он всё ещё может пожить на Ruby некоторое время перед полным переходом на Golang. Верхний слой — это CLI.

Как распространять этого монстра?

Вопрос: как распространять такую программу на 2-х языках? Хорошо: теперь у нас есть подход, чтобы постепенно портировать все компоненты.

Как только дело доходит до вызова бинарника, она может скачать эту зависимость по определенному URL (он захардкожен) и закэшировать ее локально в системе (где-нибудь в служебных файлах). В случае Ruby программа по-прежнему устанавливается как Gem.

Когда делаем новый релиз нашей программы на 2-х языках, мы должны:

  1. Собрать и загрузить все бинарные зависимости на некий хостинг.
  2. Создать Ruby Gem новой версии.

Можно было бы сделать отдельное версионирование всех зависимых бинарников. Бинарники для каждой последующей версии собираются отдельные, даже если какой-то компонент не поменялся. Но мы в своём случае исходили из того, что у нас нет времени делать что-то сверхсложное и оптимизировать временный код, поэтому для простоты собирали отдельные бинарники для каждой версии программы в ущерб экономии места и времени на скачивание. Тогда было бы не обязательно собирать новые бинарники для каждой новой версии программы.

Недостатки подхода

Очевидно, создаются накладные расходы на постоянный вызов внешних программ через system/exec.

Это надо всегда иметь в виду. Сложно сделать кэширование каких-либо глобальных данных на уровне Golang — ведь все данные в Golang (например, переменные package'ей) создаются при вызове какого-то метода и умирают после завершения. Однако кэширование всё же возможно на уровне экземпляров классов или при явной передаче параметров во внешний компонент.

Надо не забывать передавать состояние объектов в Golang и корректно восстанавливать его после вызова.

Одно дело, когда имеется единственный бинарник на 30 Мб — программа на Golang. Бинарные зависимости на Golang занимают много места. Из-за этого быстро уходит место на хостинге бинарников и на хост-машине, где работает и постоянно обновляется ваша программа. Другое дело, когда вы портировали ~10 компонентов, каждый из которых весит по 30 Мб — получаем 300 Мб файлов на каждую версию. Однако проблема не существенна, если периодически удалять старые версии.

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

Преимущества подхода

Несмотря на все указанные минусы, данный подход позволяет организовать непрерывный процесс портирования на другой язык и обойтись одной командой разработчиков.

Самое главное преимущество — возможность получить быструю обратную связь по новому коду, протестировать и стабилизировать его.

При этом можно, между делом, добавлять новые фичи в вашу программу, исправлять баги в текущей версии.

Как сделать окончательный переворот на Golang

На момент, когда все основные компоненты будут обращены в Golang и уже протестированы в production, останется только переписать верхний интерфейс вашей программы (CLI) на Golang и выкинуть весь старый Ruby-код.

На данном этапе остаётся лишь решать проблемы совместимости вашего нового CLI со старым.

Революция свершилась. Ура, товарищи!

Как мы переписали dapp на Golang

Она была написана на языке Ruby по историческим причинам: Dapp — это утилита, разработанная в компании «Флант» для организации процесса CI/CD.

  • Большой опыт разработки программ на Ruby.
  • Использовали Chef (рецепты для него пишутся на Ruby).
  • Инертность, сопротивление использованию нового для нас языка для чего-то серьёзного.

На приведенном графике видна хронология борьбы добра (Golang, синее) со злом (Ruby, красное): Описанный в статье подход был применен для переписывания dapp на Golang.

Golang с течением релизов Количество кода в проекте dapp/werf на языках Ruby vs.

0, в которой нет Ruby. На данный момент вы можете скачать alpha-версию 1. 0 уже скоро! Также мы переименовали dapp в werf, но это совсем другая история… Ждите полноценного релиза werf 1.

Так мы смогли выделить код для слежения за ресурсами K8s в отдельный проект, который может быть полезен не только в werf, но и в других проектах. В качестве дополнительных плюсов данной миграции и иллюстрации интеграции с пресловутой экосистемой Kubernetes отметим, что переписывание dapp на Golang дало нам возможность создать другой проект — kubedog. в нашем недавнем анонсе), но «конкурировать» с ними (в смысле популярности), не имея в своей основе Go, вряд ли бы стало возможным. Для этой же задачи существуют и иные решения (подробнее см.

P.S.

Читайте также в нашем блоге:

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»