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.

17 KiB

19提效实现调试模式加速开发效率

你好,我是轩脉刃。

上一节课我们把前端Vue融合进hade框架中让框架能直接通过命令行启动包含前端和后端的一个应用今天继续思考优化。

在使用Vue的时候你一定使用过 npm run dev 这个命令为前端开启调试模式在这个模式下只要你修改了src下的文件编译器就会自动重新编译并且更新浏览器页面上的渲染。这种调试模式为开发者提高了不少开发效率。

那这种调试模式能否应用到Golang后端让前后端都开启这种调试模式来进一步提升我们开发应用的效率呢接下来两节课我们就来尝试实现这种调试模式。

方案思考和设计

先来思考下调试模式应该怎么设计因为分为前端和后端关于Vue前端既然已经有了 npm run dev 这种调试模式,自然可以直接使用这种方式,要改的主要就是后端。

对于后端Golang代码Golang本身并没有提供任何调试模式的方式进行代码调试只能先通过go build 编译出二进制文件,通过运行二进制文件再启动服务。那我们如何实现刚才的想法,一旦修改代码源文件,就能重新编译运行呢?

相信你一定很快想到了之前实现过配置文件的热更新。在第16章开发配置服务的时候我们使用了 fsnotify来对配置目录下的所有文件进行变更监听一旦修改了配置目录下的文件就重新更新内存中的配置文件map。

那这里是否可以如法炮制,将 AppPath 目录下的文件也进行监听呢?一旦这个目录下的文件有了变更,就重新编译运行后端服务?

是的,原理可行,我们完全可以按照这种想法来构想一下。现在假设我们监听了后端文件能变更调试后端服务也能通过Vue自带命令调试前端,但这里又遇到难点了,如果需要前后端服务同时调试呢?

前端启动调试模式的方式和我们之前的编译方式完全不一样它是直接启动一个端口来服务并没有在dist下生成最终编译文件。这样我们上一章设计的后端直接代理最终编译文件的方法就无法使用了。怎么办

虽然过程不一样,但启动后的行为是差不多的。后端,实现了监听文件重新编译启动后,也是启动了一个进程来提供服务。思考到这里,自然而然,我们就想到是否能在前端和后端服务的前面设计一个反向代理proxy服务呢

图片

让所有外部请求进入这个反响代理服务,然后由反向代理服务进行代理分发,前端请求分发到前端进程,后端请求分发到后端进程。

方案思路很流畅,我们来看如何实现。

实现技术难点分析

先攻坚最关键的技术难点,如何实现反向代理。

所谓反向代理就是能将一个请求按照条件分发到不同的服务中去。在Golang中的net/http/httputil 包中提供了ReverseProxy 这么一个数据结构,它是实现整个反向代理的关键。

我们使用命令 go doc net/http/httputil.ReverseProxy 看下这个数据结构的定义,每个字段的说明我详细写在代码注释里面了:

// 反向代理
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、ErrorHandlerDirector是必须填写的而ModifyResponse、ErrorHandler是可选的。

Director的参数是请求表示如何对请求进行转发。最简单的我们可以修改请求的目标Host将请求转发到后端的服务。具体如何使用可以看net/http/httputil库带的NewSingleHostReverseProxy方法它将请求转发给后端target地址的时候直接将request的scheme、host、path 都进行了替换。这个方法也是后面我们经常要用到的。

// 将原先的请求转发到target地址
func NewSingleHostReverseProxy(target *url.URL) *ReverseProxy {
   targetQuery := target.RawQuery
   // 设置director
   director := func(req *http.Request) {
      // 将原先的request替换scheme, hostpath。
      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的时候会使用到它代表的函数如果我们要对下游的返回数据进行修改就可以设置这个字段。

	ModifyResponse func(*http.Response) error

这个字段的参数就只有一个http.Response指针代表的是下游返回给上游的返回结构我们可以对这个指针的内容进行操作。而返回值error代表操作的结果如果在操作过程中出现错误会返回error。

返回的error就会进入第三个字段函数 ErrorHandler中。

	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中

// 重新启动一个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 前后端同时调试。

./hade dev //显示帮助信息
./hade dev frontend // 调试前端
./hade dev backend  // 调试后端
./hade dev all  // 显示所有

在定义工具命令的时候,如果遇到有前端和后端的,我们应该统一在命令中使用关键字 frontend 和 backend 分别代表前后端,这样可以给使用者不断加深强调这两个关键字,这样我们在使用命令的时候,就能很快反应出前后端对应的命令了。

创建一个 framework/command/dev.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的命令

// 框架核心命令
func AddKernelCommands(root *cobra.Command) {
 ...
 // dev 调试命令
 root.AddCommand(initDevCommand())

proxy 类的设计

定义了dev 命令的设计我们再思考一下它如何实现。首先需要一个结构来承担起调试模式所有的逻辑这里定义为Proxy结构。proxy结构和proxy结构对应的方法我们都存放在 framework/command/dev.go中

// Proxy 代表serve启动的服务器代理
type Proxy struct {
  ...
}

同时定义一个NewProxy方法来初始化这个Proxy结构

func NewProxy(c framework.Container) *Proxy

在初始化proxy的时候需要容器中的一些服务比如配置文件服务等所以这里传递了一个容器的参数。

这个proxy结构应该有几个方法按照代理分发的结构示意图我们要定义proxy服务需要的方法、前端服务需要的方法和后端服务需要的方法

针对proxy服务首先需要定义我们在讲反向代理技术难点的时候提到的方法 newProxyReverseProxy用来创建一个代理前后端的代理ReverseProxy结构。

func (p *Proxy) newProxyReverseProxy(frontend, backend *url.URL) *httputil.ReverseProxy

其次还需要一个启动proxy的方法 startProxy。它的传入参数就直接设置为两个bool代表是否要开启前端服务的代理、以及是否要开启后端服务的代理。

func (p *Proxy) startProxy(startFrontend, startBackend bool) error

再来定义前后端服务的方法。明显要有一个方法能启动前端服务、也要有一个方法能启动后端服务:

func (p *Proxy) restartFrontend() error
func (p *Proxy) restartBackend() error 

这里注意一下,前端服务是直接使用 npm run dev 命令启动调试模式的,而后端服务是先进行 go build 再进行 go run ,所以后端服务是需要进行编译的,所以我们还需要一个编译后端服务的方法:

func (p *Proxy) rebuildBackend() error

同时,由于前端服务已经自己有了监控文件变更的逻辑,不需要我们再监控前端文件是否有变更了。而后端服务需要一个函数来监控源码文件的变更:

func (p *Proxy) monitorBackend() error

这个监控文件我们设计为阻塞式的在for 循环中不断监控文件的变动所以在调用的时候如果不需要在这个函数中阻塞可以开启一个Goroutine进行监听。

有了这些函数我们就串联一下上面的command的设计。

首先前端调试模式就非常简单启动一个只带有前端的proxy就行

// devFrontendCommand 启动前端调试模式
var devFrontendCommand = &cobra.Command{
 ...
 RunE: func(c *cobra.Command, args []string) error {
  // 启动前端服务
  proxy := NewProxy(c.GetContainer())
  return proxy.startProxy(true, false)
 },
}

其次是后端调试模式先启动一个Goroutine监听后端文件再启动一个只有后端的proxy

// 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

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 分支上。下节课我们继续完成调试模式的具体实现。

小结

今天这节课最关键的点就在于ReverseProxy的运用。ReverseProxy是Golang标准库提供的反向代理的实现方式。而反向代理在实际业务开发过程中实际上是非常好用的。

比如我们在业务开发过程中很有可能会需要自研网关来全局代理和监控所有的后端接口又或者在拆分微服务的时候需要有一个统一路由层来引导流量。这个ReverseProxy结构的熟练使用就是这些功能的核心关键。

今天我们为hade框架增加了调试模式这个模式在很多Golang的框架中是没有的算是我们的hade框架的一大特色了。大多数框架是依赖于日志进行编译调试。而hade框架之所以能提供这种方便的调试模式也是依赖于我们前面已经实现的前后端一体、目录服务配置服务等逻辑。

在实际工作中,特别在调试的时候,这种调试模式一定能为你带来很大的便利。

思考题

讲ReverseProxy时我们的逻辑是先请求后端服务如果后端服务出现404再请求前端。这里有两个问题你可以思考下

1.可以不可以先请求vue的前端服务如果前端服务出现404再请求后端呢

2.某些vue确定的请求地址比如"/app.js", “/” ,是否可以不用走这个先后端服务、再前端服务的逻辑?如果可以,怎么做呢?

欢迎在留言区分享你的思考。感谢你的收听,如果觉得有收获,也欢迎把今天的内容分享给你身边的朋友,邀他一起学习。我们下节课见~