# 20|提效(下):实现调试模式加速开发效率 你好,我是轩脉刃。 上一节课,我们讨论了调试模式的整体设计思路和关键的技术难点-反向代理,最后定义了具体的命令设计,包括三个二级命令,能让我们调试前端/后端,或者同时调试。现在,大的框架都建立好了,但是其中的细节实现还没有讨论。成败在于细节,今天我们就撸起袖子开始实现它们。 ## 配置项的设计 简单回顾一下调试模式的架构设计。所有外部请求进入反响代理服务后,会由反向代理服务进行分发,前端请求分发到前端进程,后端请求分发到后端进程。 ![](https://static001.geekbang.org/resource/image/86/16/86d2c8a583a1dafa52ee79fb95f30616.jpg?wh=1920x1080) 在这个设计中,前端服务启动的时候占用哪个端口?后端服务启动的时候占用哪个端口?反向代理服务proxy启动的时候占用哪个端口呢?这些都属于配置项,需要在设计之初就规划好,所以我们先设计配置项的具体实现。 由于调试模式配置项比较多,在framework/command/dev.go 中,我们定义如下的配置结构devConfig来表示配置信息: ```go // 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中对应定义的配置字段如下: ```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。 实现思路也不难,参数只需要把服务容器传递进入就行了,在这个函数中,我们先定义好默认的配置,然后从容器中获取配置服务,通过配置服务,获取对应的配置文件的设置,如果配置文件有对应字段的话,就进行对应字段的配置。 ```go // 初始化配置文件 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节课](https://time.geekbang.org/column/article/425820),定义目录服务接口的时候,没有定义App的服务接口,所以我们得去稍微修改下目录服务接口 framework/contract/app.go,为其增加AppFolder这个目录接口: ```go // App 定义接口 type App interface { ... // AppFolder 定义业务代码所在的目录,用于监控文件变更使用 AppFolder() string ... } ``` 同时修改其对应实现 framework/provider/app/service.go,增加这个AppFolder的实现: ```go // 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。 ```go // Proxy 代表serve启动的服务器代理 type Proxy struct { devConfig *devConfig // 配置文件 backendPid int // 当前的backend服务的pid frontendPid int // 当前的frontend服务的pid } ``` 下面我们就针对每个函数的具体实现一一说明,这里把上节课定义的各个函数简单再列一下,如果你对它们的功能有点模糊了,可以再回顾一下第19课。 ```go // 初始化一个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,我们直接将这个地址固定请求前端。 ```go // 重新启动一个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课中为编译后端定义了命令行么?所以在“调试命令”中,我们只需要调用“编译命令”就行了。 * 编译前端 ./hade build frontend * 编译后端 ./hade build backend * 同时编译前后端 ./hade build all * 自编译 ./hade build self 所以rebuildBackend 这个函数,我们就是调用一次 `./hade build backend` 。 ```go // 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章](https://time.geekbang.org/column/article/425820)将启动Web服务变成一个命令 `./hade app start` 。所以重启后端服务的步骤就是: * 关闭旧进程(kill) * 启动新进程(./hade app start) 但是这里有个小问题,**之前启动进程的时候,进程端口是写死的。但是,现在需要固定启动的App的进程端口**。所以要对 `./hade app start` 命令进行一些改造。 来修改framework/command/app.go,我们增加一个appAddress地址,这个地址可以传递类似 `localhost:8888` 或者 `:8888` 这样的启动服务地址,并且在appStartCommand中使用这个appAddress。 ```go // 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字段中: ```go // 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](https://stackoverflow.com/questions/47219819/how-to-change-port-number-in-vue-cli-project),来规定前端进程的启动端口。也就是让启动命令变为 `PORT=8071 npm run dev` ,在Golang中启动一个命令,并为命令设置环境变量是这样设置的: ```go // 运行命令 cmd := exec.Command("npm", "run", "dev") // 为默认的环境变量增加PORT=xxx的变量 cmd.Env = os.Environ() cmd.Env = append(cmd.Env, fmt.Sprintf("%s%s", "PORT=", port)) ``` 所以启动前端服务的逻辑就如下,很简单,重点位置你可以看注释。 ```go // 启动前端服务 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服务。在代码中也做了步骤说明了: ```go // 启动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](https://github.com/fsnotify/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: ```go // 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,增加对应的调试模式配置: ```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命令了。 ![](https://static001.geekbang.org/resource/image/bc/dc/bccyy36fb507fc398b4ac69d6fab12dc.png?wh=744x53) ### 前端验证 首先验证前端调试模式。调用命令 `./hade dev front`,可以看到如下的控制台信息: ![](https://static001.geekbang.org/resource/image/69/3f/6932b5eacdd96f46bd7c431388a5663f.png?wh=1837x388) 先是出现几行信息: ```go 启动前端服务:  http://127.0.0.1:8071 前端服务pid: 13750 代理服务启动: http://127.0.0.1:8070 ``` 然后进入到了Vue的调试模式,从上述信息我们知道,代理服务启动在8070端口,使用浏览器打开 [http://127.0.0.1:8070](http://127.0.0.1:8070) 看到了熟悉的Vue界面。 ![](https://static001.geekbang.org/resource/image/51/cd/51bbd33f01b482fe38543e09fcf6a8cd.png?wh=821x675) 然后修改首页的前端组件,业务目录下src/components/HelloWorld.vue,将其展示在首页的msg内容: ```javascript ``` 修改为: ```javascript ``` 现在你可以看到,前端自动更新: ![](https://static001.geekbang.org/resource/image/54/3b/54b76e64904e2c38b011e0625317cf3b.png?wh=823x700) 前端验证完成。下面验证后端调试模式。 ### 后端验证 我们已经在业务代码app/http/module/demo/api.go中,定义了/demo/demo的路由,并且简单输出文字"this is demo"。 ```go 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` ,有如下输出,可以看到输出中已经把监控文件夹、后端服务端口、代理服务端口完整输出了: ![](https://static001.geekbang.org/resource/image/a1/71/a140ce0ef5yya414bb7c51879966a871.png?wh=756x126) 访问代理服务 [http://127.0.0.1:8087/demo/demo](http://127.0.0.1:8087/demo/demo): ![](https://static001.geekbang.org/resource/image/9c/35/9cfabbc9617ea7d382ef7a7656bbyy35.png?wh=819x223) 输出了后端接口内容。 同时在代码中修改下输出内容之后: ```go func (api *DemoApi) Demo(c *gin.Context) { c.JSON(200, "this is demo for dev") } ``` 在控制台中我们可以看到,等待了3s后(这里配置文件设置为3s),在控制台看到如下输出: ![](https://static001.geekbang.org/resource/image/a9/77/a9f482cf449bdd63ab10b9589552f677.png?wh=766x251) 检测到文件更新,重启服务开启。 这个时候我们再刷新浏览器的接口,输出已经变化了。 ![](https://static001.geekbang.org/resource/image/8d/cd/8d2e45d4e396cda0772855466c864bcd.png?wh=826x257) 后端调试模式通过! ### 前后端验证 最后同时验证前端和后端,其实和前面单独验证的方法一样,只是启动命令换成了 `./hade dev all` 这里我们同时打开两个窗口,[http://127.0.0.1:8070/demo/demo](http://127.0.0.1:8070/demo/demo)、[http://127.0.0.1:8070/#/](http://127.0.0.1:8070/#/),能同时看到前端和后端信息: ![](https://static001.geekbang.org/resource/image/94/8b/948ca235cb9aaafafdfd019ec49c6e8b.png?wh=822x648)![](https://static001.geekbang.org/resource/image/24/a5/244a3f45c561081d2836da782c9d3ba5.png?wh=824x258) 修改前端msg和修改后端内容后,变更生效: ![](https://static001.geekbang.org/resource/image/b8/4b/b8322b3854a955ddab449324afb8574b.png?wh=822x686)![](https://static001.geekbang.org/resource/image/3e/8a/3eba5c41cdafd870878609274f05658a.png?wh=825x204) 到这里,前后端同时调试模式验证成功! 今天的主要内容是创建调试模式的三个二级命令。完整的代码示例在GitHub上的 [geekbang/20](https://github.com/gohade/coredemo/tree/geekbang/20) 分支,欢迎比对查看。本节课我们只在命令文件中增加了一个framework/command/dev.go文件: ![](https://static001.geekbang.org/resource/image/e7/y3/e7yy0d64af957beed7139733ac2fdyy3.png?wh=296x811) ## 小结 今天我们具体实现了调试模式,其实了解了上节课对调试模式的设计之后,今天的内容主要是细节上的代码实现了,就是工作量。不过其中的实现细节,也是在工作中不断积累下来的,你可以多多体会。 比如refresh\_time这个计时器窗口设计,在最初版本是没有的,在实际工作中,使用这个调试模式,遇到了频繁重建的困扰,才做了这个设计。总之,整个调试模式支持是非常赞的,它能让我们的Web开发效率提高了一个档次,希望你也能感同身受。 ## 思考题 在回答同学们问题的时候,我发现有不少是其他语言转来Go的,不知道你的经历是怎样的,可以来聊一聊你在使用其他语言时,调试一个程序都是怎么调试的呢?有没有比较好的调试模式? 欢迎在留言区分享你的思考。感谢你的收听,我们下节课见~