你好,我是轩脉刃。

第13章我们引入命令行的时候,将Web启动方式改成了一个命令行。但是当时只完成了一个最简单的启动Web服务的命令,这节课,我们要做的是完善这个Web服务运行命令,让Web服务的运行有完整的启动、停止、重启、查询的进程管理功能。

这套完整的进程管理功能,能让应用管理者非常方便地通过一套命令来统一管控一个应用,降低应用管理者的管理成本,后续也能为实现应用自动化部署到远端服务的工具提供了基础。下面我们来具体看下如何设计这套命令并且实现它吧。

运行命令的设计

首先照惯例需要设计一下运行命令,一级命令为 app,二级命令设计如下:

这四个二级命令,有app服务的启动、停止、重启、查询,基本上已经把一个app服务启动的状态变更都包含了,能基本满足后面我们对于一个应用的管理需求。下面来讨论下每个命令的功能和设计。

启动命令

首先是start这个命令,写在framework/command/app.go中。我们先分析下参数。

想要启动app服务,至少需要一个参数,就是启动服务的监听地址。如何获取呢?首先可以直接从默认配置获取,另外因为这是一个控制台命令,也一定可以直接从命令行获取。除了这两种方式,我们回顾下之前的配置项获取方法,还有环境变量和配置项。

所以总结起来,环境变量这个参数我们设计为有四个方式可以获取,一个是直接从命令行参数获取address参数,二是从环境变量ADDRESS中获取,然后是从配置文件中获取配置项app.address,最后如果以上三个方式都没有设置,就使用默认值:8888。关键的代码逻辑如下:

if appAddress == "" {
    envService := container.MustMake(contract.EnvKey).(contract.Env)
    if envService.Get("ADDRESS") != "" {
        appAddress = envService.Get("ADDRESS")
    } else {
        configService := container.MustMake(contract.ConfigKey).(contract.Config)
        if configService.IsExist("app.address") {
            appAddress = configService.GetString("app.address")
        } else {
            appAddress = ":8888"
        }
    }
}

除了监听地址的参数,回忆之前cron命令运行的时候,启动app服务,我们是有两种启动方式的,一种是启动后直接挂在控制台,这种启动方式适合调试开发使用;而另外一种,以守护进程daemon的方式启动,直接挂载在后台。所以,对于这两种启动方式,我们也需要有一个参数daemon,标记是使用哪种方式启动。

有了appAddress、daemon这两个参数,我们顺着继续想启动服务时需要的记录文件

不管是使用挂载方式,还是daemon方式启动进程,都能获取到一个进程PID,启动app服务的时候,要将这个PID记录在一个文件中,这里我们就存储在 app/storage/runtime/app.pid 文件中。在运行时候,需要保证这个目录和文件是存在的。

同时也会产生日志,日志存放在app/storage/log/app.log中,所以我们要确认这个目录是否存在。

关于app.pid和app.log对应的代码:

appService := container.MustMake(contract.AppKey).(contract.App)

pidFolder := appService.RuntimeFolder()
if !util.Exists(pidFolder) {
    if err := os.MkdirAll(pidFolder, os.ModePerm); err != nil {
        return err
    }
}
serverPidFile := filepath.Join(pidFolder, "app.pid")
logFolder := appService.LogFolder()
if !util.Exists(logFolder) {
    if err := os.MkdirAll(logFolder, os.ModePerm); err != nil {
        return err
    }
}
// 应用日志
serverLogFile := filepath.Join(logFolder, "app.log")
currentFolder := util.GetExecDirectory()

好到这里,准备工作都做好了,我们看看Web服务的启动,逻辑和之前设计的基本上没有什么区别,使用net/http来启动一个Web服务。

重点是启动的时候注意设置优雅关闭机制。先使用第六章实现的优雅关闭机制:开启一个Goroutine启动服务,主Goroutine监听信号,当获取到信号之后,等待所有请求都结束或者超过最长等待时长,就结束信号。当然,这里的最长等待时长可以设置为配置项,从app.close_wait配置项中获取,如果没有配置项,我们默认使用5s的最长等待时长。

启动相关代码:

// 启动AppServer, 这个函数会将当前goroutine阻塞
func startAppServe(server *http.Server, c framework.Container) error {
    // 这个goroutine是启动服务的goroutine
    go func() {
        server.ListenAndServe()
    }()

    // 当前的goroutine等待信号量
    quit := make(chan os.Signal)
    // 监控信号:SIGINT, SIGTERM, SIGQUIT
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
    // 这里会阻塞当前goroutine等待信号
    <-quit

    // 调用Server.Shutdown graceful结束
    closeWait := 5
    configService := c.MustMake(contract.ConfigKey).(contract.Config)
    if configService.IsExist("app.close_wait") {
        closeWait = configService.GetInt("app.close_wait")
    }
    timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(closeWait)*time.Second)
    defer cancel()

    if err := server.Shutdown(timeoutCtx); err != nil {
        return err
    }
    return nil
}

但是这里还出现了一个问题,挂在控制台的启动,比较简单,直接调用封装好的 startAppServe 就行了。但daemon方式如何启动呢?它是不能直接在主进程中调用startAppServe方法的,会把主进程给阻塞挂起来了,怎么办呢?

这个其实在第十四章定时任务中有说到,我们可以使用和定时任务一样的实现机制,使用开源库 go-daemon。比较重要,所以这里再啰嗦一下,理解go-daemon库的使用,要理解最核心的daemon.Context结构

在我们框架这个需求中,daemon方式启动命令为 ./hade app start --daemon=true 。所以在daemon.Context结构中的Args参数填写如下:

// 创建一个Context
cntxt := &daemon.Context{
   ...
   // 子进程的参数,按照这个参数设置,子进程的命令为 ./hade app start --daemon=true
   Args: []string{"", "app", "start", "--daemon=true"},
}
// 启动子进程,d不为空表示当前是父进程,d为空表示当前是子进程
d, err := cntxt.Reborn()

if d != nil {
   // 父进程直接打印启动成功信息,不做任何操作
   fmt.Println("app启动成功,pid:", d.Pid)
   fmt.Println("日志文件:", serverLogFile)
   return nil
}
...

有的同学对这个启动子进程的Reborn可能有些疑惑。

我们把Reborn理解成fork,当调用这个函数的时候,父进程会继续往下走,但是返回值d不为空,它的信息是子进程的进程号等信息。而子进程会重新运行对应的命令,再次进入到Reborn函数的时候,返回的d就为nil。所以在Reborn的后面,我们让父进程直接return,而让子进程继续往后进行操作,这样就达到了fork一个子进程的效果了

理解了这一点,对应的代码就很简单了:

// daemon 模式
if appDaemon {
    // 创建一个Context
    cntxt := &daemon.Context{
        // 设置pid文件
        PidFileName: serverPidFile,
        PidFilePerm: 0664,
        // 设置日志文件
        LogFileName: serverLogFile,
        LogFilePerm: 0640,
        // 设置工作路径
        WorkDir: currentFolder,
        // 设置所有设置文件的mask,默认为750
        Umask: 027,
        // 子进程的参数,按照这个参数设置,子进程的命令为 ./hade app start --daemon=true
        Args: []string{"", "app", "start", "--daemon=true"},
    }
    // 启动子进程,d不为空表示当前是父进程,d为空表示当前是子进程
    d, err := cntxt.Reborn()
    if err != nil {
        return err
    }
    if d != nil {
        // 父进程直接打印启动成功信息,不做任何操作
        fmt.Println("app启动成功,pid:", d.Pid)
        fmt.Println("日志文件:", serverLogFile)
        return nil
    }
    defer cntxt.Release()
    // 子进程执行真正的app启动操作
    fmt.Println("deamon started")
    gspt.SetProcTitle("hade app")
    if err := startAppServe(server, container); err != nil {
        fmt.Println(err)
    }
    return nil
}

到这里服务的进程启动成功,最后还有一点细节,对于启动的进程,我们一般都希望能自定义它的进程名称。

这里可以使用一个第三方库 gspt。它使用MIT协议,虽然star数不多,但是我个人亲测是功能齐全且有效的。在Golang中没有现成的设置进程名称的方法,只能调用C的设置进程名称的方法 setproctitle。所以这个库使用的方式是,使用cgo从Go中调用C的方法来实现进程名称的修改。

它的使用非常简单,就是一个函数SetProcTitle方法:

gspt.SetProcTitle("hade app")

现在,进程的启动就基本完成了。当然最后还有非常重要的关闭逻辑也记得加上。

好了,以上我们讨论了start的关键设计,再回头梳理一遍这个命令的实现步骤:

具体的实现步骤相信你已经很清楚了,完整代码我们写在 framework/command/app.go中了。

获取进程

已经完成了启动进程的命令,那么第二个获取进程PID的命令就非常简单了。因为启动命令的时候创建了一个PID文件,app/storage/runtime/app.pid,读取这个文件就可以获取到进程的PID信息了。

但是这里我们可以更谨慎一些加一步,获取到PID之后,去操作系统中查询这个PID的进程是否存在,存在的话,就确定这个PID是可行的。

如何根据PID查询一个进程是否存在呢?常用的比如Linux的ps和grep命令,基本上都是通过Linux的其他命令来检查输出,但最为可靠的方式是直接使用信号对接要查询的进程:通过给进程发送信号来检测,这个信号就是信号0

给进程发送信号0之后什么都不会操作,如果进程存在,不返回错误信息;如果进程不存在,会返回不存在进程的错误信息。在Golang中,我们可以用os库的Process结构来发送信号。

代码在 framework/util/exec.go 中,逻辑也很清晰,先用os.FindProcess来获取这个PID对应的进程,然后给进程发送signal 0, 如果返回nil,代表进程存在,否则进程不存在。

// CheckProcessExist 检查进程pid是否存在,如果存在的话,返回true
func CheckProcessExist(pid int) bool {
   // 查询这个pid
   process, err := os.FindProcess(pid)
   if err != nil {
      return false
   }

   // 给进程发送signal 0, 如果返回nil,代表进程存在, 否则进程不存在
   err = process.Signal(syscall.Signal(0))
   if err != nil {
      return false
   }
   return true
}

这个关键函数实现之后,其他的就很容易了。

这里我们也简单说一下进程获取的具体步骤:获取PID文件内容之后,做判断,如果有PID文件且有内容就继续,否则返回无进程;然后:

具体代码如下,存放在 framework/command/app.go文件中:

// 获取启动的app的pid
var appStateCommand = &cobra.Command{
   Use:   "state",
   Short: "获取启动的app的pid",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      appService := container.MustMake(contract.AppKey).(contract.App)

      // 获取pid
      serverPidFile := filepath.Join(appService.RuntimeFolder(), "app.pid")

      content, err := ioutil.ReadFile(serverPidFile)
      if err != nil {
         return err
      }

      if content != nil && len(content) > 0 {
         pid, err := strconv.Atoi(string(content))
         if err != nil {
            return err
         }
         if util.CheckProcessExist(pid) {
            fmt.Println("app服务已经启动, pid:", pid)
            return nil
         }
      }
      fmt.Println("没有app服务存在")
      return nil
   },
}

停止命令

命令的启动和获取完成了,就到了第三个停止命令了。既然有了进程号,需要停止一个进程,我们还是可以使用第六章说的信号量方法,回顾下当时说的四个关闭信号:

由于启动进程监听了SIGINT、SIGQUIT、SIGTERM 这三个信号,所以我们在这三个信号中选取一个发送给PID所在的进程即可,这里就选择更符合“关闭”语义的SIGTERM信号。

同样实现步骤也很清晰,获取PID文件内容之后,判断如果有PID文件且有内容再继续,否则什么都不做,之后就是:

对应代码同样在framework/command/app.go中:

// 停止一个已经启动的app服务
var appStopCommand = &cobra.Command{
   Use:   "stop",
   Short: "停止一个已经启动的app服务",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      appService := container.MustMake(contract.AppKey).(contract.App)

      // GetPid
      serverPidFile := filepath.Join(appService.RuntimeFolder(), "app.pid")

      content, err := ioutil.ReadFile(serverPidFile)
      if err != nil {
         return err
      }

      if content != nil && len(content) != 0 {
         pid, err := strconv.Atoi(string(content))
         if err != nil {
            return err
         }
         // 发送SIGTERM命令
         if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
            return err
         }
         if err := ioutil.WriteFile(serverPidFile, []byte{}, 0644); err != nil {
            return err
         }
         fmt.Println("停止进程:", pid)
      }
      return nil
   },
}

重启命令

最后我们要完成重启命令,还是在framework/command/app.go中。大致逻辑也很清晰,读取PID文件之后判断,如果PID文件中没有PID,说明没有进程在运行,直接启动新进程;如果PID文件中有PID,检查旧进程是否存在,如果不存在,直接启动新进程,如果存在,这里就有一些需要注意的了。

//获取pid
...

if content != nil && len(content) != 0 {
    // 解析pid是否存在
    if util.CheckProcessExist(pid) {
        // 关闭旧的pid进程
        ...
    }
}

appDaemon = true
// 启动新的进程
return appStartCommand.RunE(c, args)

因为重启的逻辑是先结束旧进程,再启动新进程。结束进程和停止命令一样,使用SIGTERM信号就能保证进程的优雅关闭了。但是由于新、旧进程都是使用同一个端口,所以必须保证旧进程结束,才能启动新的进程

而怎么保证旧进程确实结束了呢?

这里可以使用前面定义的 CheckProcessExist 方法,每秒做一次轮询,检测PID对应的进程是否已经关闭。那么轮询多少次呢?

我们知道在启动进程的时候,设置了一个优雅关闭的最大超时时间closeWait,这个closeWait的时间设置为秒。那么为了轮询检查旧进程是否关闭,我们只需要设置次数超过closeWait的轮询时间即可。考虑到net/http 在closeWait之后还有一些程序运行的逻辑,这里我们可以设置为2 * closeWait,时间是非常充裕的。关键代码如下:

// 确认进程已经关闭,每秒检测一次, 最多检测closeWait * 2秒
for i := 0; i < closeWait*2; i++ {
    if util.CheckProcessExist(pid) == false {
        break
    }
    time.Sleep(1 * time.Second)
}

再严谨一些,可以这么设置,如果在2*closeWait时间内,旧进程还未关闭,那么就不能启动新进程了,需要直接返回错误。所以,在 2 * closeWait 轮询之后,我们还需要再做一次检查,检查进程是否关闭,如果没有关闭的话,直接返回error:

// 确认进程已经关闭,每秒检测一次, 最多检测closeWait * 2秒
for i := 0; i < closeWait*2; i++ {
    if util.CheckProcessExist(pid) == false {
        break
    }
    time.Sleep(1 * time.Second)
}

// 如果进程等待了2*closeWait之后还没结束,返回错误,不进行后续的操作
if util.CheckProcessExist(pid) == true {
    fmt.Println("结束进程失败:"+strconv.Itoa(pid), "请查看原因")
    return errors.New("结束进程失败")
}

在确认旧进程结束后,记得把PID文件清空,再启动一个新进程。启动进程的逻辑还是比较复杂的,就不重复写了,我们直接调用appStartCommand的RunE方法来实现,会更优雅一些。

同其他命令一样,这里再梳理一下判断旧进程存在之后详细的实现步骤,如果存在:

在framework/command/app.go中,整体代码如下:

// 重新启动一个app服务
var appRestartCommand = &cobra.Command{
   Use:   "restart",
   Short: "重新启动一个app服务",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      appService := container.MustMake(contract.AppKey).(contract.App)

      // GetPid
      serverPidFile := filepath.Join(appService.RuntimeFolder(), "app.pid")

      content, err := ioutil.ReadFile(serverPidFile)
      if err != nil {
         return err
      }

      if content != nil && len(content) != 0 {
         pid, err := strconv.Atoi(string(content))
         if err != nil {
            return err
         }
         if util.CheckProcessExist(pid) {
            // 杀死进程
            if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
               return err
            }
            if err := ioutil.WriteFile(serverPidFile, []byte{}, 0644); err != nil {
               return err
            }

            // 获取closeWait
            closeWait := 5
            configService := container.MustMake(contract.ConfigKey).(contract.Config)
            if configService.IsExist("app.close_wait") {
               closeWait = configService.GetInt("app.close_wait")
            }

            // 确认进程已经关闭,每秒检测一次, 最多检测closeWait * 2秒
            for i := 0; i < closeWait*2; i++ {
               if util.CheckProcessExist(pid) == false {
                  break
               }
               time.Sleep(1 * time.Second)
            }

            // 如果进程等待了2*closeWait之后还没结束,返回错误,不进行后续的操作
            if util.CheckProcessExist(pid) == true {
               fmt.Println("结束进程失败:"+strconv.Itoa(pid), "请查看原因")
               return errors.New("结束进程失败")
            }

            fmt.Println("结束进程成功:" + strconv.Itoa(pid))
         }
      }

      appDaemon = true
      // 直接daemon方式启动apps
      return appStartCommand.RunE(c, args)
   },
}

测试

下面来测试一下。首先记得使用 ./hade build sef 命令编译,我们设置的默认服务启动地址为 “:8888”,这里就不用这个默认启动地址,使用环境变量ADDRESS=:8080 来启动服务。这样能测试到环境变量是否能生效。

调用命令 ADDRESS=:8080 ./hade app start --daemon=true 以daemon方式启动一个8080端口的服务:

使用浏览器打开 localhost:8080/demo/demo:

服务启动成功,且正常提供服务。

使用 ./hade app state 查看进程状态:

使用命令 ADDRESS=:8080 ./hade app restart 重新启动进程:

再次访问浏览器 localhost:8080/demo/demo,正常提供服务:

最后调用停止进程命令 ./hade app stop

到这里,对进程的启动、关闭、查询和重启的命令就验证完成了。

今天我们的所有代码都保存在GitHub上的geekbang/24分支了。只修改了framework/command/app.go 和 framework/util/exec.go文件,其他保持不变。

小结

今天我们完成了运行app相关的命令,包括app一级命令和四个二级命令,启动app服务、停止app服务、重启app服务、查询app服务。基本上已经把一个app服务启动的状态变更都包含了。有了这些命令,我们对app的控制就方便很多了。特别是daemon运行模式,为线上运行提供了不少方便。

在实现这四个命令的过程中,我们使用了不少第三方库,gspt、go-daemon,这些库的使用你要能熟练掌握,特别是go-daemon库,我们已经不止一次使用到它了。确认一个进程是否已经结束,我们使用每秒做一次轮询的 CheckProcessExist 方法实现了检查机制,并仔细考虑了轮训的次数和效果,你可以多多体会这么设计的好处。

思考题

我们在启动应用的时候,使用的地址格式为“:8080”,其实这里也可以为“localhost:8080”、“127.0.0.1:8080”或者“10.11.22.33:8080”(10.11.22.33为本机绑定的IP)。你了解localhost、127.0.0.1、10.11.22.33 以及不填写IP的区别么?

欢迎在留言区分享你的思考。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课见~