你好,我是轩脉刃。
上一节课,我们讨论了调试模式的整体设计思路和关键的技术难点-反向代理,最后定义了具体的命令设计,包括三个二级命令,能让我们调试前端/后端,或者同时调试。现在,大的框架都建立好了,但是其中的细节实现还没有讨论。成败在于细节,今天我们就撸起袖子开始实现它们。
简单回顾一下调试模式的架构设计。所有外部请求进入反响代理服务后,会由反向代理服务进行分发,前端请求分发到前端进程,后端请求分发到后端进程。
在这个设计中,前端服务启动的时候占用哪个端口?后端服务启动的时候占用哪个端口?反向代理服务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,它的核心逻辑就是创建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。这个函数的作用是重新编译后端。
那如何编译后端呢?还记得第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也是一样的逻辑,先关闭旧的前端进程,然后启动新的前端进程。这里同样也有一个问题,启动前端进程的命令是 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方法,它有两个参数,表示在启动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方法,监控某个文件夹的变动,并且重新编译并且运行后端服务。
这个方法我们重点说一下,有些逻辑还是比较绕的。
首先,在前一节课说过了,可以使用 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/demo、http://127.0.0.1:8070/#/,能同时看到前端和后端信息:
修改前端msg和修改后端内容后,变更生效:
到这里,前后端同时调试模式验证成功!
今天的主要内容是创建调试模式的三个二级命令。完整的代码示例在GitHub上的 geekbang/20 分支,欢迎比对查看。本节课我们只在命令文件中增加了一个framework/command/dev.go文件:
今天我们具体实现了调试模式,其实了解了上节课对调试模式的设计之后,今天的内容主要是细节上的代码实现了,就是工作量。不过其中的实现细节,也是在工作中不断积累下来的,你可以多多体会。
比如refresh_time这个计时器窗口设计,在最初版本是没有的,在实际工作中,使用这个调试模式,遇到了频繁重建的困扰,才做了这个设计。总之,整个调试模式支持是非常赞的,它能让我们的Web开发效率提高了一个档次,希望你也能感同身受。
在回答同学们问题的时候,我发现有不少是其他语言转来Go的,不知道你的经历是怎样的,可以来聊一聊你在使用其他语言时,调试一个程序都是怎么调试的呢?有没有比较好的调试模式?
欢迎在留言区分享你的思考。感谢你的收听,我们下节课见~