679 lines
28 KiB
Markdown
679 lines
28 KiB
Markdown
|
# 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的,不知道你的经历是怎样的,可以来聊一聊你在使用其他语言时,调试一个程序都是怎么调试的呢?有没有比较好的调试模式?
|
|||
|
|
|||
|
欢迎在留言区分享你的思考。感谢你的收听,我们下节课见~
|
|||
|
|