你好,我是轩脉刃。

上一节课,我们讨论了调试模式的整体设计思路和关键的技术难点-反向代理,最后定义了具体的命令设计,包括三个二级命令,能让我们调试前端/后端,或者同时调试。现在,大的框架都建立好了,但是其中的细节实现还没有讨论。成败在于细节,今天我们就撸起袖子开始实现它们。

配置项的设计

简单回顾一下调试模式的架构设计。所有外部请求进入反响代理服务后,会由反向代理服务进行分发,前端请求分发到前端进程,后端请求分发到后端进程。

在这个设计中,前端服务启动的时候占用哪个端口?后端服务启动的时候占用哪个端口?反向代理服务proxy启动的时候占用哪个端口呢?这些都属于配置项,需要在设计之初就规划好,所以我们先设计配置项的具体实现。

由于调试模式配置项比较多,在framework/command/dev.go 中,我们定义如下的配置结构devConfig来表示配置信息:

// devConfig 代表调试模式的配置信息
type devConfig struct {

   Port    string   // 调试模式最终监听的端口,默认为8070
   
   Backend struct { // 后端调试模式配置
      RefreshTime   int    // 调试模式后端更新时间,如果文件变更,等待3s才进行一次更新,能让频繁保存变更更为顺畅, 默认1s
      Port          string // 后端监听端口, 默认 8072
      MonitorFolder string // 监听文件夹,默认为AppFolder
   }
   
   Frontend struct { // 前端调试模式配置
      Port string // 前端启动端口, 默认8071
   }
}

这个结构可以说已经非常清晰了。结构根目录下的Port代表proxy的端口,而根目录下的Backend 和 Frontend 分别代表后端和前端的配置。

其中,前端只需要配置一个端口Port,而后端,我们除了配置端口Port之外,还另外多了两个配置,一个是监听的文件夹MonitorFolder,另外一个是监听文件夹的变更时间RefreshTime,这两个配置都是和后端监听文件夹相关的,具体如何使用,我们在后面写proxy的方法monitorBackend再详细说。

有了这个配置结构还不够,我们还要定义配置结构中每个值的赋值和默认值,在配置文件app.yaml中对应定义的配置字段如下:

dev: # 调试模式
  port: 8070 # 调试模式最终监听的端口,默认为8070
  backend: # 后端调试模式配置
    refresh_time: 3  # 调试模式后端更新时间,如果文件变更,等待3s才进行一次更新,能让频繁保存变更更为顺畅, 默认1s
    port: 8072 # 后端监听端口,默认8072
    monitor_folder: "" # 监听文件夹地址,为空或者不填默认为AppFolder
  frontend: # 前端调试模式配置
    port: 8071 # 前端监听端口, 默认8071

之后如果在配置文件中有配置这些字段,就使用配置文件中的字段,否则的话,则使用默认配置。对应到代码上,我们可以在framework/command/dev.go中实现一个initDevConfig。

实现思路也不难,参数只需要把服务容器传递进入就行了,在这个函数中,我们先定义好默认的配置,然后从容器中获取配置服务,通过配置服务,获取对应的配置文件的设置,如果配置文件有对应字段的话,就进行对应字段的配置。

// 初始化配置文件
func initDevConfig(c framework.Container) *devConfig {
    // 设置默认值
    devConfig := &devConfig{
        Port: "8087",
        Backend: struct {
            RefreshTime   int
            Port          string
            MonitorFolder string
        }{
            1,
            "8072",
            "",
        },
        Frontend: struct {
            Port string
        }{
            "8071",
        },
    }
    // 容器中获取配置服务
    configer := c.MustMake(contract.ConfigKey).(contract.Config)
    // 每个配置项进行检查
    if configer.IsExist("app.dev.port") {
        devConfig.Port = configer.GetString("app.dev.port")
    }
    if configer.IsExist("app.dev.backend.refresh_time") {
        devConfig.Backend.RefreshTime = configer.GetInt("app.dev.backend.refresh_time")
    }
    if configer.IsExist("app.dev.backend.port") {
        devConfig.Backend.Port = configer.GetString("app.dev.backend.port")
    }
    
    // monitorFolder 默认使用目录服务的AppFolder()
    monitorFolder := configer.GetString("app.dev.backend.monitor_folder")
    if monitorFolder == "" {
        appService := c.MustMake(contract.AppKey).(contract.App)
        devConfig.Backend.MonitorFolder = appService.AppFolder()
    }
    if configer.IsExist("app.dev.frontend.port") {
        devConfig.Frontend.Port = configer.GetString("app.dev.frontend.port")
    }
    return devConfig
}

这里着重说一下monitorFolder这个配置的逻辑,如果配置文件中有定义这个配置的话,我们就使用配置文件的配置,否则我们就去目录服务中获取AppFolder。其实这种有层次的配置方式,在配置服务那一节我们已经见过了,多使用这种配置方式能让框架可用性更高。

但是之前第12节课,定义目录服务接口的时候,没有定义App的服务接口,所以我们得去稍微修改下目录服务接口 framework/contract/app.go,为其增加AppFolder这个目录接口:

// App 定义接口
type App interface {
   ...

   // AppFolder 定义业务代码所在的目录,用于监控文件变更使用
   AppFolder() string
   ...
}

同时修改其对应实现 framework/provider/app/service.go,增加这个AppFolder的实现:

// AppFolder 代表app目录
func (app *HadeApp) AppFolder() string {
   if val, ok := app.configMap["app_folder"]; ok {
      return val
   }
   return filepath.Join(app.BaseFolder(), "app")
}

到这里,配置结构devConfig及配置结构初始化方法 initDevConfig,就实现完成了。

具体实现

现在,来完成拼图的最后一个部分,回到framework/command/dev.go中,上节课只定义了Proxy结构,但是Proxy结构中的字段,我们没有讨论。

首先有了上面定义的devConfig结构之后,Proxy的结构中,应该有一个字段保存这个Proxy的配置信息devConfig。

其次,在restart前端或者后端的时候,由于新进程和旧进程都使用一样的端口,我们一定是先关闭旧的前端进程或者后端进程,才能启动新的前端或者后端进程。所以这里要记录一下前后端进程的进程ID,设置了backendPid和 frontendPid来存储进程ID。

// Proxy 代表serve启动的服务器代理
type Proxy struct {
   devConfig   *devConfig // 配置文件
   backendPid  int        // 当前的backend服务的pid
   frontendPid int        // 当前的frontend服务的pid
}

下面我们就针对每个函数的具体实现一一说明,这里把上节课定义的各个函数简单再列一下,如果你对它们的功能有点模糊了,可以再回顾一下第19课。

// 初始化一个Proxy
func NewProxy(c framework.Container) *Proxy{}
// 重新启动一个proxy网关
func (p *Proxy) newProxyReverseProxy(frontend, backend *url.URL) *httputil.ReverseProxy{}
// 启动前端服务
func (p *Proxy) restartFrontend() error{}
// 启动后端服务
func (p *Proxy) restartBackend() error {}
// 编译后端服务
func (p *Proxy) rebuildBackend() error {}
// 启动proxy
func (p *Proxy) startProxy(startFrontend, startBackend bool) error{}
// 监控后端服务源码文件的变更
func (p *Proxy) monitorBackend() error{}

newProxyReverseProxy

首先是newProxyReverseProxy,它的核心逻辑就是创建ReverseProxy,设置Director、ModifyResponse、ErrorHandler三个字段。但是我们在细节上要做一些补充。

首先,既然已经在proxy中存了前后端的PID,那就可以知道当下前端服务或者后端服务是否已经启动了。如果只启动了前端服务,我们直接代理前端就好了;如果只启动后端服务,就直接代理后端。而只有两个服务都启动了,我们才进行上一节课说的:先请求后端服务,遇到404了,再请求前端服务

同时稍微修改一下director,对于前端一些固定的请求地址,比如 / 或者 /app.js,我们直接将这个地址固定请求前端。

// 重新启动一个proxy网关
func (p *Proxy) newProxyReverseProxy(frontend, backend *url.URL) *httputil.ReverseProxy {
   if p.frontendPid == 0 && p.backendPid == 0 {
      fmt.Println("前端和后端服务都不存在")
      return nil
   }

   // 后端服务存在
   if p.frontendPid == 0 && p.backendPid != 0 {
      return httputil.NewSingleHostReverseProxy(backend)
   }

   // 前端服务存在
   if p.backendPid == 0 && p.frontendPid != 0 {
      return httputil.NewSingleHostReverseProxy(frontend)
   }

   // 两个都有进程
   // 先创建一个后端服务的directory
   director := func(req *http.Request) {
      if req.URL.Path == "/" || req.URL.Path == "/app.js" {
         req.URL.Scheme = frontend.Scheme
         req.URL.Host = frontend.Host
      } else {
         req.URL.Scheme = backend.Scheme
         req.URL.Host = backend.Host
      }
   }

   // 定义一个NotFoundErr
   NotFoundErr := errors.New("response is 404, need to redirect")
   return &httputil.ReverseProxy{
      Director: director, // 先转发到后端服务
      ModifyResponse: func(response *http.Response) error {
         // 如果后端服务返回了404,我们返回NotFoundErr 会进入到errorHandler中
         if response.StatusCode == 404 {
            return NotFoundErr
         }
         return nil
      },
      ErrorHandler: func(writer http.ResponseWriter, request *http.Request, err error) {
         // 判断 Error 是否为NotFoundError, 是的话则进行前端服务的转发,重新修改writer
         if errors.Is(err, NotFoundErr) {
            httputil.NewSingleHostReverseProxy(frontend).ServeHTTP(writer, request)
         }
      }}
}

rebuildBackend / restartBackend

下一个函数是rebuildBackend。这个函数的作用是重新编译后端。

那如何编译后端呢?还记得第18课中为编译后端定义了命令行么?所以在“调试命令”中,我们只需要调用“编译命令”就行了。

所以rebuildBackend 这个函数,我们就是调用一次 ./hade build backend

// rebuildBackend 重新编译后端
func (p *Proxy) rebuildBackend() error {
   // 重新编译hade
   cmdBuild := exec.Command("./hade", "build", "backend")
   cmdBuild.Stdout = os.Stdout
   cmdBuild.Stderr = os.Stderr
   if err := cmdBuild.Start(); err == nil {
      err = cmdBuild.Wait()
      if err != nil {
         return err
      }
   }
   return nil
}

编译后端函数实现了,下面就是重启后端进程restartBackend。

我们当然也会记得在第12章将启动Web服务变成一个命令 ./hade app start 。所以重启后端服务的步骤就是:

但是这里有个小问题,之前启动进程的时候,进程端口是写死的。但是,现在需要固定启动的App的进程端口。所以要对 ./hade app start 命令进行一些改造。

来修改framework/command/app.go,我们增加一个appAddress地址,这个地址可以传递类似 localhost:8888 或者 :8888 这样的启动服务地址,并且在appStartCommand中使用这个appAddress。

// app启动地址
var appAddress = ""

// initAppCommand 初始化app命令和其子命令
func initAppCommand() *cobra.Command {
   // 设置启动地址
   appStartCommand.Flags().StringVar(&appAddress, "address", ":8888", "设置app启动的地址,默认为:8888")

   appCommand.AddCommand(appStartCommand)
   return appCommand
}

// appStartCommand 启动一个Web服务
var appStartCommand = &cobra.Command{
   Use:   "start",
   Short: "启动一个Web服务",
   RunE: func(c *cobra.Command, args []string) error {
      ...
      // 创建一个Server服务
      server := &http.Server{
         Handler: core,
         Addr:    appAddress,
      }
      // 这个goroutine是启动服务的goroutine
      go func() {
         server.ListenAndServe()
      }()
      ...
   },
}

这样,后端进程就可以通过命令 ./hade app start --address=:8888 这样的方式,来指定端口启动服务了。

小问题解决之后,回到framework/command/dev.go, 我们实现restartBackend方法。先杀死旧的进程,再通过命令 ./hade app start 带上参数 address,启动新的后端服务。启动之后,再将启动的进程ID存储到proxy结构的backendPid字段中:

// restartBackend 启动后端服务
func (p *Proxy) restartBackend() error {

   // 杀死之前的进程
   if p.backendPid != 0 {
      syscall.Kill(p.backendPid, syscall.SIGKILL)
      p.backendPid = 0
   }

   // 设置随机端口,真实后端的端口
   port := p.devConfig.Backend.Port
   hadeAddress := fmt.Sprintf(":" + port)
   // 使用命令行启动后端进程
   cmd := exec.Command("./hade", "app", "start", "--address="+hadeAddress)
   cmd.Stdout = os.NewFile(0, os.DevNull)
   cmd.Stderr = os.Stderr
   fmt.Println("启动后端服务: ", "http://127.0.0.1:"+port)
   err := cmd.Start()
   if err != nil {
      fmt.Println(err)
   }
   p.backendPid = cmd.Process.Pid
   fmt.Println("后端服务pid:", p.backendPid)
   return nil
}

restartFrontend

而重启前端服务的函数restartFrontend也是一样的逻辑,先关闭旧的前端进程,然后启动新的前端进程。这里同样也有一个问题,启动前端进程的命令是 npm run dev ,我们怎么固定其端口呢?

在Vue中,我们可以通过设置环境变量PORT,来规定前端进程的启动端口。也就是让启动命令变为 PORT=8071 npm run dev ,在Golang中启动一个命令,并为命令设置环境变量是这样设置的:

// 运行命令
cmd := exec.Command("npm", "run", "dev")
// 为默认的环境变量增加PORT=xxx的变量
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, fmt.Sprintf("%s%s", "PORT=", port))

所以启动前端服务的逻辑就如下,很简单,重点位置你可以看注释。

// 启动前端服务
func (p *Proxy) restartFrontend() error {
   // 启动前端调试模式
   // 先杀死旧进程
   if p.frontendPid != 0 {
      syscall.Kill(p.frontendPid, syscall.SIGKILL)
      p.frontendPid = 0
   }
   // 否则开启npm run serve
   port := p.devConfig.Frontend.Port
   path, err := exec.LookPath("npm")
   if err != nil {
      return err
   }
   cmd := exec.Command(path, "run", "dev")
   cmd.Env = os.Environ()
   cmd.Env = append(cmd.Env, fmt.Sprintf("%s%s", "PORT=", port))
   cmd.Stdout = os.NewFile(0, os.DevNull)
   cmd.Stderr = os.Stderr
   // 因为npm run serve 是控制台挂起模式,所以这里使用go routine启动
   err = cmd.Start()
   fmt.Println("启动前端服务: ", "http://127.0.0.1:"+port)
   if err != nil {
      fmt.Println(err)
   }
   p.frontendPid = cmd.Process.Pid
   fmt.Println("前端服务pid:", p.frontendPid)
   return nil
}

startProxy

下面我们来实现startProxy方法,它有两个参数,表示在启动Proxy时是否要启动前端、后端服务。

这个方法的逻辑也并不复杂,步骤有四步,先根据参数判断是否启动后端服务,根据参数判断是否启动前端服务,然后使用newProxyReverseProxy来创建新的ReverseProxy,最后启动Proxy服务。在代码中也做了步骤说明了:

// 启动proxy服务,并且根据参数启动前端服务或者后端服务
func (p *Proxy) startProxy(startFrontend, startBackend bool) error {
   var backendURL, frontendURL *url.URL
   var err error

   // 启动后端
   if startBackend {
      if err := p.restartBackend(); err != nil {
         return err
      }
   }
   // 启动前端
   if startFrontend {
      if err := p.restartFrontend(); err != nil {
         return err
      }
   }

   if frontendURL, err = url.Parse(fmt.Sprintf("%s%s", "http://127.0.0.1:", p.devConfig.Frontend.Port)); err != nil {
      return err
   }

   if backendURL, err = url.Parse(fmt.Sprintf("%s%s", "http://127.0.0.1:", p.devConfig.Backend.Port)); err != nil {
      return err
   }

   // 设置反向代理
   proxyReverse := p.newProxyReverseProxy(frontendURL, backendURL)
   proxyServer := &http.Server{
      Addr:    "127.0.0.1:" + p.devConfig.Port,
      Handler: proxyReverse,
   }

   fmt.Println("代理服务启动:", "http://"+proxyServer.Addr)
   // 启动proxy服务
   err = proxyServer.ListenAndServe()
   if err != nil {
      fmt.Println(err)
   }
   return nil
}

monitorBackend

最后是一个monitorBackend方法,监控某个文件夹的变动,并且重新编译并且运行后端服务。

这个方法我们重点说一下,有些逻辑还是比较绕的。

首先,在前一节课说过了,可以使用 fsnotify 库对目录进行监控。那么对哪个目录进行监控呢?之前在配置devConfig中,定义了一个Backend.MonitorFolder目录,这个配置默认使用的是AppFolder目录。这个就是我们监控的目标目录。

其次,每次有变化的时候,都要进行一次编译后端服务、杀死旧进程、重启新进程么?

在开发过程中我们知道,每次调整一个逻辑的时候,是有可能短时间内重复修改、保存多个文件的,或者保存一个文件多次。而重新编译、重新启动进程的过程,又是有一定耗时的,如果每改一次就重来一次,可以想象这个体验是很差的。

能怎么优化这种体验呢?我们可以使用一种计时时间机制。

这个机制的逻辑就是,每次有文件变动,并不立刻进行实质的操作,而是开启一个计时时间,如果这个时间内,没有任何后续的文件变动了,那么在计时时间到了之后,我们再进行实质的操作。而如果在计时时间内,有任何更新的文件变动,我们就将计时时间机制重新开始计时。

这种机制能有一定概率保证,在“更新代码等待一段时间后”进行后端的重启服务。而这里的计时时间我们也变成一个配置,devConfig里面的Backend.RefreshTime,默认时长为1s。

对应在framework/command/dev.go的monitorBackend代码实现中,我们大致分为这么几步,先创建watcher,监听目标目录,有变动的时候开启计时时间机制,循环监听

这里在监听目标目录的时候,我们需要监听AppFolder目录下的所有子目录及孙目录,所以这里需要用到递归 filepath.Walk ,来递归一遍所有子目录及孙目录。如果是目录,就使用watcher.Add 来将目录加入到监控列表中。

具体的代码逻辑可以看framework/command/dev.go中的monitorBackend:

// monitorBackend 监听应用文件
func (p *Proxy) monitorBackend() error {
   // 监听
   watcher, err := fsnotify.NewWatcher()
   if err != nil {
      return err
   }
   defer watcher.Close()

   // 开启监听目标文件夹
   appFolder := p.devConfig.Backend.MonitorFolder
   fmt.Println("监控文件夹:", appFolder)
   // 监听所有子目录,需要使用filepath.walk
   filepath.Walk(appFolder, func(path string, info os.FileInfo, err error) error {
      if info != nil && !info.IsDir() {
         return nil
      }
      // 如果是隐藏的目录比如 . 或者 .. 则不用进行监控
      if util.IsHiddenDirectory(path) {
         return nil
      }
      return watcher.Add(path)
   })

   // 开启计时时间机制
   refreshTime := p.devConfig.Backend.RefreshTime
   t := time.NewTimer(time.Duration(refreshTime) * time.Second)
   // 先停止计时器
   t.Stop()
   for {
      select {
      case <-t.C:
         // 计时器时间到了,代表之前有文件更新事件重置过计时器
         // 即有文件更新
         fmt.Println("...检测到文件更新,重启服务开始...")
         if err := p.rebuildBackend(); err != nil {
            fmt.Println("重新编译失败:", err.Error())
         } else {
            if err := p.restartBackend(); err != nil {
               fmt.Println("重新启动失败:", err.Error())
            }
         }
         fmt.Println("...检测到文件更新,重启服务结束...")
         // 停止计时器
         t.Stop()
      case _, ok := <-watcher.Events:
         if !ok {
            continue
         }
         // 有文件更新事件,重置计时器
         t.Reset(time.Duration(refreshTime) * time.Second)
      case err, ok := <-watcher.Errors:
         if !ok {
            continue
         }
         // 如果有文件监听错误,则停止计时器
         fmt.Println("监听文件夹错误:", err.Error())
         t.Reset(time.Duration(refreshTime) * time.Second)
      }
   }
}

验证

到这里Proxy相关的逻辑和调试对应的命令行工具都开发完成了,下面我们来做一下对应的验证,一共三次验证,单独的前端、后端修改,以及同时对前后端的修改。

先修改一下config/development/app.yaml,增加对应的调试模式配置:

dev: # 调试模式
  port: 8070 # 调试模式最终监听的端口,默认为8070
  backend: # 后端调试模式配置
    refresh_time: 3  # 调试模式后端更新时间,如果文件变更,等待3s才进行一次更新,能让频繁保存变更更为顺畅, 默认1s
    port: 8072 # 后端监听端口,默认8072
    monitor_folder: "" # 监听文件夹地址,为空或者不填默认为AppFolder
  frontend: # 前端调试模式配置
    port: 8071 # 前端监听端口, 默认8071

这里设置refresh_time为3s,代表后续后端变更后3s后会触发重新编译。对我们的代码进行一次编译,不用go build了,可以使用自定义的build命令了。

前端验证

首先验证前端调试模式。调用命令 ./hade dev front,可以看到如下的控制台信息:

先是出现几行信息:

启动前端服务:  http://127.0.0.1:8071
前端服务pid: 13750
代理服务启动: http://127.0.0.1:8070

然后进入到了Vue的调试模式,从上述信息我们知道,代理服务启动在8070端口,使用浏览器打开 http://127.0.0.1:8070 看到了熟悉的Vue界面。

然后修改首页的前端组件,业务目录下src/components/HelloWorld.vue,将其展示在首页的msg内容:

<script>
export default {
  name: 'HelloWorld',
  data() {
    return {
      msg: 'Welcome to Your Vue.js App '
    }
  }
}
</script>

修改为:

<script>
export default {
    name: 'HelloWorld',
    data() {
        return {
            msg: 'Welcome to Hade Vue.js App '
        }
    }
}
</script>

现在你可以看到,前端自动更新:

前端验证完成。下面验证后端调试模式。

后端验证

我们已经在业务代码app/http/module/demo/api.go中,定义了/demo/demo的路由,并且简单输出文字"this is demo"。

func Register(r *gin.Engine) error {
   api := NewDemoApi()
   ...
   r.GET("/demo/demo", api.Demo)
   ...
   return nil
}

func (api *DemoApi) Demo(c *gin.Context) {
   c.JSON(200, "this is demo")
}

使用命令 ./hade dev backend ,有如下输出,可以看到输出中已经把监控文件夹、后端服务端口、代理服务端口完整输出了:

访问代理服务 http://127.0.0.1:8087/demo/demo

输出了后端接口内容。

同时在代码中修改下输出内容之后:

func (api *DemoApi) Demo(c *gin.Context) {
   c.JSON(200, "this is demo for dev")
}

在控制台中我们可以看到,等待了3s后(这里配置文件设置为3s),在控制台看到如下输出:

检测到文件更新,重启服务开启。

这个时候我们再刷新浏览器的接口,输出已经变化了。

后端调试模式通过!

前后端验证

最后同时验证前端和后端,其实和前面单独验证的方法一样,只是启动命令换成了 ./hade dev all

这里我们同时打开两个窗口,http://127.0.0.1:8070/demo/demohttp://127.0.0.1:8070/#/,能同时看到前端和后端信息:

修改前端msg和修改后端内容后,变更生效:

到这里,前后端同时调试模式验证成功!

今天的主要内容是创建调试模式的三个二级命令。完整的代码示例在GitHub上的 geekbang/20 分支,欢迎比对查看。本节课我们只在命令文件中增加了一个framework/command/dev.go文件:

小结

今天我们具体实现了调试模式,其实了解了上节课对调试模式的设计之后,今天的内容主要是细节上的代码实现了,就是工作量。不过其中的实现细节,也是在工作中不断积累下来的,你可以多多体会。

比如refresh_time这个计时器窗口设计,在最初版本是没有的,在实际工作中,使用这个调试模式,遇到了频繁重建的困扰,才做了这个设计。总之,整个调试模式支持是非常赞的,它能让我们的Web开发效率提高了一个档次,希望你也能感同身受。

思考题

在回答同学们问题的时候,我发现有不少是其他语言转来Go的,不知道你的经历是怎样的,可以来聊一聊你在使用其他语言时,调试一个程序都是怎么调试的呢?有没有比较好的调试模式?

欢迎在留言区分享你的思考。感谢你的收听,我们下节课见~