company_banner

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

    Так случилось, что ваша программа написана на скриптовом языке — например, на 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 будет закончен.


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


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


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


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

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


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


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


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


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


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


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


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


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


    1. Собрать и загрузить все бинарные зависимости на некий хостинг.
    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.


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


    Флант
    349,00
    Специалисты по DevOps и высоким нагрузкам в вебе
    Поделиться публикацией

    Комментарии 35

      +5
      Простите, а почему бы не сделать один бинарник на Golang, и вызывать нужную вам функцию через параметр?
      Например:
      <binary-name> config args.json res.json

      Тогда как минимум вам не нужно будет дублировать весь рантайм и библиотеки в каждом бинарнике.
        +4

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


        Во время переписывания было удобно распределить работу параллельно по людям и каждому дать задачу типа: вот тебе компонент, ожидается бинарник с таким-то интерфейсом. При этом каких-то договоренностей о том, как устроен бинарь не было, главное чтобы делал то что надо и соответствовал интерфейсу. Так было удобнее делать работу именно параллельно с точки зрения редактирования кода. В случае когда один бинарь и над ним работает несколько человек — надо сразу определиться с "правильной" организацией кода, чтобы несколько человек не редактировало один файл. Нам же был больше нужен быстрый результат, чем экономия места.


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

        +1

        Я конечно только за прогресс, но не нашел в вашей статье доводов для переписывания именно на Го.


        Во-вторых, простота установки

        С использованием контейнеризации этот аргумент выглядит сомнительно


        В-третьих, скорость работы

        Ну если уж на то пошло то скорость работы Rust или Kotlin Native заметно выше(не на порядок конечно) да и комфорта больше ибо оба языка гораздо богаче в плане фитч, да и инфраструктура тоже если не лучше то эквивалентна голанговской.

          0
          «Во первых» тоже отпадает. У k8s есть api, который может работать на любом языке, хоть на php.
            +2
            Сложно согласиться. Когда проект для широкого сообщества, которое поголовно пишет на Go, и взаимодействует с другими проектами этой экосистемы, вопрос вовсе не в одном конкретном API.
              +2

              Это только в теории. Есть проблемы с аутентификацией в api например. Ниже ответил развернуто.


              В k8s еще есть куча оберток над этим api. Например, shared-informer-ы для слежения за состоянием ресурсов. Эта обертка решает низкоуровневые проблемы и является стандартной для написания kubernetes-операторов. В других языках такого нет, пока сам не напишешь.

                +3
                Не вникая в проблему, так действительно может показаться, однако реальность чуть сложнее.
                Последние две недели я активно работаю с Python Client. Ничем, помимо сгенерированных классов/методов из OpenAPI, он похвастаться не может. Сильно не хватает более высокоуровневых концептов из client-go, а потому велик соблазн забить на Watch совсем и пользоваться только List'ом, несмотря на performance impact. Таких, как Informer, например. Работать напрямую с List/Watch сильно неудобно.
                  +1

                  Похожее впечатление было от pyqt. Как бы молодцы, биндинги с классами нагенерили, а за решением проблем всё равно лезешь в C++-ную доку и потом как-то транслируешь примеры кода из C++ в python. В итоге два языка используешь, а не 1. Плюс автодополнение не всё автодополняет, потому что часть свойств в бинаре — удобной разработки! ;)

                +3

                Инфраструктура — главный аргумент. На Rust не может быть богаче инфраструктура именно для Docker и Kubernetes.


                Например, по опыту с Ruby: отсуствие нормальных клиентов для Kubernetes. У нас был самописный, чтобы достучатся до некоторых фич API типа слежения за ресурсами. Самописный клиент привел к тому, что наш dapp не мог коннектится к Google Kubernetes Engine, из-за использования там кастомной схемы аутентфикации. А времени реализовать эту схему в нашем клиенте не было. Вот такого рода проблемы.


                Когда перешли на Golang — мы просто включили стандартный клиент kubernetes и все, коннект к GKE из коробки. Если появится что-то новое — сразу будет из коробки.

                  +1
                  Вы так уверенно рассуждаете про kotlin-native. Не подскажите, где бы можно посмотреть результаты тестирования KN? А то у меня пока ровно обратная информация.
                    +1

                    Если говорить про сравнение с Rust то KN проигрывает, так же как и Го. А вот если сравнивать Го с jvm то тут быстродействие на одном уровне. KN по определению работает быстрее без jvm, темным пятном остается его оптимальная работа с памятью, тут нужно детальное сравнение, которым пока никто не занимался.

                      +1
                      KN по определению работает быстрее без jvm

                      вы это проверяли? или какую-нибудь ссылку дайте.
                        0

                        А вы обратное проверяли? Или какую-нибудь ссылку дайте. Или так и будете на всякие слухи опираться?

                          +1
                          Я проверял. KN отставал чуть ли не в 20 раз. Было очевидно что о скорости там ещё даже не задумывались, так как ещё рано об этом думать.

                          добавлено:
                          сейчас поискал что-то посвежее: github.com/msink/kotlin-pi
                          получше, но далеко не так, как вы рассказываете.
                            +1

                            Следуя вашей же ссылке:


                            - C 6.8 sec
                            - Kotlin/Native interop with C  6.8 sec

                            Как бы не в х20 раз медленнее
                            Замеры были сделаны до выхода стабильной версии в апреле прошлого года.


                            Может и в какой нибудь 0.6 версии и были отставания, я не знаю, но вот к выходу стабильной версии все было подправлено. Компилятор KN опирается на LLVM и это уже о многом говорит.

                  +2

                  Рикну предположить, что команде потребовались считаные дни, максимум недели, чтобы уже начать писать рабочий код на go. На Rust эта цифра измерялась бы на порядок большим сроком. Тут как-то писали об опыте на rust, что через год работы с языком начало получаться писать простые программы сразу, чтобы они компилировались без ошибок...

                    –2

                    Злые языки, не верьте :). В языке где нет обобщений и где всего одна конструкция для циклов можно писать быстро, дешево и сердито как на php4. Вот только в Го 2 собрались добавить обобщения, так что халяве пришел конец. Ну а если серьезно я как-то наблюдал мучения команды бывших рубистов при использовании указателей при вызове нативных функций на Го.

                      0

                      Представляюих вероятные мучения в rust...

                        +1

                        По этому Kotlin Native в данном случае выглядит куда более привлекательнее. У меня есть другой пример, мой один товарищ по цеху Scala за месяц игры с Rust в перерывах на обед освоил язык и сейчас пишет особо нагруженные сервисы на Rust. Так что, что скалистому хорошо, то рубероиду смерть :) .

                          0

                          Да, товарищ уже из сложного языка переходил. Может сработать.

                  0
                  Ребята, это реально охуенно ;) отсутсвие golang это была единственная реальная причина по которой я в сторону dapp даже не смотрел ;) не хотелось тянуть ruby в экосистему
                    +3
                    Ничеси гайки открутили!
                    +2
                    Если хотелось просто переписать работающий продукт на другой ЯП ради переписывания — то всё ок. Из трёх пунктов мотивации на переписывание, только первый выглядит адекватным для перехода именно на go. Если важна была скорость работы продукта с минимум затрат на переписывание, то можно было взглянуть на crystal (или elixir) которые имеют ruby синтаксис и семантику. А менять ООП язык на «процедурный» фишка которого concurrency, и не имея в требованиях эту concurrency — звучит странно.
                      +1
                      только первый выглядит адекватным для перехода именно на go.

                      Выше автор уже так и написал:
                      Инфраструктура — главный аргумент.
                        0

                        Ещё одна фишка Go и аргумент за начало всей этой движухи с переписыванием — кросскомпиляция. Компилятор Go умеет кросскомпилировать статические бинарники без дополнительных телодвижений. Одним заданием в travis ci можно собрать бинарники под несколько платформ (linux+macos+windows).


                        У Crystal с этим туговато, похоже это не его основное предназначение:


                        1. https://crystal-lang.org/reference/syntax_and_semantics/cross-compilation.html
                        2. NOTE: Building statically-linked executables is currently only supported on Alpine Linux.


                        Статические бинарники только под alpine linux. Для сборки динамического бинарника нужно произвести линковку на той платформе, под которую кросс-компилируешь. Не звучит невыполнимо, но заметно сложнее, чем сборка для Go.

                          0

                          Эта фишка свойственна не только Го

                        0
                        Почему не Elixir?
                          +1
                          Как минимум (и этого достаточно) — потому что «во-первых» (экосистема Kubernetes).
                            0
                            А зачем рантайм Erlang тянуть (BEAM)?
                            0
                            Почему назвали werf? Какой-то прикол на тему оборотней? Или просто неудачно транлитерировали слово «верфь» на английский? Логика есть в обоих версиях )
                              +1
                              Это был долгий процесс… Скоро представим проект отдельной публикацией — там и расскажем! ;-)
                                +2
                                Я отвечу кратче, чем Дима.
                                en.wiktionary.org/wiki/werf#Dutch
                                  0
                                  Как будто тут много голландцев ;) хотя отсылка на первоисточник — +1 в карму )
                                  0

                                  Так "верфь" и есть транслитерация слова werf, а не наоборот ;)

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

                                Самое читаемое