你好,我是轩脉刃。
在第13章我们引入命令行的时候,将Web启动方式改成了一个命令行。但是当时只完成了一个最简单的启动Web服务的命令,这节课,我们要做的是完善这个Web服务运行命令,让Web服务的运行有完整的启动、停止、重启、查询的进程管理功能。
这套完整的进程管理功能,能让应用管理者非常方便地通过一套命令来统一管控一个应用,降低应用管理者的管理成本,后续也能为实现应用自动化部署到远端服务的工具提供了基础。下面我们来具体看下如何设计这套命令并且实现它吧。
首先照惯例需要设计一下运行命令,一级命令为 app,二级命令设计如下:
./hade app start
二级命令,启动一个app服务./hade app state
二级命令,获取启动的app的信息./hade app stop
二级命令,停止已经启动的app服务./hade app restart
二级命令,重新启动一个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的区别么?
欢迎在留言区分享你的思考。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课见~