碰巧您的程序是用脚本语言(例如,Ruby)编写的,因此需要用Golang重写。
一个合理的问题: 为什么您根本需要重写已经编写并且可以正常运行的程序?

首先,假设程序与特定的生态系统相关联 -在我们的例子中,这些是Docker和Kubernetes。 这些项目的整个基础架构都是用Golang编写的。 这将打开对使用Docker,Kubernetes等库的访问。 从支持,开发和完成您的程序的角度来看,使用与主要产品所使用的相同的基础结构会更有利可图。 在这种情况下,所有新功能将立即可用,而您不必用另一种语言重新实现它们。 仅在我们的特定情况下,这种情况才足以就原则上是否需要更改语言以及应该更改哪种语言做出决定。 但是,还有其他优点...
其次, 易于在Golang 上安装应用程序。 您无需在系统中安装Rvm,Ruby,一组gems等,只需下载一个静态二进制文件并使用它即可。
第三,Golang上的程序速度更高。 这不是通过任何语言使用正确的体系结构和算法获得的系统速度的显着提高。 但是,从控制台启动程序时会感觉到这种增加。 例如,Ruby中的--help
可以在0.8秒内计算出来,而在Golang上可以-0.02秒计算出来。 它只是明显改善了使用该程序的用户体验。
注意 :正如我们博客的普通读者可能会猜到的那样,该文章基于重写dapp产品的经验,该产品现在-尚未正式(!)-称为werf 。 有关更多详细信息,请参见本文结尾。
很好:您只需拿起并编写与旧脚本代码完全隔离的新代码即可。 但是,立即出现了一些困难和限制 ,这些困难和局限性在于为发展分配的资源和时间:
- Ruby中程序的当前版本一直需要改进和更正:
- 使用时会发生错误,应立即修复;
- 您无法在六个月内冻结新功能的添加,因为 客户/用户通常需要这些功能。
- 同时维护2个代码库既困难又昂贵:
- 考虑到除了该Ruby程序之外其他项目的存在,只有2-3人的团队很少。
- 新版本简介:
- 功能不应显着降低;
- 理想情况下,这应该是无缝的和无缝的。
需要一个连续的移植过程。 但是,如果将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
和相应的包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
文件的输出中, 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
字段必须由调用方处理。
从Ruby调用Golang
在Ruby方面,我们将Config::parse(config_path)
转换为包装器,该包装器调用config
,获取结果,处理所有可能的错误。 这是一个简化的示例Ruby伪代码:
module Config def parse(config_path) call_id = get_random_number args_file = "
二进制文件可能会因非零的意外代码而崩溃-这是一种例外情况。 或使用提供的代码-在这种情况下,我们查看res.json
文件中是否存在error
和config
字段,结果我们从序列化的config
字段中返回Config::Config
对象。
从用户的角度来看, Config::Parse
函数没有更改。
端口组件类
例如,使用类层次结构lib/git_repo
。 有2个类: GitRepo::Local
和GitRepo::Remote
。 将它们的实现组合在单个git_repo
二进制文件中,并因此在Golang中打包git_repo
是有意义的。
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
。
从Ruby调用Golang
在Ruby方面,我们将GitRepo::Base
, GitRepo::Local
, GitRepo::Remote
每个方法转换为调用git_repo
包装器,获取结果,设置GitRepo::Local
或GitRepo::Remote
类的对象的新状态。
否则,一切都类似于调用简单函数。
如何处理多态和基类
最简单的方法是不支持Golang的多态性。 即 确保始终对git_repo
二进制文件的调用始终明确地寻址到特定的实现(如果在参数localGitRepo
指定了localGitRepo
,则该调用来自GitRepo::Local
类对象;如果指定了remoteGitRepo
则来自GitRepo::Remote
),并通过复制少量样板在cmd中的代码。 毕竟,向Golang的迁移完成后, 该代码将被丢弃 。
如何更改另一个对象的状态
在某些情况下,一个对象接收另一个对象作为参数并调用一个隐式更改该第二个对象的状态的方法。
在这种情况下,您必须:
- 调用二进制文件时,除了调用该方法的对象的序列化状态外,还传输所有参数对象的序列化状态。
- 调用之后,重置调用该方法的对象的状态,并重置所有作为参数传递的对象的状态。
否则,一切都是相似的。
怎么了
我们使用一个组件,移植到Golang,发布新版本。
如果已经移植了基础组件,并且已转移了使用它们的更高级别的组件,则该组件可以“吸收”这些基础组件 。 在这种情况下,相应的多余二进制文件可能已作为不必要的文件而被删除。
这一直持续到我们到达最顶层,将所有底层抽象粘合在一起。 这将完成第一阶段的移植。 顶层是CLI。 在完全切换到Golang之前,他仍然可以在Ruby上生活一段时间。
如何分配这个怪物?
好:现在,我们有了一种逐步移植所有组件的方法。 问题:如何用2种语言分发这样的程序?
对于Ruby,该程序仍作为Gem安装。 一旦调用二进制文件,它就可以将此依赖项下载到特定的URL(它是硬编码的),并在系统中本地将其缓存(在服务文件中的某个位置)。
当我们使用两种语言发布程序的新版本时,我们必须:
- 收集所有二进制依赖性并将其上载到特定主机。
- 创建一个新的Ruby Gem版本。
即使某些组件没有更改,每个后续版本的二进制文件也会分别收集。 可以对所有相关二进制文件进行单独的版本控制。 这样就不必为程序的每个新版本收集新的二进制文件。 但是在我们的案例中,我们是从没有时间做非常复杂的事情和优化时间代码这一事实出发的,因此为了简单起见,我们为程序的每个版本收集了单独的二进制文件,这有损于节省下载的空间和时间。
该方法的缺点
显然, exec
通过system
/ exec
不断调用外部程序的开销。
很难在Golang级别上缓存任何全局数据 -毕竟,Golang中的所有数据(例如,包变量)都是在调用方法时创建的,并在完成后死亡。 必须始终牢记这一点。 但是,仍可以在类实例级别或通过将参数显式传递给外部组件来进行缓存。
我们一定不要忘记将对象的状态转移到Golang并在调用后正确还原它。
对Golang的二进制依赖占用了很多空间 。 只有一个30 MB二进制文件是一回事-Golang上的程序。 另一件事,当您移植〜10个组件(每个组件重30 MB)时, 每个版本我们将获得300 MB文件。 因此,二进制主机和主机上程序运行并不断更新的空间很快就会消失。 但是,如果您定期删除旧版本,则问题并不严重。
还要注意,随着程序的每次更新,下载二进制依赖项都将花费一些时间。
方法的好处
尽管提到了所有缺点,但是这种方法允许您组织一个连续的过程,以移植到另一种语言并与一个开发团队一起工作。
最重要的优点是能够获得有关新代码的快速反馈 ,对其进行测试并使其稳定的能力。
在这种情况下,除其他外,您可以向程序添加新功能,修复当前版本中的错误。
如何在Golang上进行最终政变
当所有主要组件都转换为Golang并已经在生产中进行测试时,剩下的就是将程序的顶部接口(CLI)重写为Golang并丢弃所有旧的Ruby代码。
在此阶段,剩下的只是解决新CLI与旧CLI的兼容性问题。
同志们,万岁! 革命实现了。
我们如何在Golang上重写dapp
Dapp是由Flant开发的用于组织CI / CD流程的实用程序。 由于历史原因,它是用Ruby编写的:
- 在Ruby中开发程序的丰富经验。
- 二手厨师(食谱使用Ruby编写)。
- 惯性,拒绝为我们认真使用新语言。
本文中介绍的方法已应用于在Golang上重写dapp。 下图显示了善(Golang,蓝色)和邪恶(Ruby,红色)之间进行斗争的时间顺序:

Ruby与语言的dapp / werf项目中的代码量 Golang在发行过程中
目前,您可以下载没有Ruby 的alpha版本1.0 。 我们也将dapp重命名为werf,但这是另一个故事…… 等待werf 1.0的完整发布!
作为此迁移的其他优点以及与臭名昭著的Kubernetes生态系统集成的例证,我们注意到在Golang上重写dapp给了我们创建另一个项目kubedog的机会。 因此,我们能够将用于跟踪K8s资源的代码分离到一个单独的项目中,这不仅在werf中有用,而且在其他项目中也很有用。 对于同一任务,还有其他解决方案(有关详细信息 , 请参见我们最近的公告 ) ,但是要在没有Go的情况下与它们“竞争”(就受欢迎程度而言),因为它的基础很难实现。
聚苯乙烯
另请参阅我们的博客: