# 19|提效(上):实现调试模式加速开发效率 你好,我是轩脉刃。 上一节课我们把前端Vue融合进hade框架中,让框架能直接通过命令行启动包含前端和后端的一个应用,今天继续思考优化。 在使用Vue的时候,你一定使用过 `npm run dev` 这个命令,为前端开启调试模式,在这个模式下,只要你修改了src下的文件,编译器就会自动重新编译,并且更新浏览器页面上的渲染。这种调试模式,为开发者提高了不少开发效率。 那这种调试模式能否应用到Golang后端,让前后端都开启这种调试模式,来进一步提升我们开发应用的效率呢?接下来两节课,我们就来尝试实现这种调试模式。 ## 方案思考和设计 先来思考下调试模式应该怎么设计?因为分为前端和后端,关于Vue前端,既然已经有了 `npm run dev` 这种调试模式,自然可以直接使用这种方式,要改的主要就是后端。 对于后端Golang代码,Golang本身并没有提供任何调试模式的方式进行代码调试,**只能先通过go build 编译出二进制文件,通过运行二进制文件再启动服务**。那我们如何实现刚才的想法,一旦修改代码源文件,就能重新编译运行呢? 相信你一定很快想到了之前实现过配置文件的热更新。在第16章开发配置服务的时候,我们使用了 [fsnotify](https://github.com/fsnotify/fsnotify) 库,来对配置目录下的所有文件进行变更监听,一旦修改了配置目录下的文件,就重新更新内存中的配置文件map。 那这里是否可以如法炮制,将 AppPath 目录下的文件也进行监听呢?一旦这个目录下的文件有了变更,就重新编译运行后端服务? 是的,原理可行,我们完全可以按照这种想法来构想一下。现在假设我们**监听了后端文件,能变更调试后端服务,也能通过Vue自带命令调试前端**,但这里又遇到难点了,如果需要前后端服务同时调试呢? 前端启动调试模式的方式和我们之前的编译方式完全不一样,它是直接启动一个端口来服务,并没有在dist下生成最终编译文件。这样,我们上一章设计的后端直接代理最终编译文件的方法就无法使用了。怎么办? 虽然过程不一样,但启动后的行为是差不多的。后端,实现了监听文件重新编译启动后,也是启动了一个进程来提供服务。思考到这里,自然而然,我们就想到**是否能在前端和后端服务的前面,设计一个反向代理proxy服务呢**? ![图片](https://static001.geekbang.org/resource/image/86/16/86d2c8a583a1dafa52ee79fb95f30616.jpg?wh=1920x1080) 让所有外部请求进入这个反响代理服务,然后由反向代理服务进行代理分发,前端请求分发到前端进程,后端请求分发到后端进程。 方案思路很流畅,我们来看如何实现。 ## 实现技术难点分析 先攻坚最关键的技术难点,如何实现反向代理。 所谓反向代理,就是能将一个请求按照条件分发到不同的服务中去。在Golang中的net/http/httputil 包中提供了ReverseProxy 这么一个数据结构,它是实现整个反向代理的关键。 我们使用命令 `go doc net/http/httputil.ReverseProxy` 看下这个数据结构的定义,每个字段的说明我详细写在代码注释里面了: ```go // 反向代理 type ReverseProxy struct { // Director这个函数传入的参数是新复制的一个请求,我们可以修改这个请求 // 比如修改请求的请求Host或者请求URL等 Director func(*http.Request) // Transport 代表底层的连接池设置,比如连接最长保持多久等 // 如果不填的话,则使用默认的设置 Transport http.RoundTripper // FlushInterval表示多久将下游的response的数据拷贝到proxy的response FlushInterval time.Duration // ErrorLog 表示错误日志打印的句柄 ErrorLog *log.Logger // BufferPool表示将下游response拷贝到proxy的response的时候使用的缓冲池大小 BufferPool BufferPool // ModifyResponse 函数表示,如果要将下游的response内容进行修改,再传递给proxy // 的response,这个函数就可以进行设置,但是如果这个函数返回了error,则将response // 传递进入ErrorHandler,否则使用默认设置 ModifyResponse func(*http.Response) error // ErrorHandler 处理ModifyResponse返回的Error ErrorHandler func(http.ResponseWriter, *http.Request, error) } ``` 这里我着重解释一下这次会使用到的三个字段 Director、ModifyResponse、ErrorHandler,Director是必须填写的,而ModifyResponse、ErrorHandler是可选的。 **Director的参数是请求,表示如何对请求进行转发**。最简单的,我们可以修改请求的目标Host,将请求转发到后端的服务。具体如何使用,可以看net/http/httputil库带的NewSingleHostReverseProxy方法,它将请求转发给后端target地址的时候,直接将request的scheme、host、path 都进行了替换。这个方法也是后面我们经常要用到的。 ```go // 将原先的请求转发到target地址 func NewSingleHostReverseProxy(target *url.URL) *ReverseProxy { targetQuery := target.RawQuery // 设置director director := func(req *http.Request) { // 将原先的request替换scheme, host,path。 req.URL.Scheme = target.Scheme req.URL.Host = target.Host req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL) ... } return &ReverseProxy{Director: director} } ``` 其次是**ModifyResponse字段**,在下游response要拷贝给上游proxy的response的时候,会使用到它代表的函数,如果我们要对下游的返回数据进行修改,就可以设置这个字段。 ```go ModifyResponse func(*http.Response) error ``` 这个字段的参数就只有一个,http.Response指针,代表的是下游返回给上游的返回结构,我们可以对这个指针的内容进行操作。而返回值error,代表操作的结果,如果在操作过程中出现错误,会返回error。 返回的error,就会进入**第三个字段函数 ErrorHandler**中。 ```go ErrorHandler func(http.ResponseWriter, *http.Request, error) ``` ErrorHandler有三个参数,responseWriter是新proxy的reponse的写句柄,request 是Director修改后给下游的request,而error则是ModifyResponse处理后的error。 了解清楚这三个字段函数中每个参数和返回值是非常重要的,这样才能准确地使用这些字段。下面我们就活学活用这个ReverseProxy。 **使用ReverseProxy作为反向代理,那么对应的路由规则是什么样的呢?什么样的请求进入后端,什么样的请求进入前端呢**?这里我们需要再思考下。 还记得么,在上一节课增加前端代码Vue进入hade框架中的时候,我们使用了一个中间件static,来将请求按照规则进行分发:如果请求地址在dist目录中存在,返回对应的请求文件,而如果请求地址在dist目录中不存在,就什么都不做,进行后续的路由规则判定。 但是在调试模式下,并没有前端编译环境,那我们怎么判断这个请求是进入前端,还是进入后端呢?这里是一个比较难的点。 可以反过来做。**一个请求到了,直接先请求一下后端服务,如果后端发现请求不存在,返回404 Not Found之后, 我们再将请求再请求到前端服务**,就可以完美解决这个问题。这里用到刚才学习的 ReverseProxy 结构里面的 Director。 在Director中,将请求设置为转发给后端服务。这样当后端服务查找到路由不存在,返回404的时候,我们是能在 ModifyResponse 中获取到后端返回的StatusCode的。之后再判断如果为404,让 ModifyResponse 返回一个自定义的 NotFoundErr。 一旦ModifyResponse返回了Error,就会进入到 ErrorHandler 函数中,在这个函数中,我们判断一下参数中的error是否是之前定义的NotFoundErr,如果是的话,就再用NewSingleHostReverseProxy来创建一个前端的Proxy,将这个请求代理到前端服务中。 把这段实现的网关服务逻辑翻译成代码,在framework/command/dev.go中: ```go // 重新启动一个proxy网关 func (p *Proxy) newProxyReverseProxy(frontend, backend *url.URL) *httputil.ReverseProxy { ... // 先创建一个后端服务的directory director := func(req *http.Request) { 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) } }} } ``` ## command 设计 思考清楚了技术难点,我们就可以开始设计命令了。这里为我们的框架重新定义一个dev一级命令,这个命令专门是调试模式,没有什么实际的作用,只是显示帮助信息。而它下面有三个二级命令:dev frontend 调试前端、dev backend 调试后端、dev all 前后端同时调试。 ```go ./hade dev //显示帮助信息 ./hade dev frontend // 调试前端 ./hade dev backend // 调试后端 ./hade dev all // 显示所有 ``` 在定义工具命令的时候,如果遇到有前端和后端的,我们应该统一在命令中使用关键字 frontend 和 backend 分别代表前后端,这样可以给使用者不断加深强调这两个关键字,这样我们在使用命令的时候,就能很快反应出前后端对应的命令了。 创建一个 framework/command/dev.go 来存放这个调试命令: ```go // 初始化Dev命令 func initDevCommand() *cobra.Command { devCommand.AddCommand(devBackendCommand) devCommand.AddCommand(devFrontendCommand) devCommand.AddCommand(devAllCommand) return devCommand } // devCommand 为调试模式的一级命令 var devCommand = &cobra.Command{ Use: "dev", Short: "调试模式", RunE: func(c *cobra.Command, args []string) error { c.Help() return nil }, } // devBackendCommand 启动后端调试模式 var devBackendCommand = &cobra.Command{ Use: "backend", Short: "启动后端调试模式", RunE: func(c *cobra.Command, args []string) error { ... }, } // devFrontendCommand 启动前端调试模式 var devFrontendCommand = &cobra.Command{ Use: "frontend", Short: "前端调试模式", RunE: func(c *cobra.Command, args []string) error { ... }, } // 同时启动前端和后端调试 var devAllCommand = &cobra.Command{ Use: "all", Short: "同时启动前端和后端调试", RunE: func(c *cobra.Command, args []string) error { ... }, } ``` 同时在 framework/command/kernel.go 中,我们加上dev的命令: ```go // 框架核心命令 func AddKernelCommands(root *cobra.Command) { ... // dev 调试命令 root.AddCommand(initDevCommand()) ``` ## proxy 类的设计 定义了dev 命令的设计,我们再思考一下它如何实现。首先需要一个结构来承担起调试模式所有的逻辑,这里定义为Proxy结构。proxy结构和proxy结构对应的方法我们都存放在 framework/command/dev.go中: ```go // Proxy 代表serve启动的服务器代理 type Proxy struct { ... } ``` 同时定义一个NewProxy方法来初始化这个Proxy结构: ```go func NewProxy(c framework.Container) *Proxy ``` 在初始化proxy的时候,需要容器中的一些服务,比如配置文件服务等,所以这里传递了一个容器的参数。 这个proxy结构应该有几个方法,按照代理分发的结构示意图,我们要**定义proxy服务需要的方法、前端服务需要的方法和后端服务需要的方法**。 针对proxy服务,首先需要定义我们在讲反向代理技术难点的时候提到的方法 newProxyReverseProxy,用来创建一个代理前后端的代理ReverseProxy结构。 ```go func (p *Proxy) newProxyReverseProxy(frontend, backend *url.URL) *httputil.ReverseProxy ``` 其次,还需要一个启动proxy的方法 startProxy。它的传入参数就直接设置为两个bool,代表是否要开启前端服务的代理、以及是否要开启后端服务的代理。 ```go func (p *Proxy) startProxy(startFrontend, startBackend bool) error ``` 再来定义前后端服务的方法。明显要有一个方法能启动前端服务、也要有一个方法能启动后端服务: ```go func (p *Proxy) restartFrontend() error func (p *Proxy) restartBackend() error ``` 这里注意一下,前端服务是直接使用 `npm run dev` 命令启动调试模式的,而后端服务是先进行 `go build` 再进行 `go run` ,所以后端服务是需要进行编译的,所以我们还需要一个编译后端服务的方法: ```go func (p *Proxy) rebuildBackend() error ``` 同时,由于前端服务已经自己有了监控文件变更的逻辑,不需要我们再监控前端文件是否有变更了。而后端服务需要一个函数来监控源码文件的变更: ```go func (p *Proxy) monitorBackend() error ``` 这个监控文件我们设计为阻塞式的,在for 循环中不断监控文件的变动,所以在调用的时候,如果不需要在这个函数中阻塞,可以开启一个Goroutine进行监听。 有了这些函数,我们就串联一下上面的command的设计。 首先前端调试模式,就非常简单,启动一个只带有前端的proxy就行: ```go // devFrontendCommand 启动前端调试模式 var devFrontendCommand = &cobra.Command{ ... RunE: func(c *cobra.Command, args []string) error { // 启动前端服务 proxy := NewProxy(c.GetContainer()) return proxy.startProxy(true, false) }, } ``` 其次是后端调试模式,先启动一个Goroutine监听后端文件,再启动一个只有后端的proxy: ```go // devBackendCommand 启动后端调试模式 var devBackendCommand = &cobra.Command{ ... RunE: func(c *cobra.Command, args []string) error { proxy := NewProxy(c.GetContainer()) // 监听后端文件 go proxy.monitorBackend() // 启动只有后端的proxy if err := proxy.startProxy(false, true); err != nil { return err } return nil }, } ``` 而前后端同时调试,则是先启动一个Goroutine监听后端文件,再同时启动监听前后端的proxy: ```go var devAllCommand = &cobra.Command{ ... RunE: func(c *cobra.Command, args []string) error { proxy := NewProxy(c.GetContainer()) // 监听后端文件 go proxy.monitorBackend() // 启动前后端同时监听的proxy if err := proxy.startProxy(true, true); err != nil { return err } return nil }, } ``` 今天只在framework/command/目录下增加了一个dev.go文件,代码地址在 [geekbang/19](https://github.com/gohade/coredemo/tree/geekbang/19) 分支上。下节课我们继续完成调试模式的具体实现。 ![](https://static001.geekbang.org/resource/image/4f/62/4f70c9293be8ccdyy55b395bc9705862.png?wh=734x1450) ## 小结 今天这节课最关键的点就在于ReverseProxy的运用。ReverseProxy是Golang标准库提供的反向代理的实现方式。而反向代理,在实际业务开发过程中实际上是非常好用的。 比如我们在业务开发过程中很有可能会需要自研网关,来全局代理和监控所有的后端接口;又或者在拆分微服务的时候,需要有一个统一路由层来引导流量。这个ReverseProxy结构的熟练使用就是这些功能的核心关键。 今天我们为hade框架增加了调试模式,这个模式在很多Golang的框架中是没有的,算是我们的hade框架的一大特色了。大多数框架是依赖于日志进行编译调试。而hade框架之所以能提供这种方便的调试模式,也是依赖于我们前面已经实现的前后端一体、目录服务,配置服务等逻辑。 在实际工作中,特别在调试的时候,这种调试模式一定能为你带来很大的便利。 ## 思考题 讲ReverseProxy时,我们的逻辑是先请求后端服务,如果后端服务出现404,再请求前端。这里有两个问题你可以思考下: 1.可以不可以先请求vue的前端服务,如果前端服务出现404,再请求后端呢? 2.某些vue确定的请求地址,比如"/app.js", “/” ,是否可以不用走这个先后端服务、再前端服务的逻辑?如果可以,怎么做呢? 欢迎在留言区分享你的思考。感谢你的收听,如果觉得有收获,也欢迎把今天的内容分享给你身边的朋友,邀他一起学习。我们下节课见~