Go os-exec 極速入門
os/exec
是 Go 提供的內置包,可以用來執行外部命令或程序。比如,我們的主機上安裝了 redis-server
二進制文件,那麼就可以使用 os/exec
在 Go 程序中啓動 redis-server
提供服務。當然,我們也可以使用 os/exec
執行 ls
、pwd
等操作系統內置命令。本文不求內容多麼深入,旨在帶大家極速入門 os/exec
的常規使用。
極速入門
如下是 os/exec 包實現的 exported 結構體和方法:
func LookPath(file string) (string, error)
type Cmd
func Command(name string, arg ...string) *Cmd
func CommandContext(ctx context.Context, name string, arg ...string) *Cmd
func (c *Cmd) CombinedOutput() ([]byte, error)
func (c *Cmd) Environ() []string
func (c *Cmd) Output() ([]byte, error)
func (c *Cmd) Run() error
func (c *Cmd) Start() error
func (c *Cmd) StderrPipe() (io.ReadCloser, error)
func (c *Cmd) StdinPipe() (io.WriteCloser, error)
func (c *Cmd) StdoutPipe() (io.ReadCloser, error)
func (c *Cmd) String() string
func (c *Cmd) Wait() error
Cmd
結構體表示一個準備或正在執行的外部命令。調用函數 Command
或 CommandContext
可以構造一個 *Cmd
對象。調用 Run
、Start
、Output
、CombinedOutput
方法可以運行 *Cmd
對象所代表的命令。 調用 Environ
方法可以獲取命令執行時的環境變量。調用 StdinPipe
、StdoutPipe
、StderrPipe
方法用於獲取管道對象。調用 Wait
方法可以阻塞等待命令執行完成。調用 String
方法返回命令的字符串形式。LookPath
函數用於搜索可執行文件。
運行一個命令
使用最簡單的方式運行一個命令:
package main
import (
"log"
"os/exec"
)
func main() {
// 創建一個命令
cmd := exec.Command("echo", "Hello, World!")
// 執行命令並等待命令完成
err := cmd.Run() // 執行後控制檯不會有任何輸出
if err != nil {
log.Fatalf("Command failed: %v", err)
}
}
exec.Command
函數用於創建一個命令,函數第一個參數是命令的名稱,後面跟一個不定常參數作爲這個命令的參數,最終會傳遞給這個命令。
*Cmd.Run
方法會阻塞等待命令執行完成,默認情況下命令執行後控制檯不會有任何輸出:
# 執行程序
$ go run main.go
# 執行完成後沒有任何輸出
我們也可以在後臺運行一個命令:
func main() {
cmd := exec.Command("sleep", "3")
// 執行命令(非阻塞,不會等待命令執行完成)
if err := cmd.Start(); err != nil {
log.Fatalf("Command start failed: %v", err)
return
}
fmt.Println("Command running in the background...")
// 阻塞等待命令完成
if err := cmd.Wait(); err != nil {
log.Fatalf("Command wait failed: %v", err)
return
}
log.Println("Command finished")
}
實際上 Run
方法就等於 Start
+ Wait
方法,如下是 Run
方法源碼的實現:
func (c *Cmd) Run() error {
if err := c.Start(); err != nil {
return err
}
return c.Wait()
}
創建帶有 context 的命令
os/exec
還提供了一個 exec.CommandContext
構造函數可以創建一個帶有 context
的命令。那麼我們就可以利用 context
的特性來控制命令的執行了。
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "sleep", "5")
if err := cmd.Run(); err != nil {
log.Fatalf("Command failed: %v\n", err) // signal: killed
}
}
執行示例代碼,得到輸出如下:
$ go run main.go
2025/01/14 23:54:20 Command failed: signal: killed
exit status 1
當命令執行超時會收到 killed
信號自動取消。
獲取命令的輸出
無論是調用 *Cmd.Run
還是 *Cmd.Start
方法,默認情況下執行命令後控制檯不會得到任何輸出。
我們可以使用 *Cmd.Output
方法來執行命令,以此來獲取命令的標準輸出:
func main() {
// 創建一個命令
cmd := exec.Command("echo", "Hello, World!")
// 執行命令,並獲取命令的輸出,Output 內部會調用 Run 方法
output, err := cmd.Output()
if err != nil {
log.Fatalf("Command failed: %v", err)
}
fmt.Println(string(output)) // Hello, World!
}
執行示例代碼,得到輸出如下:
$ go run main.go
Hello, World!
獲取組合的標準輸出和錯誤輸出
*Cmd.CombinedOutput
方法能夠在運行命令後,返回其組合的標準輸出和標準錯誤輸出:
func main() {
// 使用一個命令,既產生標準輸出,也產生標準錯誤輸出
cmd := exec.Command("sh", "-c", "echo 'This is stdout'; echo 'This is stderr' >&2")
// 獲取 標準輸出 + 標準錯誤輸出 組合內容
output, err := cmd.CombinedOutput()
if err != nil {
log.Fatalf("Command execution failed: %v", err)
}
// 打印組合輸出
fmt.Printf("Combined Output:\n%s", string(output))
}
執行示例代碼,得到輸出如下:
$ go run main.go
Combined Output:
This is stdout
This is stderr
設置標準輸出和錯誤輸出
我們可以利用 *Cmd
對象的 Stdout
和 Stderr
屬性,重定向標準輸出和標準錯誤輸出到當前進程:
func main() {
cmd := exec.Command("ls", "-l")
// 設置標準輸出和標準錯誤輸出到當前進程,執行後可以在控制檯看到命令執行的輸出
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("Command failed: %v", err)
}
}
這樣,使用 *Cmd.Run
執行命令後控制檯就能看到命令執行的輸出了。
執行示例代碼,得到輸出如下:
$ go run main.go
total 4824
-rw-r--r-- 1 jianghushinian staff 12 Jan 4 10:37 demo.log
drwxr-xr-x 3 jianghushinian staff 96 Jan 13 09:41 examples
-rwxr-xr-x 1 jianghushinian staff 2453778 Jan 1 15:09 main
-rw-r--r-- 1 jianghushinian staff 6179 Jan 15 09:13 main.go
使用標準輸入傳遞數據
我們可以使用 grep
命令接收 stdin
的數據,然後在其中搜索包含指定模式的文本行:
func main() {
cmd := exec.Command("grep", "hello")
// 通過標準輸入傳遞數據給命令
cmd.Stdin = bytes.NewBufferString("hello world!\nhi there\n")
// 獲取標準輸出
output, err := cmd.Output()
if err != nil {
log.Fatalf("Command failed: %v", err)
return
}
fmt.Println(string(output)) // hello world!
}
可以將一個 io.Reader
對象賦值給 *Cmd.Stdin
屬性,來實現將數據通過 stdin
傳遞給外部命令。
執行示例代碼,得到輸出如下:
$ go run main.go
hello world!
再比如,我們還可以將打開的文件描述符傳給 *Cmd.Stdin
屬性:
func main() {
file, err := os.Open("demo.log") // 打開一個文件
if err != nil {
log.Fatalf("Open file failed: %v\n", err)
return
}
defer file.Close()
cmd := exec.Command("cat")
cmd.Stdin = file // 將文件作爲 cat 的標準輸入
cmd.Stdout = os.Stdout // 獲取標準輸出
if err := cmd.Run(); err != nil {
log.Fatalf("Command failed: %v", err)
}
}
只要是 io.Reader
對象即可。
設置和使用環境變量
*Cmd
的 Environ
方法可以獲取環境變量,Env
屬性則可以設置環境變量:
func main() {
cmd := exec.Command("printenv", "ENV_VAR")
log.Printf("ENV: %+v\n", cmd.Environ())
// 設置環境變量
cmd.Env = append(cmd.Environ(), "ENV_VAR=HelloWorld")
log.Printf("ENV: %+v\n", cmd.Environ())
// 獲取輸出
output, err := cmd.Output()
if err != nil {
log.Fatalf("Command failed: %v", err)
}
fmt.Println(string(output)) // HelloWorld
}
這段代碼輸出結果與執行環境相關,我就不演示執行結果了,你可以自行嘗試。
不過最終的 output
輸出結果一定是 HelloWorld
。
使用管道
os/exec
支持管道功能,*Cmd
對象提供的 StdinPipe
、StdoutPipe
、StderrPipe
三個方法用於獲取管道對象。故名思義,三者分別對應標準輸入、標準輸出、標準錯誤輸出的管道對象。
使用示例如下:
func main() {
// 命令中使用了管道
cmdEcho := exec.Command("echo", "hello world\nhi there")
outPipe, err := cmdEcho.StdoutPipe()
if err != nil {
log.Fatalf("Command failed: %v", err)
}
// 注意,這裏不能使用 Run 方法阻塞等待,應該使用非阻塞的 Start 方法
if err := cmdEcho.Start(); err != nil {
log.Fatalf("Command failed: %v", err)
}
cmdGrep := exec.Command("grep", "hello")
cmdGrep.Stdin = outPipe
output, err := cmdGrep.Output()
if err != nil {
log.Fatalf("Command failed: %v", err)
}
fmt.Println(string(output)) // hello world
}
首先創建一個用於執行 echo
命令的 *Cmd
對象 cmdEcho
,並調用它的 StdoutPipe
方法獲得標準輸出管道對象 outPipe
;然後調用 Start
方法非阻塞的方式執行 echo
命令;接着創建一個用於執行 grep
命令的 *Cmd
對象 cmdGrep
,將 cmdEcho
的標準輸出管道對象賦值給 cmdGrep.Stdin
作爲標準輸入,這樣,兩個命令就通過管道串聯起來了;最終通過 cmdGrep.Output
方法拿到 cmdGrep
命令的標準輸出。
執行示例代碼,得到輸出如下:
$ go run main.go
hello world
使用 bash -c
執行復雜命令
如果你不想使用 os/exec
提供的管道功能,那麼在命令中直接使用管道符 |
,也可以實現同樣功能。
不過此時就需要使用 sh -c
或者 bash -c
等 Shell 命令來解析執行更復雜的命令了:
func main() {
// 命令中使用了管道
cmd := exec.Command("bash", "-c", "echo 'hello world\nhi there' | grep hello")
output, err := cmd.Output()
if err != nil {
log.Fatalf("Command failed: %v", err)
}
fmt.Println(string(output)) // hello world
}
這段代碼中的管道功能同樣生效。
指定工作目錄
可以通過指定 *Cmd
對象的的 Dir
屬性來指定工作目錄:
func main() {
cmd := exec.Command("cat", "demo.log")
cmd.Stdout = os.Stdout // 獲取標準輸出
cmd.Stderr = os.Stderr // 獲取錯誤輸出
// cmd.Dir = "/tmp" // 指定絕對目錄
cmd.Dir = "." // 指定相對目錄
if err := cmd.Run(); err != nil {
log.Fatalf("Command failed: %v", err)
}
}
捕獲退出狀態
上面講解了很多執行命令相關操作,但其實還有一個很重要的點沒有講到,就是如何捕獲外部命令執行後的退出狀態碼:
func main() {
// 查看一個不存在的目錄
cmd := exec.Command("ls", "/nonexistent")
// 運行命令
err := cmd.Run()
// 檢查退出狀態
var exitError *exec.ExitError
if errors.As(err, &exitError) {
log.Fatalf("Process PID: %d exit code: %d", exitError.Pid(), exitError.ExitCode()) // 打印 pid 和退出碼
}
}
這裏執行 ls
命令來查看一個不存在的目錄 /nonexistent
,程序退出狀態碼必然不爲 0
。
執行示例代碼,得到輸出如下:
$ go run main.go
2025/01/15 23:31:44 Process PID: 78328 exit code: 1
exit status 1
搜索可執行文件
最後要介紹的函數就只剩一個 LookPath
了,它用來搜索可執行文件。
搜索一個存在的命令:
func main() {
path, err := exec.LookPath("ls")
if err != nil {
log.Fatal("installing ls is in your future")
}
fmt.Printf("ls is available at %s\n", path)
}
執行示例代碼,得到輸出如下:
$ go run main.go
ls is available at /bin/ls
搜索一個不存在的命令:
func main() {
path, err := exec.LookPath("lsx")
if err != nil {
log.Fatal(err)
}
fmt.Printf("ls is available at %s\n", path)
}
執行示例代碼,得到輸出如下:
$ go run main.go
2025/01/15 23:37:45 exec: "lsx": executable file not found in $PATH
exit status 1
功能練習
介紹完了 os/exec
常用的方法和函數,我們現在來做一個小練習,使用 os/exec
來執行外部命令 ls -l /var/log/*.log
。
示例如下:
func main() {
cmd := exec.Command("ls", "-l", "/var/log/*.log")
output, err := cmd.CombinedOutput() // 獲取標準輸出和錯誤輸出
if err != nil {
log.Fatalf("Command failed: %v", err)
}
fmt.Println(string(output))
}
執行示例代碼,得到輸出如下:
$ go run main.go
2025/01/16 09:15:52 Command failed: exit status 1
exit status 1
執行報錯了,這裏的錯誤碼爲 1
,但錯誤信息並不明確。
這個報錯其實是因爲 os/exec
默認不支持通配符參數導致的,exec.Command
不支持直接在參數中使用 Shell 通配符(如 *
),因爲它不會通過 Shell 來解析命令,而是直接調用底層的程序。
要解決這個問題,可以通過顯式調用 Shell(例如 bash
或 sh
),讓 Shell 來解析通配符。
比如使用 bash -c
執行通配符命令 ls -l /var/log/*.log
:
func main() {
// 使用 bash -c 來解析通配符
cmd := exec.Command("bash", "-c", "ls -l /var/log/*.log")
output, err := cmd.CombinedOutput() // 獲取標準輸出和錯誤輸出
if err != nil {
log.Fatalf("Command failed: %v", err)
}
fmt.Println(string(output))
}
執行示例代碼,得到輸出如下:
$ go run main.go
-rw-r--r-- 1 root wheel 0 Oct 7 21:20 /var/log/alf.log
-rw-r--r-- 1 root wheel 11936 Jan 13 11:36 /var/log/fsck_apfs.log
-rw-r--r-- 1 root wheel 334 Jan 13 11:36 /var/log/fsck_apfs_error.log
-rw-r--r-- 1 root wheel 19506 Jan 11 18:04 /var/log/fsck_hfs.log
-rw-r--r--@ 1 root wheel 21015342 Jan 16 09:02 /var/log/install.log
-rw-r--r-- 1 root wheel 1502 Nov 5 09:44 /var/log/shutdown_monitor.log
-rw-r-----@ 1 root admin 3779 Jan 16 08:59 /var/log/system.log
-rw-r----- 1 root admin 187332 Jan 16 09:05 /var/log/wifi.log
此外,我們還可以用 Go 標準庫提供的 filepath.Glob
來手動解析通配符:
func main() {
// 匹配通配符路徑
files, err := filepath.Glob("/var/log/*.log")
if err != nil {
log.Fatalf("Glob failed: %v", err)
}
if len(files) == 0 {
log.Println("No matching files found")
return
}
// 將匹配到的文件傳給 ls 命令
args := append([]string{"-l"}, files...)
cmd := exec.Command("ls", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("Command failed: %v", err)
}
}
filepath.Glob
函數會返回模式匹配的文件名列表,如果不匹配則返回 nil
。這樣,我們就可以先解析文件名列表,再交給 exec.Command
來執行 ls
命令了。
項目實戰
雖然我們現在入門了 os/exec
包,不過還沒在實際項目中使用過,本小節就來看下 os/exec
在真實項目中的應用。
我在「超簡單!用 Go 啓動 Redis 實例」一文中介紹了一款可以用來啓動 redis-server
的開源庫 github.com/stvp/tempredis
。現在,我來帶你深入 tempredis
的源碼探究其實現原理,也讓你體會下 os/exec
的實際應用。
tempredis
源碼實現非常簡單,核心源碼只有兩個文件 tempredis.go 和 config.go。
config.go
源碼如下:
https://github.com/stvp/tempredis/blob/master/config.go
// Config is a key-value map of Redis config settings.
type Config map[string]string
func (c Config) Socket() string {
return c["unixsocket"]
}
這裏定義了一個 Config
結構體用於保存配置信息,實際上就是一個 map
。Socket
方法返回 unixsocket
這個 key
所對應的 value
。
接下來我們來分析 tempredis.go
源碼:
https://github.com/stvp/tempredis/blob/master/tempredis.go
// Server encapsulates the configuration, starting, and stopping of a single
// redis-server process that is reachable via a local Unix socket.
type Server struct {
dir string
config Config
cmd *exec.Cmd
stdout io.Reader
stdoutBuf bytes.Buffer
stderr io.Reader
}
首先,這裏定義了一個結構體 Server
用於表示 redis-server
服務器對象。可以發現,其內部包含了 *exec.Cmd
類型屬性 cmd
,這是 Server
結構體的核心所在。
接下來看下 Start
函數的定義:
// Start initiates a new redis-server process configured with the given
// configuration. redis-server will listen on a temporary local Unix socket. An
// error is returned if redis-server is unable to successfully start for any
// reason.
func Start(config Config) (server *Server, err error) {
if config == nil {
config = Config{}
}
dir, err := ioutil.TempDir(os.TempDir(), "tempredis")
if err != nil {
returnnil, err
}
if _, ok := config["unixsocket"]; !ok {
config["unixsocket"] = fmt.Sprintf("%s/%s", dir, "redis.sock")
}
if _, ok := config["port"]; !ok {
config["port"] = "0"
}
server = &Server{
dir: dir,
config: config,
}
err = server.start()
if err != nil {
return server, err
}
err = server.ready()
if err != nil {
return server, err
}
return server, nil
}
Start
函數用於根據給定的配置 Config
創建並啓動 Server
。
實例化的 server
對象就是上面介紹的 Server
結構體,這裏通過 server.start()
來啓動 Redis 服務,並通過 server.ready()
來阻塞判斷服務是否啓動成功。
start
方法定義如下:
func (s *Server) start() (err error) {
if s.cmd != nil {
return fmt.Errorf("redis-server has already been started")
}
s.cmd = exec.Command("redis-server", "-")
stdin, _ := s.cmd.StdinPipe()
s.stdout, _ = s.cmd.StdoutPipe()
s.stderr, _ = s.cmd.StderrPipe()
err = s.cmd.Start()
if err == nil {
err = writeConfig(s.config, stdin)
}
return err
}
可以看到,這裏構造的核心命令就是 exec.Command("redis-server", "-")
,並將其賦值給 s.cmd
屬性;然後分別記錄了幾個管道對象;接着調用 s.cmd.Start()
來執行命令;並通過 writeConfig(s.config, stdin)
將配置信息傳遞給標準輸入供 redis-server -
命令讀取使用。
writeConfig
函數實現如下:
func writeConfig(config Config, w io.WriteCloser) (err error) {
for key, value := range config {
if value == "" {
value = "\"\""
}
_, err = fmt.Fprintf(w, "%s %s\n", key, value)
if err != nil {
return err
}
}
return w.Close()
}
接着我們再來看下 ready
方法的實現:
func (s *Server) ready() (err error) {
c := make(chan error, 1)
gofunc() {
// Block until Redis is ready to accept connections.
c <- s.waitFor()
}()
select {
case err := <-c:
return err
case <-time.After(1 * time.Second):
return fmt.Errorf("timed out awaiting startup")
}
}
這裏啓動一個新的 goroutine 調用 s.waitFor()
來阻塞等待 Redis 服務準備就緒,並將結果通過 channel
傳遞給主 goroutine,主 goroutine 會等待 Redis 服務準備就緒或超時退出。
waitFor
方法實現如下:
var (
// ready is the string redis-server prints to stdout after starting
// successfully.
ready = []string{
"The server is now ready to accept connections",
"Ready to accept connections",
}
)
// waitFor blocks until redis-server is ready
func (s *Server) waitFor() (err error) {
var line string
scanner := bufio.NewScanner(s.stdout)
for scanner.Scan() {
line = scanner.Text()
fmt.Fprintf(&s.stdoutBuf, "%s\n", line)
for _, s := range ready {
if strings.Contains(line, s) {
returnnil
}
}
}
err = scanner.Err()
if err == nil {
err = io.EOF
}
return err
}
這裏的源碼實現比較有意思,就是通過讀取執行 redis-server
命令後,捕獲到的標準輸出中的字符串內容,來判斷是否匹配 ready
變量中的字符串,以此來識別 redis-server
是否啓動成功。在 Redis 7.2 版本之前,啓動成功會在控制檯輸出 The server is now ready to accept connections
,在 Redis 7.2 版本及以後,啓動成功會在控制檯輸出 Ready to accept connections
。
就是這樣,有些我們以爲可能需要寫很多代碼才能支持的功能,源碼實現竟然如此樸素。
Server
對象也實現了 Socket
方法,不過僅僅是 Config.Socket
方法的代理:
// Socket returns the full path to the local redis-server Unix socket.
func (s *Server) Socket() string {
return s.config.Socket()
}
還記得在 Server.start
方法中會將管道對象記錄到 Server
對象中嗎?
s.stdout, _ = s.cmd.StdoutPipe()
s.stderr, _ = s.cmd.StderrPipe()
如下就是二者被使用的地方:
// Stdout blocks until redis-server returns and then returns the full stdout
// output.
func (s *Server) Stdout() string {
io.Copy(&s.stdoutBuf, s.stdout)
return s.stdoutBuf.String()
}
// Stderr blocks until redis-server returns and then returns the full stdout
// output.
func (s *Server) Stderr() string {
bytes, _ := ioutil.ReadAll(s.stderr)
returnstring(bytes)
}
這兩個方法分別用來阻塞等待返回 redis-server
執行後的標準輸出和標準錯誤輸出。
它們二者在實現上有所差別,因爲 *Server.waitFor
中其實已經消費了 stdout
,所以會將其暫存到 *Server.stdoutBuf
屬性中。
最後用於關閉 redis-server
的兩個方法源碼實現如下:
// Term gracefully shuts down redis-server. It returns an error if redis-server
// fails to terminate.
func (s *Server) Term() (err error) {
return s.signalAndCleanup(syscall.SIGTERM)
}
// Kill forcefully shuts down redis-server. It returns an error if redis-server
// fails to die.
func (s *Server) Kill() (err error) {
return s.signalAndCleanup(syscall.SIGKILL)
}
func (s *Server) signalAndCleanup(sig syscall.Signal) error {
s.cmd.Process.Signal(sig)
_, err := s.cmd.Process.Wait()
os.RemoveAll(s.dir)
return err
}
這裏通過 s.cmd.Process.Signal(sig)
爲進程發送信號,*Cmd.Process
屬性是 *os.Process
類型,實際上 Cmd
對象是底層類型 *os.Process
和 *os.ProcessState
的封裝。我們暫且不必深究更底層的實現,只需要知道 *Cmd.Process
代表了命令執行的進行對象,調用 Signal
方法可以爲進程發送信號,調用 Wait
方法會阻塞等待進程執行完成。
至此,tempredis
包的源碼就都講解完成了。
總結
os/exec
是 Go 語言內置包,用於運行一個外部命令。我向你介紹了它的常用方法,算是帶你一起入個門。並且我們還一起閱讀了第三方包 tempredis 的源碼,以此來學習在工程實踐中如何使用 os/exec
。
此外,你還需要注意,調用 *Cmd
的 Run
、Start
、Output
、CombinedOutput
方法執行外部命令以後,這個對象就無法重複使用了,即一個命令不能被執行多次,否則將得到 exec: already started
報錯信息。
對 os/exec
的入門講解就到這裏,如果你還想進行更深入的探索,可以參考官方文檔及其源碼。
本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。
希望此文能對你有所啓發。
延伸閱讀
-
os/exec Documentation:https://pkg.go.dev/os/exec@go1.23.0
-
tempredis Documentation:https://pkg.go.dev/github.com/stvp/tempredis
-
tempredis GitHub 源碼:https://github.com/stvp/tempredis
-
Distributed mutual exclusion lock using Redis for Go:https://github.com/go-redsync/redsync
-
Install Redis:https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/
-
本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/os/exec
聯繫我
-
公衆號:Go 編程世界
-
微信:jianghushinian
-
郵箱:jianghushinian007@outlook.com
-
博客:https://jianghushinian.cn
-
GitHub:https://github.com/jianghushinian
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/_02nRvotc2UHUYakeP2S7A