想法来自:Graceful Restart In Golang

Go如何热更新,其实一直是一个问题。现在比较流行的方式,基本都来自 Graceful Restart In Golang 这篇由 Grisha Trubetskoy 所提出的方案。所以我也依据在佬所提出的方案,实现个简易的热更功能。

一、基本思想

其实方法很简单,就是项目在收到热更请示后,重新编译代码并以启动新编译好的可执行文件。但要注意,启动新的可执行文件时要告知它要关闭父进程。所以思想并不难。

二、基本实现

首先,我们新建一个名为 HotUpdate 的Go项目。我们先定义好,如果项以正常模式启动,则使用 ./HotUpdate 如果使用热更新模式启动,则使用 ./HotUpdate -update ,所以为了实现这一点,我们需要要写定义好启动参数,代码如下。

var isUpdate bool
func init() {
    flag.BoolVar(&isUpdate, "update", false, "if you need update this project, set this filed true")
    flag.Parse()
}

上段代码定义了 isUpdate 这个 bool 变量用于保存当前是否以 update 方式启动。

然后我们增加一个简单的 http 服务,当收到 /hello 请求时回一个字符串,以模拟普通请求,当收到/update请求时开始执行热更新。
模拟服务的代码如下:

http.HandleFunc("/hello", hello)
http.HandleFunc("/update", update)
err := http.ListenAndServe(":8080", nil)

接下来就是增加热更新逻辑了,写在 hotUpdate 函数里。逻辑就是先使用 go build 重新编译项目,待项目编译完成后,以热更的模式启动新编译好的可执行文件。
代码如下:

func hotUpdate() {
    cmd := exec.Command("go", "build")
    err := cmd.Run()
    if err != nil {
        fmt.Println(err)
    }
    fmt.Println("build end")

    path := os.Args[0]
    cmd = exec.Command(path, "-update")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    _ = cmd.Run()
    fmt.Println("child server running")
}

然后我们还需要写一段逻辑,如果是以热更模式启动的服务,则要先杀掉父进程,然后才开始监听端口,不然报会端口已被占用。
代码如下:

func main() {
    fmt.Println("Running ...")
    if isUpdate {
        fmt.Println("hot update, kill parent", syscall.Getppid())
        syscall.Kill(syscall.Getppid(), syscall.SIGTERM)
        time.Sleep(100 * time.Millisecond) // 等待,确保父进程结束
    }
    http.HandleFunc("/hello", hello)
    http.HandleFunc("/update", update)
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        fmt.Println(err)
    }
}

至此,完整代码如下:

package main

import (
    "flag"
    "fmt"
    "net/http"
    "os"
    "os/exec"
    "syscall"
    "time"
)

var isUpdate bool


func init() {
    flag.BoolVar(&isUpdate, "update", false, "if you need update this project, set this filed true")
    flag.Parse()
}

func hello(w http.ResponseWriter, r *http.Request) {
    _, _ = w.Write([]byte("hello\n"))
}

func update(w http.ResponseWriter, r *http.Request) {
    _, _ = w.Write([]byte("start update\n"))
    go hotUpdate()
}

func hotUpdate() {
    cmd := exec.Command("go", "build")
    err := cmd.Run()
    if err != nil {
        fmt.Println(err)
    }
    fmt.Println("build end")

    path := os.Args[0]
    cmd = exec.Command(path, "-update")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    _ = cmd.Run()
    fmt.Println("child server running")
}

func main() {
    fmt.Println("Running ...")
    if isUpdate {
        fmt.Println("hot update, kill parent", syscall.Getppid())
        syscall.Kill(syscall.Getppid(), syscall.SIGTERM)
        time.Sleep(100 * time.Millisecond)
    }
    http.HandleFunc("/hello", hello)
    http.HandleFunc("/update", update)
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        fmt.Println(err)
    }
}

然后编译运行,注意该代码暂时不能运行在 windows,因为 window 环境下没有 syscall.Kill(pid, syscall.SIGTERM) 函数,所以如果要运行在 windows 上,则还需要处理一下。

同时注意不要使用浏览器直接访问 localhost:8080/update,因为热更本质上会重启,浏览器会在服务重启后重新请示,会导致服务一起热更。
所以要使用 curl localhost:8080/update 方式请求。

接下来测试代码,启动项目后使用 curl 请示。

curl loaclhost:8080/hello
hello

// 修改代码中 hello 函数,将反回的字符串改为 "hello, world\n"
curl localhost:8080/update
start update

curl localhost:8080/hello
hello, world

至此热更成功。