You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

679 lines
28 KiB
Markdown

2 years ago
# 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
<script>
export default {
name: 'HelloWorld',
data() {
return {
msg: 'Welcome to Your Vue.js App '
}
}
}
</script>
```
修改为:
```javascript
<script>
export default {
name: 'HelloWorld',
data() {
return {
msg: 'Welcome to Hade Vue.js App '
}
}
}
</script>
```
现在你可以看到,前端自动更新:
![](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的不知道你的经历是怎样的可以来聊一聊你在使用其他语言时调试一个程序都是怎么调试的呢有没有比较好的调试模式
欢迎在留言区分享你的思考。感谢你的收听,我们下节课见~