Electron 的 GUI 和 Ruby 的 CLI 的一種互動實踐

雲音樂技術團隊發表於2023-02-17
本文作者: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 個階段的另外一種描述。

命令列迭代的 4 個階段示意圖

一箇中大型 App 的 DevOps 會同時用到 CLI 和 GUI 來完成研發過程中的任務,其中 GUI 和 CLI 之間是存在互動通訊,最終開發人員和 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 命令的示例:

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 程式間通訊的簡單示意圖:

Ruby 程式和 Electron 程式間通訊的簡單示意圖

程式間通訊的本質是交換資訊,程式間的互動方式需要考慮以下因素:

  1. 一對一或者一對多;
  2. 同步呼叫或者非同步呼叫;

考慮到存在同時執行多個任務的情況,故需要支援一對多,且 GUI 大部分場景都不應該被 CLI 阻塞,故同步和非同步呼叫都要支援。

考慮到 Ruby 指令碼最終是打包成 gem 包,且支援以命令列的方式來呼叫,同時 Node 的 childProcess 模組支援開啟一個新的 Shell 程式。因此可以將 Electron 程式呼叫 Ruby 轉化為 Node 程式建立 Shell 程式,然後由 Shell 程式負責 Ruby 程式碼的執行,且每執行一次命令則開啟一個新的 Shell 程式,透過 childProcess 模組的 spawnSync 和 spawn ,可以實現同步和非同步呼叫。Node 和 Shell 程式之間的關係如下圖所示:

Node 和 Shell 程式關係圖

最終 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。

3 種通訊方式示意圖

通訊方式 - 檔案

為此,我們可以選擇檔案作為傳遞命令列的執行結果的通訊方式,上面可能遇到的命令列超長引數問題也可以用檔案的通訊方式來解決。下面是基於檔案的通訊方式的描述:

  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 環境變數中儲存的也就是一個大於零的整數,如下圖所示:

lsof 命令檢視 PIPE 示意圖

需要注意的是,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 內建 IPCNode 父子程式間通訊、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!

相關文章