关于electron:Electron-的-GUI-和-Ruby-的-CLI-的一种交互实践

103次阅读

共计 10124 个字符,预计需要花费 26 分钟才能阅读完成。

本文作者: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 个阶段的另外一种形容。

一个中大型 App 的 DevOps 会同时用到 CLI 和 GUI 来实现研发过程中的工作,其中 GUI 和 CLI 之间是存在交互通信,最终开发人员和 GUI、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 命令的示例:

将 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 过程间通信的简略示意图:

过程间通信的实质是替换信息,过程间的交互方式须要思考以下因素:

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

思考到存在同时执行多个工作的状况,故须要反对一对多,且 GUI 大部分场景都不应该被 CLI 阻塞,故同步和异步调用都要反对。

思考到 Ruby 脚本最终是打包成 gem 包,且反对以命令行的形式来调用,同时 Node 的 childProcess 模块反对开启一个新的 Shell 过程。因而能够将 Electron 过程调用 Ruby 转化为 Node 过程创立 Shell 过程,而后由 Shell 过程负责 Ruby 代码的执行,且每执行一次命令则开启一个新的 Shell 过程,通过 childProcess 模块的 spawnSync 和 spawn,能够实现同步和异步调用。Node 和 Shell 过程之间的关系如下图所示:

最终 Node 以命令行的形式来调用 Ruby 代码。在 Electron 中,主过程和渲染过程之间能够通过内置的 IPC 实现通信,于是一个典型的基于 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。

通信形式 – 文件

为此,咱们能够抉择文件作为传递命令行的执行后果的通信形式,下面可能遇到的命令行超长参数问题也能够用文件的通信形式来解决。上面是基于文件的通信形式的形容:

  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 环境变量中存储的也就是一个大于零的整数,如下图所示:

须要留神的是,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!

正文完
 0