gitbook/手把手带你写一个Web框架/docs/445835.md
2022-09-03 22:05:03 +08:00

31 KiB
Raw Blame History

28SSH如何生成发布系统让框架发布自动化

你好,我是轩脉刃。

在前面的课程中我们基本上已经完成了一个能同时生成前端和后端的框架hade也能很方便对框架进行管理控制。下面两节课我们来考虑框架的一些周边功能比如部署自动化。

部署自动化其实不是一个框架的刚需有很多方式可以将一个服务进行自动化部署比如现在比较流行的Docker化或者CI/CD流程。

但是一些比较个人比较小的项目,比如一个博客、一个官网网站,这些部署流程往往都太庞大了,更需要一个服务,能快速将在开发机器上写好、调试好的程序上传到目标服务器,并且更新应用程序。这就是我们今天要实现的框架发布自动化。

所有的部署自动化工具基本都依赖本地与远端服务器的连接这个连接可以是FTP可以是HTTP但是更经常的连接是SSH连接。因为一旦我们购买了一个Web服务器服务器提供商就会提供一个有SSH登录账号的服务器我们可以通过这个账号登录到服务器上来进行各种软件的安装比如FTP、HTTP服务等。

基本上SSH账号是我们拿到Web服务器的首要凭证所以要设计的自动化发布系统也是依赖SSH的。

SSH服务

那么在Golang中如何SSH连接远端的服务器呢有一个ssh库能完成SSH的远端连接。

这里介绍一个小知识你可以看下这个ssh库的gitgolang.org/x/crypto/ssh。它是在官网golang.org 下的但是又不是官方的标准库因为子目录是x。

这种库其实也是经过官方认证的,属于实验性的库,我们可以这么理解:以golang.org/x/ 开头的库,都是官方认为这些库后续有可能成为标准库的一部份但是由于种种原因现在还没有计划放进标准库中需要更多时间打磨。但是这种库的维护者和开发者一般已经是Golang官方组的人员了。比如现在今年讨论热度很大的Golang泛型据说也会先以实验库的形式出现。

不管怎么样这种以golang.org/x/开头的库成熟度已经非常高了我们是可以放心使用的。来了解一下这个ssh库

package main

import (
	"bytes"
	"fmt"
	"log"

	"golang.org/x/crypto/ssh"
)

func main() {
	var hostKey ssh.PublicKey

	// ssh相关配置
	config := &ssh.ClientConfig{
		User: "username",
		Auth: []ssh.AuthMethod{
			ssh.Password("yourpassword"),
		},
		HostKeyCallback: ssh.FixedHostKey(hostKey),
	}
    // 创建client
	client, err := ssh.Dial("tcp", "yourserver.com:22", config)
	if err != nil {
		log.Fatal("Failed to dial: ", err)
	}
	defer client.Close()

    // 使用client做各种操作
	
	session, err := client.NewSession()
	if err != nil {
		log.Fatal("Failed to create session: ", err)
	}
	defer session.Close()

	var b bytes.Buffer
	session.Stdout = &b
	if err := session.Run("/usr/bin/whoami"); err != nil {
		log.Fatal("Failed to run: " + err.Error())
	}
	fmt.Println(b.String())
}

在这个官方示例中我们可以看到ssh库作为客户端连接最重要的是创建ssh.Client这个数据结构而这个数据结构使用ssh.Dail能进行创建创建的时候依赖ssh.ClientConfig这么一个配置结构。

是不是非常熟悉和前面的Gorm、Redis一样将SSH的连接部分封装成为hade框架的SSH服务这样我们就能很方便地初始化一个ssh.Client了。

经过前面几节课相信你已经非常熟悉这种套路了我们就简要说明下ssh service的封装和实现思路。这节课的重点在后面对自动化发布系统的实现上。

ssh service的封装一样有三个部分服务协议、服务提供者、服务实现。

服务协议我们提供GetClient方法

// SSHService 表示一个ssh服务
type SSHService interface {
   // GetClient 获取ssh连接实例
   GetClient(option ...SSHOption) (*ssh.Client, error)
}

而其中的SSHOption作为更新SSHConfig的函数

// SSHOption 代表初始化的时候的选项
type SSHOption func(container framework.Container, config *SSHConfig) error

我们封装配置结构为 SSHConfig

// SSHConfig 为hade定义的SSH配置结构
type SSHConfig struct {
   NetWork string
   Host    string
   Port    string
   *ssh.ClientConfig
}

对应的配置文件如下 config/testing/ssh.yaml你可以看看每个配置的说明

timeout: 1s
network: tcp
web-01:
    host: 118.190.3.55 # ip地址
    port: 22 # 端口
    username: yejianfeng # 用户名
    password: "123456" # 密码
web-02:
    network: tcp
    host: localhost # ip地址
    port: 3306 # 端口
    username: jianfengye # 用户名
    rsa_key: "/Users/user/.ssh/id_rsa"
    known_hosts: "/Users/user/.ssh/known_hosts"

这里注意下SSH的连接方式有两种一种是直接使用用户名密码来连接远程服务器还有一种是使用rsa key文件来连接远端服务器所以这里的配置需要同时支持两种配置。对于使用rsa key文件的方式需要设置rsk_key的私钥地址和负责安全验证的known_hosts

定义好了SSH的服务协议服务提供者和服务实现并没有什么特别就不展示具体代码了在GitHub上的provider/ssh/provider.goprovider/ssh/service.go中。我们简单说一下思路。

对于服务提供者我们实现基本的五个函数Register/Boot/IsDefer/Param/Name。另外这个ssh服务并不是框架启动时候必要加载的所以设置IsDefer为true而Param我们就照例把服务容器container作为参数传递给Register设定的实例化方法。

而SSH服务的具体实现同样类似Redis先配置更新再查询是否已经实例化若已经实例化返回实例化对象若没有实例化实例化client并且存在map中。

完成了SSH的服务协议、服务提供者、服务实例我们就重点讨论下如何使用SSH的服务协议来实现自动化部署。

自动化部署

首先还是思考清楚自动化部署的命令设计。我们的hade框架是同时支持前后端的开发框架所以自动化部署是需要同时支持前后端部署的也就是说它的命令也需要支持前后端的部署这里我们设计一个显示帮助信息的一级命令./hade deploy 和四个二级命令:

  • ./hade deploy frontend ,部署前端
  • ./hade deploy backend ,部署后端
  • ./hade deploy all ,同时部署前后端
  • ./hade deploy rollback ,部署回滚

同时也设计一下部署配置文件。

首先我们是需要知道部署在哪个或者哪几个服务器上的所以需要有一个数组配置项connections来定义部署服务器。而部署服务器的具体用户名密码配置在前面SSH的配置里是存在的所以这里直接把SSH的配置路径放在我们的connections中就可以了。

其次还要知道我们要部署的远端服务器的目标文件夹是什么所以这里需要有一个remote_folder配置项来配置远端文件夹。

然后就是前端部署的配置frontend了。我们知道在本地编译之后会直接编译成了dist目录下的HTML/JS/CSS文件这些文件直接上传到远端文件夹就是可以使用的了。

但是在上传前端编译文件之前和在远端服务器执行一些命令之后是有可能要做一些操作的。比如上传前先清空远端文件夹、上传后更新nginx等。所以这里我们设计两个数组结构pre_action和post_action来分别存放部署的前置命令和部署的后置命令

最后就是后端部署的配置backend。同前端部署一样我们也有部署的前置命令和后置命令。但是后端编译还有一个不同点。

因为后端是Golang编译的而它的编译其实是分平台的加上Go支持“交叉编译”。就是说比如我的工作机器是Mac操作系统Web服务器是Linux操作系统那么我需要编译Linux操作系统的后端程序但是我可以直接在Mac操作系统上使用GOOS 和 GOARCH 来编译Linux操作系统的程序

GOOS=linux GOARCH=amd64 go build ./

这样编译出来的文件就是可以在Linux运行的后端进程了。所以在后端部署的配置项里面我们增加GOOS 和 GOARCH分别表示后端的交叉编译参数。

完整的配置文件在config/development/deploy.yaml中

connections: # 要自动化部署的连接
    - ssh.web-01

remote_folder: "/home/yejianfeng/coredemo/"  # 远端的部署文件夹

frontend: # 前端部署配置
    pre_action: # 部署前置命令
        - "pwd"
    post_action: # 部署后置命令
        - "pwd"

backend: # 后端部署配置
    goos: linux # 部署目标操作系统
    goarch: amd64 # 部署目标cpu架构
    pre_action: # 部署前置命令
        - "pwd"
    post_action: # 部署后置命令
        - "chmod 777 /home/yejianfeng/coredemo/hade"
        - "/home/yejianfeng/coredemo/hade app restart"

好,配置文件设计好了,下面我们开始实现对应的命令。

其实估计你对如何实现,已经大致心中有数了。一级命令 ./hade deploy 还是并没有什么内容,只是将帮助信息打印出来,之前也做过很多次,就不描述了。二级命令按之前的套路,一般是先编译,再部署,最后上传到目标服务器。

部署前端

看二级命令 ./hade deploy frontend。对于部署前端,我们分为三个步骤:

  • 创建要部署的文件夹;
  • 编译前端文件到部署文件夹中;
  • 上传部署文件夹并且执行对应的前置和后置的shell。

在framework/command/deploy.go中

// deployFrontendCommand 部署前端
var deployFrontendCommand = &cobra.Command{
    Use:   "frontend",
    Short: "部署前端",
    RunE: func(c *cobra.Command, args []string) error {
        container := c.GetContainer()

        // 创建部署文件夹
        deployFolder, err := createDeployFolder(container)
        if err != nil {
            return err
        }

        // 编译前端到部署文件夹
        if err := deployBuildFrontend(c, deployFolder); err != nil {
            return err
        }

        // 上传部署文件夹并执行对应的shell
        return deployUploadAction(deployFolder, container, "frontend")
    },
}

这里可能你会有个疑惑为什么要创建一个部署文件夹我们直接将前端编译的dist目录上传到目标服务器不就行了么来为你解答下。

部署服务是一个很小心的过程,因为它会影响现在的线上服务,而每次部署都是有可能失败的,也就很有可能需要进行回滚操作,就是我们前面定义的部署回滚操作命令 ./hade deploy rollback而回滚的时候,需要能找到某个特定版本的编译内容,这里就需要部署文件夹

这个部署文件夹我们定义为目录 deploy/xxxxxx其中的xxxx直接设置为细化到秒的时间。对应的创建部署文件夹的函数如下

// 创建部署的folder
func createDeployFolder(c framework.Container) (string, error) {
   appService := c.MustMake(contract.AppKey).(contract.App)
   deployFolder := appService.DeployFolder()

   // 部署文件夹的名称
   deployVersion := time.Now().Format("20060102150405")
   versionFolder := filepath.Join(deployFolder, deployVersion)
   if !util.Exists(versionFolder) {
      return versionFolder, os.Mkdir(versionFolder, os.ModePerm)
   }
   return versionFolder, nil
}

这里的appService.DeployFolder() 是我们在appService下创建的一个新的目录deploy在framework/contract/app.go中

// App 定义接口
type App interface {
   ...
   // DeployFolder 存放部署的时候创建的文件夹
   DeployFolder() string
   ...
}

有了这个部署文件夹,每次的发布都有“档案”存储了,这就为回滚命令提供了可能性。我们每次编译的文件,也都会先经过这个部署文件夹,再中转上传到目标服务器。

第一步创建部署文件夹实现了,我们再回头看下部署前端的第二个步骤,编译前端文件到部署文件夹。可以直接使用 buildFrontendCommand的RunE方法它会将前端编译到dist目录下然后我们再将dist目录文件拷贝到部署文件夹中

func deployBuildFrontend(c *cobra.Command, deployFolder string) error {
   container := c.GetContainer()
   appService := container.MustMake(contract.AppKey).(contract.App)

   // 编译前端
   if err := buildFrontendCommand.RunE(c, []string{}); err != nil {
      return err
   }

   // 复制前端文件到deploy文件夹
   frontendFolder := filepath.Join(deployFolder, "dist")
   if err := os.Mkdir(frontendFolder, os.ModePerm); err != nil {
      return err
   }

   buildFolder := filepath.Join(appService.BaseFolder(), "dist")
   if err := util.CopyFolder(buildFolder, frontendFolder); err != nil {
      return err
   }
   return nil
}

第三步上传部署文件夹并且执行对应的前置和后置的shell。

这个步骤的实现是今天这节课的重点了。首先遍历配置文件中的deploy.connections明确我们要在哪几个远端节点中进行部署然后对每个远端服务创建一个ssh.Client由于前面已经写好了SSH服务所以直接使用GetClient方法就能为每个节点创建一个sshClient了

for _, node := range deployNodes {
   sshClient, err := sshService.GetClient(ssh.WithConfigPath(node))
   if err != nil {
      return err
   }
   ...
}

接下来就要执行命令了,那怎么执行前置或者后置命令呢?

我们需要为每个命令创建一个session然后使用session.CombinedOut来输出这个命令的结果把每个命令的结果都输出在控制台中。相关代码如下

for _, action := range preActions {
   // 创建session
   session, err := sshClient.NewSession()
   if err != nil {
      return err
   }
   // 执行命令,并且等待返回
   bts, err := session.CombinedOutput(action)
   if err != nil {
      session.Close()
      return err
   }
   session.Close()
   
   // 执行前置命令成功
   logger.Info(context.Background(), "execute pre action", map[string]interface{}{
      "cmd":        action,
      "connection": node,
      "out":        strings.ReplaceAll(string(bts), "\n", ""),
   })
}

执行了前置命令之后下面就是要把部署文件夹中的文件上传到目标服务器了。如何通过SSH服务将文件上传到目标服务器呢

这里需要使用到一个成熟的第三方库 sftp目前已经有1.1k star采用BSD-2的开源协议允许修改商用但是要保留申明。这个库就是封装SSH的将SFTP文件传输协议封装了一下。SFTP是什么它是基于SSH协议来进行文件传输的一个协议功能与FTP相似区别就是它的连接通道使用SSH。

SFTP的底层连接实际上就是SSH只是把传输的文件内容进行了一下加密等工作增加了传输的安全性。所以SFTP本质就是“使用SSH连接来完成文件传输功能”。这点可以从它的实例化看出sftp.Client的唯一参数就是ssh.Client。

client, err := sftp.NewClient(sshClient)
if err != nil {
   return err
}

SFTP这个库在初始化sftp.Client之后会将这个client封装地和官方的本地操作文件OS库一样你在使用sftp.Client的时候完全没有障碍。

比如OS库创建一个文件是os.Create在SFTP中就是使用client.CreateOS库获取一个文件信息的函数是os.Stat在SFTP中就是client.Stat。但是注意下这里完全是SFTP刻意将这个库函数设计的和OS库一样的它们之间并没有什么嵌套关系。

我们使用ssh.Client初始化一个sftp.Client之后写一个uploadFolderToSFTP的函数来实现将本地文件夹同步到远端文件夹

// 上传部署文件夹
func uploadFolderToSFTP(container framework.Container, localFolder, remoteFolder string, client *sftp.Client) error {
    logger := container.MustMake(contract.LogKey).(contract.Log)
    // 遍历本地文件
    return filepath.Walk(localFolder, func(path string, info os.FileInfo, err error) error {
        // 获取除了folder前缀的后续文件名称
        relPath := strings.Replace(path, localFolder, "", 1)
        if relPath == "" {
            return nil
        }
        // 如果是遍历到了一个目录
        if info.IsDir() {
            logger.Info(context.Background(), "mkdir: "+filepath.Join(remoteFolder, relPath), nil)
            // 创建这个目录
            return client.MkdirAll(filepath.Join(remoteFolder, relPath))
        }

        // 打开本地的文件
        rf, err := os.Open(filepath.Join(localFolder, relPath))
        if err != nil {
            return errors.New("read file " + filepath.Join(localFolder, relPath) + " error:" + err.Error())
        }
        // 检查文件大小
        rfStat, err := rf.Stat()
        if err != nil {
            return err
        }
        // 打开/创建远端文件
        f, err := client.Create(filepath.Join(remoteFolder, relPath))
        if err != nil {
            return errors.New("create file " + filepath.Join(remoteFolder, relPath) + " error:" + err.Error())
        }

        // 大于2M的文件显示进度
        if rfStat.Size() > 2*1024*1024 {
            logger.Info(context.Background(), "upload local file: "+filepath.Join(localFolder, relPath)+
                " to remote file: "+filepath.Join(remoteFolder, relPath)+" start", nil)
            // 开启一个goroutine来不断计算进度
            go func(localFile, remoteFile string) {
                // 每10s计算一次
                ticker := time.NewTicker(2 * time.Second)
                for range ticker.C {
                    // 获取远端文件信息
                    remoteFileInfo, err := client.Stat(remoteFile)
                    if err != nil {
                        logger.Error(context.Background(), "stat error", map[string]interface{}{
                            "err":         err,
                            "remote_file": remoteFile,
                        })
                        continue
                    }
                    // 如果远端文件大小等于本地文件大小,说明已经结束了
                    size := remoteFileInfo.Size()
                    if size >= rfStat.Size() {
                        break
                    }
                    // 计算进度并且打印进度
                    percent := int(size * 100 / rfStat.Size())
                    logger.Info(context.Background(), "upload local file: "+filepath.Join(localFolder, relPath)+
                        " to remote file: "+filepath.Join(remoteFolder, relPath)+fmt.Sprintf(" %v%% %v/%v", percent, size, rfStat.Size()), nil)
                }
            }(filepath.Join(localFolder, relPath), filepath.Join(remoteFolder, relPath))
        }

        // 将本地文件并发读取到远端文件
        if _, err := f.ReadFromWithConcurrency(rf, 10); err != nil {
            return errors.New("Write file " + filepath.Join(remoteFolder, relPath) + " error:" + err.Error())
        }
        // 记录成功信息
        logger.Info(context.Background(), "upload local file: "+filepath.Join(localFolder, relPath)+
            " to remote file: "+filepath.Join(remoteFolder, relPath)+" finish", nil)
        return nil
    })
}

这段代码长一点。首先我们使用功能filePath.Walk来遍历本地文件夹中的所有文件如果遍历到的是子文件夹就创建子文件夹否则的话我们就将本地文件上传到远端。而上传远端的操作大致就是三步打开本地文件、打开远端文件、将本地文件传输到远端文件。

在上述函数中大致是这几句代码:

// 打开本地的文件
rf, err := os.Open(filepath.Join(localFolder, relPath))

// 打开/创建远端文件
f, err := client.Create(filepath.Join(remoteFolder, relPath))

// 将本地文件并发读取到远端文件
if _, err := f.ReadFromWithConcurrency(rf, 10); err != nil 

SFTP提供了并发读取到远端文件ReadFromWithConcurrency的方法我们可以使用这个并发读的方法提高上传效率。

但是即使是并发读,对于比较大的文件,还是需要等候比较长的时间。而这个等待时长,对于在控制台敲下部署命令的使用者来说是非常不友好的。我们希望能每隔一段时间显示一下当前的部署进度,这个怎么做呢?

这里我们设计大于2M的文件执行这个操作。2M是我自己实验出来体验比较差的一个阈值。然后每2s就打印一下当前进度所以使用了一个ticker来计算时间。每次这个ticker结束的时候计算一下远端文件的大小再计算一下本地文件的大小。两者相除就是这个文件的上传进度再使用日志打印就能打印出具体的进度了。

最后的效果如下:

到这里部署前端的代码就开发完成了。

部署后端

理解了如何部署前端,部署后端的对应方法基本如出一辙。唯一不同的地方就是编译。

编译Golang的后端需要指定对应的编译平台和编译CPU架构就是前面说的GOOS和GOARCH。所以我们就不能直接使用build命令来编译后端了。改成定位go程序来执行go build并且需要修改输出文件路径输出到部署文件夹中。

当然这个部署文件夹还是按照我们之前的设计为 deploy/xxxxxx其中的xxxx直接设置为细化到秒的时间继续在framework/command/deploy.go中写入

// 编译后端
path, err := exec.LookPath("go")
if err != nil {
   log.Fatalln("hade go: 请在Path路径中先安装go")
}
   // 组装命令
deployBinFile := filepath.Join(deployFolder, binFile)
cmd := exec.Command(path, "build", "-o", deployBinFile, "./")
cmd.Env = os.Environ()
   // 设置GOOS和GOARCH
if configService.GetString("deploy.backend.goos") != "" {
   cmd.Env = append(cmd.Env, "GOOS="+configService.GetString("deploy.backend.goos"))
}
if configService.GetString("deploy.backend.goarch") != "" {
   cmd.Env = append(cmd.Env, "GOARCH="+configService.GetString("deploy.backend.goarch"))
}
   // 执行命令
ctx := context.Background()
out, err := cmd.CombinedOutput()
if err != nil {
   logger.Error(ctx, "go build err", map[string]interface{}{
      "err": err,
      "out": string(out),
   })
   return err
}
logger.Info(ctx, "编译成功", nil)

同时除了生成二进制文件,还要记得把.env文件如果有的话、config目标文件传递到本地的部署目录

// 复制.env
if util.Exists(filepath.Join(appService.BaseFolder(), ".env")) {
   if err := util.CopyFile(filepath.Join(appService.BaseFolder(), ".env"), filepath.Join(deployFolder, ".env")); err != nil {
      return err
   }
}

// 复制config文件
deployConfigFolder := filepath.Join(deployFolder, "config", env)
if !util.Exists(deployConfigFolder) {
   if err := os.MkdirAll(deployConfigFolder, os.ModePerm); err != nil {
      return err
   }
}
if err := util.CopyFolder(filepath.Join(appService.ConfigFolder(), env), deployConfigFolder); err != nil {
   return err
}

自动化部署后端的命令,除了以上的编译文件到部署目录之外,其他部分都和自动化部署前端的命令一致:

// deployBackendCommand 部署后端
var deployBackendCommand = &cobra.Command{
   Use:   "backend",
   Short: "部署后端",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()

      // 创建部署文件夹
      deployFolder, err := createDeployFolder(container)
      if err != nil {
         return err
      }

      // 编译后端到部署文件夹
      if err := deployBuildBackend(c, deployFolder); err != nil {
         return err
      }

      // 上传部署文件夹并执行对应的shell
      return deployUploadAction(deployFolder, container, "backend")
   },
}

部署全部

而对于同时部署前后端命令其实就是在编译阶段把前端和后端同时进行编译并且最终上传部署文件夹。同样放在framework/command/deploy.go

var deployAllCommand = &cobra.Command{
   Use:   "all",
   Short: "全部部署",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()

      deployFolder, err := createDeployFolder(container)
      if err != nil {
         return err
      }

      // 编译前端
      if err := deployBuildFrontend(c, deployFolder); err != nil {
         return err
      }

      // 编译后端
      if err := deployBuildBackend(c, deployFolder); err != nil {
         return err
      }

      // 上传前端+后端并执行对应的shell
      return deployUploadAction(deployFolder, container, "all")
   },
}

部署回滚

最后就是部署回滚操作,主要明确一下需要传递的参数:

一个是回滚版本号。这个版本号就是我们的部署目录的名称前面说过部署目录为deploy/xxxxxxxxxx设置为细化到秒的时间。比如20211110233354表示是我们2021年11月10日23点33分54秒创建的版本。

另外一个就是标记希望回滚前端,还是后端,还是全部回滚。这里主要涉及执行前端的回滚命令,还是执行后端的回滚命令。

这两个参数我们直接以参数形式跟在deploy rollback命令之后如下

 ./hade deploy rollback 20211110233354 backend

明确了参数它的具体实现就很简单了因为它没有任何的编译过程我们只需要把回滚版本所在目录的编译结果上传到目标服务器就可以了同样我们把这个命令放在framework/command/deploy.go中

// deployRollbackCommand 部署回滚
var deployRollbackCommand = &cobra.Command{
   Use:   "rollback",
   Short: "部署回滚",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()

      if len(args) != 2 {
         return errors.New("参数错误,请按照参数进行回滚 ./hade deploy rollback [version] [frontend/backend/all]")
      }

      version := args[0]
      end := args[1]

      // 获取版本信息
      appService := container.MustMake(contract.AppKey).(contract.App)
      deployFolder := filepath.Join(appService.DeployFolder(), version)

      // 上传部署文件夹并执行对应的shell
      return deployUploadAction(deployFolder, container, end)
   },
}

到这里四个自动化部署命令就都开发完成。我们来验证一下。

验证

要验证部署命令我们当然需要有一个目标部署服务器这是我设置的web-01服务器配置在config/development/ssh.yaml中

timeout: 3s
network: tcp
web-01:
    host: 111.222.333.444 # ip地址
    port: 22 # 端口
    username: yejianfeng # 用户名
    password: "123456" # 密码

而在config/development/deploy.yaml中我的配置如下

connections: # 要自动化部署的连接
    - ssh.web-01

remote_folder: "/home/yejianfeng/coredemo/"  # 远端的部署文件夹

frontend: # 前端部署配置
    pre_action: # 部署前置命令
        - "pwd"
    post_action: # 部署后置命令
        - "pwd"

backend: # 后端部署配置
    goos: linux # 部署目标操作系统
    goarch: amd64 # 部署目标cpu架构
    pre_action: # 部署前置命令
        - "rm /home/yejianfeng/coredemo/hade"
    post_action: # 部署后置命令
        - "chmod 777 /home/yejianfeng/coredemo/hade"
        - "/home/yejianfeng/coredemo/hade app restart"

重点看后端部署配置。在部署后端之前我们先运行一个rm 命令来将旧的hade二进制进程删除然后部署后端文件其中包括这个二进制进程。最后执行了两个命令一个是chmod命令保证上传上去的二进制进程命令可以执行第二个就是./hade app restart命令能将远端的命令启动。

这里就演示下部署后端服务 ./hade deploy backend ,输出结果如下:

我们看到它成功地编译后端服务到目标文件夹deploy/20211110233533 并且上传了编译的hade命令在远端启动了进程。

接着验证下回滚命令。在之前已经发布过版本 20211110233354 了。所以这里直接运行命令 ./hade deploy rollback 20211110233354 backend 将版本回滚到 20211110233354。

验证成功!

本节课我们对framework下的provider、contract、command目录都有修改。目录截图如下供你对比查看所有代码都已经上传到geekbang/28分支了。

小结

今天我们实现了将代码自动化部署到Web服务器的机制。为了实现这个自动化部署先实现了一个SSH服务然后定制了一套自动化部署命令包括部署前端、部署后端、部署全部和部署回滚。

虽然说这个由框架负责的自动化部署机制在大项目中可能用不上毕竟现在大项目都采用Docker化和k8s部署了。不过对于小型项目这种部署机制还是有其便利性的。所以我们的hade框架还是决定提供这个机制。

在实现这个机制的过程中要做到熟练掌握Golang对于SSH、SFTP等库的操作。基本上这两个库的操作你熟悉了就能在一个程序中同时自动化操作多个服务器了。在实际工作中如果遇到类似的需求可以按照这节课所展示的技术来自动化你的需求。

思考题

其实今天的内容涉及自动化运维的范畴了我们就布置一个课外研究吧。自动化运维范畴中有一个很出名的自动化运维配置框架ansible你可以去浏览下Ansible中文权威指南网站学习一下ansible有哪些功能分享一下你的学习心得。

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