本文作者:linusflow

背景

在一个中大型的客户端研制团队中,会运用诸如 Ruby、Shell、Python 等脚本言语编写的脚本、指令行和 GUI 东西来完结各项使命。比方 iOS、Android 开发人员想在一台新电脑上开发一个新 App ,那么需求先在本地装备好开发环境,之后才干经过 Xcode 或 Android Studio 进入开发。

在 App 的初期,开发人员或许只需求简略的几行指令即可完结环境的装备。跟着 App 规划变大,装备环境所需履行的指令越来越多,此刻可以运用一种或多种脚本言语将这些指令聚合到脚本文件里边,这样履行该脚本文件即可快速履行繁复的指令。当 App 规划进一步变大,散落的脚本文件会越来越多,变得难以运用和保护,此刻可以将这些散落的脚本文件绑缚到一同,构成一个或多个 CLI 东西集,CLI 东西集可以创立一个或多个新的指令的办法给开发人员运用。跟着时刻的推移和发展,App 的规划会进一步变大,此刻会发现 CLI 东西集越来越杂乱,供给的指令的调用时的参数和选项会变得杂乱又多样,开发人员难以回忆这些又长又多的参数和选项。此刻可以将这些 CLI 东西集聚合到 GUI 上,让开发人员仅经过点击按钮即可完结环境的装备,极大的提高了开发人员的运用体会和功率。下面剖析出了指令行迭代(履行)的 4 个阶段示意图,并在后续的篇幅中将只叙述第 3 阶段和第 4 阶段。文章后续的描绘中,有关「CLI」和「CLI 东西集」的描绘是等同的,「指令行」是针对 CLI 中 3 个阶段的另外一种描绘。

Electron 的 GUI 和 Ruby 的 CLI 的一种交互实践

一个中大型 App 的 DevOps 会一起用到 CLI 和 GUI 来完结研制进程中的使命,其间 GUI 和 CLI 之间是存在交互通讯,终究开发人员和 GUI、CLI 的交互示意图如下所示:

Electron 的 GUI 和 Ruby 的 CLI 的一种交互实践

笔者在 iOS 团队,故选取了当时抢手的桌面端技能 Electron 作为 GUI,熟悉的脚本言语 Ruby 作为 CLI ,聚焦指令行迭代的第 3 和第 4 阶段,给出 Electron 的 GUI 和 Ruby 的 CLI 的一种交互实践。

Ruby 脚本指令行化

在指令行迭代的 4 个阶段中的第 3 阶段,咱们可以将 Ruby 脚本做成 CLI 东西集,也可以了解为是将 Ruby 脚本进程指令行化。下面将给出 Ruby 脚本指令行化的实践办法。

将散落的 Ruby 脚本打包成一个 gem 包,可以便利代码的复用、同享和按版别迭代保护,一起便利分发、下载和安装。gem 包可以类比为 Centos 的 yum ,前端的 npm 包。咱们可以运用 Bundler 来 创立 gem 包,且支撑指令行化(CLI 指令),详细流程可以检查 官方教程。信任 iOS 开发者对 Cocoapods 都不陌生,Cocoapods 以 gem 包的办法分发,一起供给了 pod 指令,如咱们熟知的「pod install」指令。Cocoapods 运用 CLAide 完结了指令行化,当然咱们也可以运用 Bundler 供给的指令行化的办法,或许规划一种自定义的指令行的标准后再完结指令行化,这儿咱们引荐运用 CLAide 来完结 gem 的指令行。有关 CLAide 的运用示例,在网上可以找到很多案例,本文不再累述。下图是 pod 指令的示例:

Electron 的 GUI 和 Ruby 的 CLI 的一种交互实践

将 Ruby 脚本打包成一个 gem 包,并供给 CLI 指令支撑,后续新增功用可以经过新增指令的办法来完结。至此,咱们现已完结了指令行迭代的第 3 阶段。跟着新增的功用越来越多,CLI 东西集规划也随之变大,供给的指令和参数也变得又多又杂乱,即使关于指令的开发者来说,在运用进程中也难以高效的去运用。为此,咱们可以对这些 CLI 东西集进行下一阶段的聚合,即进入指令行迭代的第 4 个阶段。

Ruby 和 Electron 的通讯计划

在指令行迭代的 4 个阶段中的终究一个阶段,中心需求完结 CLI 和 GUI 的交互通讯。GUI 调用 CLI 则涉及到跨言语调用,这时一般有两种处理计划:

  1. 将函数做成一个服务,经过进程间通讯(IPC)或网络协议通讯(RPC、Http、WebSocket 等)完结调用,至少两个进程才干完结;
  2. 直接将其它言语的函数内嵌到本言语中,经过言语交互接口(FFI)调用,调用功率比第一种计划高;

这两种调用办法实质上都可以了解为:参数传递 + 函数调用 + 回来值传递。Ruby 不是编译型言语,会边解说边履行,不会生成可履行程序,一般也不会被打包成二进制可履行文件来供其它言语进行 FFI 调用,故第二种调用计划并不能用于 Ruby 和 Javascript 或 Typescript 的调用。现在只考虑第一种调用计划,即进程间通讯或许经过网络协议通讯。

进程间通讯

Electron 中包含一个主进程(Main)和一个及以上的烘托进程(Renderer),咱们可以简略了解为主进程便是一个后台运转的 Node 进程,咱们看到的窗口(Window)就对应一个烘托进程(如 Chrome 浏览器的一个 Tab 页对应一个烘托进程)。Electron 调用 Ruby ,可以了解为是主进程去调用 Ruby 进程,实质上是两个不同进程之间的通讯进程。烘托进程可以经过 内置的 IPC 才干 和主进程通讯,并凭借主进程完结对 Ruby 进程的调用,故中心仍是主进程调用 Ruby 进程。两个进程之间通讯(IPC)的办法有很多种,常见的办法 有:文件、信号、套接字、管道(命名和匿名)、同享内存和音讯传递等,故也可以将网络协议通讯了解为广义上的进程间 IPC 通讯。下图是 Ruby 进程和 Electron 进程间通讯的简略示意图:

Electron 的 GUI 和 Ruby 的 CLI 的一种交互实践

进程间通讯的实质是交换信息,进程间的交互办法需求考虑以下要素:

  1. 一对一或许一对多;
  2. 同步调用或许异步调用;

考虑到存在一起履行多个使命的状况,故需求支撑一对多,且 GUI 大部分场景都不应该被 CLI 阻塞,故同步和异步调用都要支撑。

考虑到 Ruby 脚本终究是打包成 gem 包,且支撑以指令行的办法来调用,一起 Node 的 childProcess 模块支撑敞开一个新的 Shell 进程。因而可以将 Electron 进程调用 Ruby 转化为 Node 进程创立 Shell 进程,然后由 Shell 进程负责 Ruby 代码的履行,且每履行一次指令则敞开一个新的 Shell 进程,经过 childProcess 模块的 spawnSync 和 spawn ,可以完结同步和异步调用。Node 和 Shell 进程之间的联系如下图所示:

Electron 的 GUI 和 Ruby 的 CLI 的一种交互实践

终究 Node 以指令行的办法来调用 Ruby 代码。在 Electron 中,主进程和烘托进程之间可以经过内置的 IPC 完结通讯,所以一个典型的依据 Electron 的 GUI 和依据 Ruby 的 CLI 的调用模型如下图所示:

Electron 的 GUI 和 Ruby 的 CLI 的一种交互实践

通讯计划

Node 调用 Shell 指令,需求考虑到指令的参数怎么传给指令,一起需求考虑到指令履行的终究成果怎么回来给 Node。最简略的是直接将指令的参数和选项直接凑集到指令的后边,然后将凑集后的指令直接在 Shell 中履行。实践咱们也是运用的这种办法,有以下几个点需求注意:

  1. 凑集后的指令字符串需求做特别字符的转义,如 JSON 格局的字符串,需求 JSON.stringify(JSON.stringify()) 的办法来做特别字符的转义;
  2. 参数中包含有意义的空格(不是分隔符)时,需求用双引号包含起来;
  3. 操作系统对指令行的参数长度有限制,不然会呈现「Argument list too long」报错,故需求操控好指令行的参数长度,或许另寻其它办法来传递超长参数的字符串;

指令行中的参数存在字符转义和长度的限制,假如 stdin 通道没有被用作其它用处,可以运用 stdin 通道来传递参数,或许供给一种新的通讯办法来传递参数。Shell 指令履行的成果怎么回来给 Node 进程,最简略的便是经过 stdout/stderr 来获取成果。参阅 git 指令的规划,一起供给高档指令(Porcelain)和初级指令(Plumbing),其间初级指令要比高档指令的输出稳定,因而可以输出固定格局的成果,这样 Node 进程就可以依据不同指令输出的不同的格局的成果进行处理。可是这样会占用 stdout/stderr 通道,然后导致代码的日志输出不能运用 stdout/stderr 通道。假如简略的将日志输出重定向到其它当地,那么会干扰到现有指令的日志正常输出,再者都是已有的 Ruby 脚本,导致对现有 Ruby 脚本代码的侵入性较高。

为此,咱们是可以考虑不运用 stdout/stderr 通道来获取指令的履行成果,这样可以在这两个输出通道中检查日志,便利排查问题。为了一起支撑指令行参数和履行成果的传递,下面给出常用的 3 种通讯办法的说明,包含文件、Unix Domain Socket 和 Node 内置 IPC。

Electron 的 GUI 和 Ruby 的 CLI 的一种交互实践

通讯办法 – 文件

为此,咱们可以挑选文件作为传递指令行的履行成果的通讯办法,上面或许遇到的指令行超长参数问题也可以用文件的通讯办法来处理。下面是依据文件的通讯办法的描绘:

  1. 针对超长参数字符串,可以由 GUI 创立一个文件,将超长参数字符串写入入参文件,之后将入参文件的途径经过一个入参文件途径选项的办法传给 CLI,CLI 读取入参文件途径选项所指向的文件,读取完毕后再将该文件删去;
  2. 针对指令行回来成果,GUI 生成一个空的履行成果文件途径选项传递给 CLI,CLI 依据履行成果文件选项途径创立出文件,然后将指令的履行成果写入该文件,GUI 等指令履行完毕后再依据传入的履行成果文件途径来读取成果,读取完毕后再将文件删去;

这儿咱们运用 JSON 作为履行成果的回来格局。下面给出 Node 和 Ruby 通讯一次的简略示例代码:

Node 完整示例代码:

import fs from "fs-extra"
import childProcess from "node:child_process"
const components = { params: {} }
const componentsWithEscape = JSON.stringify(JSON.stringify(components))
const guiResultPath = "/tmp/result.json"
const options = { shell: "/bin/zsh" } // 也可以指明cwd选项(当时目录),合适bundle exec的办法
const args = `--components=${componentsWithEscape} --GUI="${guiResultPath}"`
const command = `martinx gui commit`
const executeResult = await childProcess.spawn(command, args, options) // 履行指令
const guiResult = fs.readJsonSync(guiResultPath)  // 读取回来成果
fs.rm(guiResultPath)  // 读取完后删去文件
const { stdout, stderr, all } = executeResult // 可以读取日志

Ruby 完整示例代码:

require 'claide'
module MartinX
    class Command < CLAide::Command
        def run(argv)
            super(argv)
            output = {
                :data => {},
                :code => 200,
                :msg => "success"            
            }
            # do something...
        ensure
            expand_path = Pathname.new(@path).expand_path
            file_dir.dirname.mkpath unless expand_path.dirname.exist?
            File.new(expand_path, File::CREAT | File::TRUNC, 0644).close # 创立文件
            File.open(@path, 'w') do |file|
                file.syswrite(output.to_json) # 将履行成果写入文件
            end
        end        
        def initialize(argv)
            @path = argv.option('GUI')  # 运用path目标实例变量保存文件途径
        end
    end
end

上面的 martinx 为一个名为 MartinX 的 gem 包所对应的指令,是内部一个 DevOps 东西集的姓名,用作示例运用,后边其它的通讯办法的解说也会用 martinx 作为示例。以上示例代码可运转测试。

通讯办法 – Unix Domain Socket

UNIX Domain Socket 与传统依据 TCP/IP 协议栈的 Socket 不同,不需求经过网络协议栈,以文件系统作为地址空间,与管道类似。因为管道的发送与接收数据相同依赖于途径称号,故也支撑 owner、group、other 的文件权限设定。UNIX Domain Socket 在通讯完毕后不会自动销毁,故需求手动调用 fs.unlink 来复用 unixSocketPath,不同进程间会经过读写操作系统创立的「.sock」文件来完结通讯。与多个服务一起通讯,此刻需求保护多个通讯通道,运用 UNIX Domain Socket,可以运用 Linux IO 多路复用功用。下面给出 Node 和 Ruby 经过 Unix Domain Socket 的通讯办法的示例代码。

Node 中心示例代码:

const net = require("net")
const unixSocketServer = net.createServer() // 需求创立服务
const unixSocketPath = "/tmp/unixSocket.sock"
unixSocketServer.listen(unixSocketPath, () => {
    console.log("listening")
})
unixSocketServer.on("connection", (s) => {
    s.write("hello world from Node")
    s.on("data", (data) => {
        console.log("Recived from Ruby: " + data.toString())
    })
    s.end()
})
const fs = require("fs")
fs.unlink(unixSocketPath) // 便利后续 unixSocketPath 的复用

Ruby 中心示例代码:

require 'socket'
unixSocketPath = '/tmp/unixSocket.sock'
UNIXSocket.open(unixSocketPath) do |sock|
    sock.puts "hello world from Ruby"
    puts "Recived from Node: #{sock.gets}"
end

通讯办法 – Node 内置 IPC

从 Node 官网有关 child_process 模块的 介绍文档 里边可知,Node 父进程在创立子进程之前,会创立 IPC 通道并监听它,然后才真实的创立出子进程,这个进程中也会经过环境变量(NODE_CHANNEL_FD)告知子进程这个 IPC 通道的文件描绘符(File Descriptor),咱们可以了解文件描绘符是一个指向 PIPE 管道的链接。子进程可以经过这个 IPC 通道来和父进程完结通讯,在本文也便是 Electron 的 Node 主进程可以经过这个 IPC 通道来和创立出来的子进程(Shell 进程)来完结通讯。

在 Windows 操作系统中,这个 IPC 通道是经过命名管道完结,在 Unix 操作系统上,则是经过 Unix Domain Socket 完结。比方在 MacOS 操作系统内核中,会保护一张 Open File Table,该 Table 会记录每个进程一切翻开的文件描绘(File Description),咱们可以经过 lsof 指令来检查某个进程的一切 PIPE 类型的文件描绘所对应的文件描绘符,指令输出的第四列为数字,该数字便是 PIPE 的文件描绘,NODE_CHANNEL_FD 环境变量中存储的也便是一个大于零的整数,如下图所示:

Electron 的 GUI 和 Ruby 的 CLI 的一种交互实践

需求注意的是,NODE_CHANNEL_FD 所指向的 IPC 通道只支撑 JSON 格局的字符串的通讯。咱们可以给 spawn 的 option 参数中的 stdio 数组中传入「ipc」字符串,即可敞开父子进程之间的 IPC 通讯才干。从 Node.js 的「process_wrap.cc」源码中咱们可以知道,翻开的 PIPE 管道的 fd(File Descriptor)会重定向到stdio 数组中「ipc」值的索引,鄙人面的代码示例中,翻开的 PIPE 管道的 fd 会重定向到 fd 为 3 的 PIPE 管道。下面将给出代码示例。

Node 中心示例代码:

const cp = require('child_process');
const n = cp.spawn('martinx', ['--version'], {
    stdio: ['ignore', 'ignore', 'ignore', 'ipc']
});
spawned.on("message", (data) => {
    console.log("Recived from Ruby:" + data)
})
spawned.send({"message": "hello world from Node"})

Ruby 中心示例代码:

node_channel_fd = ENV['NODE_CHANNEL_FD']
io = IO.new(node_channel_fd.to_i)
data = { :data => 'hello world from Ruby' } # 只支撑JSON格局的字符串
io.puts data.to_json
puts "Recived from Node: " + io.gets

咱们也可以直接经过 Shell 脚本的办法直接和 Node 通讯。Shell 的示例代码如下:

# 数字 1 是文件描绘符,代表标准输出(stdout)。将 stdout 重定向到 NODE_CHANNEL_FD 指向的管道流
printf "{"message": "hello world from Node"}" 1>&$NODE_CHANNEL_FD
NODE_MESSAGE=read -u $NODE_CHANNEL_FD
echo $NODE_MESSAGE

以上示例代码可运转测试。

通讯办法总结

至此,咱们经过上面给出的 3 种通讯办法,完结了指令行迭代的第 3 阶段到第 4 阶段的跨越,即终究完结了指令行迭代的第 4 阶段。以上给出的 3 种通讯办法示例中,考虑到跨平台以及不同环境下的通用性和调试的快捷性,笔者地点的团队内部的 DevOps 主要运用了文件的通讯办法。在 CLI 内部只需求对指令行的入参和履行成果拟定一些简略的标准和标准,即可在不同的操作系统上正常运转,一起在多个不同言语的 CLI 东西集之间也能很便利的进行 IPC 通讯。在开发调试时,可以经过检查履行成果文件的办法快速检查到履行成果。上面介绍的 3 种通讯办法,没有绝对的好坏之分,咱们可以依据实践的项目需求来灵活选用,下面给出了引荐运用场景:

通讯办法 引荐运用场景
文件 十分注重通用性、和多个服务通讯、交互简略的实时性不高的数据
Unix Domain Socket 和多个服务一起通讯、传输很多数据或高并发场景、权限隔离
Node 内置 IPC Node 父子进程间通讯、Node 与 Shell 进程间通讯

最佳实践

下面将给出指令行迭代的第 3 阶段到第 4 阶段的进程中遇到的 Shell 和 Ruby 的环境问题、Ruby 脚本的指令行化后的调用以及 Electron 和 Ruby 的开发调试的实践。

Shell 中的 Ruby 环境

Node 创立的 Shell 进程和咱们运用 Mac 自带的 Terminal 或许 Iterm2 中创立的 Shell 进程中的环境是不相同的。比方咱们经过 Terminal 在电脑上用 Rvm 安装了 2.6.8 版别的 Ruby,在 Node 创立的 Shell 进程中,默许是找不到安装的 2.6.8 版别的 Ruby,故需求将这些 Ruby 环境注入到 Node 创立的 Shell 进程后,才干正常运用。

Node 经过 childProcess 模块的 spawnSync 或 spawn 创立的 Shell 进程需求注入 Ruby 的环境,此刻有两种计划:第一种是直接内置一套最小化的 Ruby 环境,如 traveling-ruby 的 Ruby 二进制打包计划;第二种是运用用户本地现有的 Ruby 环境。这儿可以依据团队项目的实践状况来挑选,当然也可以两种办法都支撑,本文将讨论第二种办法。这儿引荐运用 Rvm 来安装和管理Ruby 环境。咱们可以在用户根目录下的「.zshrc」、「.profile」、「.bash_profile」等文件中获悉 Rvm 的环境信息,只需求在每次履行指令前,先将 Rvm 的环境信息注入即可。下面给出了 Rvm 的环境注入的 Shell 示例代码:

export LANG=en_US.UTF-8 && [[ -s "$HOME/.rvm/scripts/rvm" ]] && source "$HOME/.rvm/scripts/rvm"

Ruby 脚本的指令行调用

调用 Ruby 脚本的指令有下面两种办法:

  1. 「bundle exec」 + 指令
  2. 指令

第一种办法一起合适开发环境和出产环境,在以 gem 包发布 Ruby 脚本的前提下,故只适用于开发环境,此刻 Node 履行 Shell 指令需求指明 cwd 选项,将该选项设置为本地的 Ruby 的 gem 包的代码根目录即可。第二种办法合适在出产环境运用,并可以在指令后添加如「1.6.6」来指明运用 1.6.6 版别的 gem 包。下面是这两种履行办法的代码示例:

# 第一种办法
bundle exec martinx gui code check --path="/Users/xx/x" --GUI="/private/var/folders/s3/071qk97d5hg525j3hstqfw9m0000gn/T/martinx_LiGuWarY"
# 第二种办法
martinx _1.6.6_ gui code check --path="/Users/xx/x" --GUI="/private/var/folders/s3/071qk97d5hg525j3hstqfw9m0000gn/T/martinx_LiGuWarY"

在第一种调用办法中,假如调用的指令的代码中会以「bundle exec」的办法去调用其它指令,那么需求先清空当时的 Bundler 环境后,才干够正常调用。下面是代码示例:

# Bundler 2.1+ 版别运用 with_unbundled_env,不然运用 with_clean_env 办法
::Bundler.with_unbundled_env do
    `bundle exec martinx xxx`
end

终究,需求注意在 Ruby 代码里不要呈现「$stdin.gets」调用,这样会导致 Shell 进程一直在等待输入,形成进程忙等的假象,而是将需求输入的内容在指令调用时就以参数或选项的方式传入。

Ruby 和 Electron 调试

一般来说,咱们可以经过指令行接口来和言语调试器后端连接起来,并运用 stdin/stdout/stderr 流来进行操控;也可以挑选依据线路协议,经过 TCP/IP 或许网络协议来连接到调试器,这两种办法都能便利用户调试脚本代码。

Ruby 调试

Ruby 的调试东西挑选仍是很多样的,咱们常用的有以下几种挑选:

  • puts
  • pry
  • byebug
  • pry-byebug
  • RubyMine/VSCode 等 GUI 调试东西
  • 以上的任意组合

假如 Ruby 脚本代码有一定规划和杂乱度,为了便利调试,仍是引荐咱们运用如 RubyMine 这种 GUI 调试东西。RubyMine 调试 Ruby 的运转原理是会把一切的代码都加入断点监控,故会比只加载部分代码模块速度要慢。运用 RubyMine 调试单条指令的履行关于习惯了 IDE 的开发来说,是十分友好的,且合理运用其供给的 attach(LLDB) 到运转的 Ruby 进程也是十分便利的。有关更多 RubyMine 的调试,感兴趣的读者可以检查 官网材料。

Electron 调试

Electron 的主进程和烘托进程的调试,引荐运用 VSCode,简略几步装备即可调试。其间烘托进程的调试可以像普通网页相同在 DevTools 上直接断点调试,在网上可以找到很多这方面的材料,本文不做过多解说。这儿引荐直接运用官网给出的 调试示例。

总结

本文介绍了日常研制进程中,很多散落的 Ruby 脚本怎么以一种更高效的办法给研制运用,并给出了指令行迭代的 4 个阶段。从 Ruby 脚本指令行化到后边逐渐剖析 Ruby 脚本指令行化后的可视化,探究了跨言语进程间的通讯计划,并给出文件、Unix Domain Socket 和管道这 3 种 GUI 和 CLI 之间的通讯办法。终究针对依据 Ruby 的 CLI 和依据 Electron 的 GUI 在实践开发进程中,说明了会遇到的 Ruby 环境问题和对应的处理计划,终究给出了 Ruby 和 Electron 开发调试的一些剖析和主张。以上内容都是依据笔者在实践的 DevOps 研制进程中运用到的内容,包含跨言语进程间的 IPC 通讯、Ruby 脚本指令行化、Ruby 相关的环境问题以及 Ruby 和 Electron 的调试,以上这些内容关于运用其它开发言语或结构的 CLI 和 GUI 之间的交互实践,也是可以供给一些参阅和主张。

本文发布自网易云音乐技能团队,文章未经授权禁止任何方式的转载。咱们终年接收各类技能岗位,假如你预备换工作,又恰好喜爱云音乐,那就加入咱们 grp.music-fe(at)corp.netease.com!