Kubernetes的Kata Containers 与 gVisor - ghostwritten

原文:https://blog.csdn.net/xixihahalelehehe/article/details/119942945

1. 容器发展

使用虚拟化技术来做一个像 Docker 一样的容器项目,并不是一个新鲜的主意。早在 Docker 项目发布之后,Google 公司就开源了一个实验性的项目,叫作 novm。这,可以算是试图使用常规的虚拟化技术来运行 Docker 镜像的第一次尝试。不过,novm 在开源后不久,就被放弃了,这对于 Google 公司来说或许不算是什么新鲜事,但是 novm 的昙花一现,还是激发出了很多内核开发者的灵感。

所以在 2015 年,几乎在同一个星期,Intel OTC (Open Source Technology Center) 和国内的 HyperHQ 团队同时开源了两个基于虚拟化技术的容器实现,分别叫做 Intel Clear ContainerrunV 项目。

而在 2017 年,借着 Kubernetes的东风,这两个相似的容器运行时项目在中立基金会的撮合下最终合并,就成了现在大家耳熟能详的 Kata Containers 项目。 由于 Kata Containers 的本质就是一个精简后的轻量级虚拟机,所以它的特点,就是“像虚拟机一样安全,像容器一样敏捷”。

而在 2018 年,Google 公司则发布了一个名叫 gVisor 的项目。gVisor 项目给容器进程配置一个用 Go 语言实现的、运行在用户态的、极小的“独立内核”。这个内核对容器进程暴露 Linux 内核 ABI,扮演着“Guest Kernel”的角色,从而达到了将容器和宿主机隔离开的目的。

难看到,无论是 Kata Containers,还是 gVisor,它们实现安全容器的方法其实是殊途同归的。这两种容器实现的本质,都是给进程分配了一个独立的操作系统内核,从而避免了让容器共享宿主机的内核。这样,容器进程能够看到的攻击面,就从整个宿主机内核变成了一个极小的、独立的、以容器为单位的内核,从而有效解决了容器进程发生“逃逸”或者夺取整个宿主机的控制权的问题。这个原理,可以用如下所示的示意图来表示清楚。

在这里插入图片描述 而它们的区别在于,Kata Containers 使用的是传统的虚拟化技术,通过虚拟硬件模拟出了一台“小虚拟机”,然后在这个小虚拟机里安装了一个裁剪后的 Linux 内核来实现强隔离。

而 gVisor 的做法则更加激进,Google 的工程师直接用 Go 语言“模拟”出了一个运行在用户态的操作系统内核,然后通过这个模拟的内核来代替容器进程向宿主机发起有限的、可控的系统调用。接下来,我就来为你详细解读一下 KataContainersgVisor 具体的设计原理。

2. KataContainers原理

在这里插入图片描述

我们前面说过,Kata Containers 的本质,就是一个轻量化虚拟机。所以当你启动一个 Kata Containers 之后,你其实就会看到一个正常的虚拟机在运行。这也就意味着,一个标准的虚拟机管理程序(Virtual Machine Manager, VMM)是运行 Kata Containers 必备的一个组件。在我们上面图中,使用的 VMM 就是 Qemu

而使用了虚拟机作为进程的隔离环境之后,Kata Containers 原生就带有了 Pod 的概念。即:这个 Kata Containers 启动的虚拟机,就是一个 Pod;而用户定义的容器,就是运行在这个轻量级虚拟机里的进程。在具体实现上,Kata Containers 的虚拟机里会有一个特殊的 Init 进程负责管理虚拟机里面的用户容器,并且只为这些容器开启 Mount Namespace。所以,这些用户容器之间,原生就是共享 Network 以及其他 Namespace 的。

此外,为了跟上层编排框架比如 Kubernetes 进行对接,Kata Containers 项目会启动一系列跟用户容器对应的 shim 进程,来负责操作这些用户容器的生命周期。当然,这些操作,实际上还是要靠虚拟机里的 Init 进程来帮你做到。而在具体的架构上,Kata Containers 的实现方式同一个正常的虚拟机其实也非常类似。这里的原理,可以用如下所示的一幅示意图来表示。

在这里插入图片描述 可以看到,当 Kata Containers 运行起来之后,虚拟机里的用户进程(容器),实际上只能看到虚拟机里的、被裁减过的 Guest Kernel,以及通过 Hypervisor 虚拟出来的硬件设备。

而为了能够对这个虚拟机的 I/O 性能进行优化,Kata Containers 也会通过 vhost 技术(比如:vhost-user)来实现 GuestHost 之间的高效的网络通信,并且使用 PCI Passthrough (PCI 穿透)技术来让 Guest 里的进程直接访问到宿主机上的物理设备。这些架构设计与实现,其实跟常规虚拟机的优化手段是基本一致的。

3. gVisor原理

相比之下,gVisor 的设计其实要更加“激进”一些。它的原理,可以用如下所示的示意图来表示清楚。

在这里插入图片描述 gVisor 工作的核心,在于它为应用进程、也就是用户容器,启动了一个名叫 Sentry 的进程。 而 Sentry 进程的主要职责,就是提供一个传统的操作系统内核的能力,即:运行用户程序,执行系统调用。所以说,Sentry 并不是使用 Go 语言重新实现了一个完整的 Linux 内核,而只是一个对应用进程“冒充”内核的系统组件。

在这种设计思想下,我们就不难理解,Sentry 其实需要自己实现一个完整的 Linux 内核网络栈,以便处理应用进程的通信请求。然后,把封装好的二层帧直接发送给 Kubernetes 设置的 Pod 的 Network Namespace 即可。

此外,Sentry 对于 Volume 的操作,则需要通过 9p 协议交给一个叫做 Gofer 的代理进程来完成。Gofer 会代替应用进程直接操作宿主机上的文件,并依靠 seccomp 机制将自己的能力限制在最小集,从而防止恶意应用进程通过 Gofer 来从容器中“逃逸”出去。

而在具体的实现上,gVisorSentry 进程,其实还分为两种不同的实现方式。这里的工作原理,可以用下面的示意图来描述清楚。 在这里插入图片描述 第一种实现方式,是使用 Ptrace 机制来拦截用户应用的系统调用(System Call),然后把这些系统调用交给 Sentry 来进行处理。这个过程,对于应用进程来说,是完全透明的。而 Sentry 接下来,则会扮演操作系统的角色,在用户态执行用户程序,然后仅在需要的时候,才向宿主机发起 Sentry 自己所需要执行的系统调用。这,就是 gVisor 对用户应用进程进行强隔离的主要手段。不过, Ptrace 进行系统调用拦截的性能实在是太差,仅能供 Demo 时使用。

而第二种实现方式,则更加具有普适性。它的工作原理如下图所示。

在这里插入图片描述

在这种实现里,Sentry 会使用 KVM 来进行系统调用的拦截,这个性能比 Ptrace 就要好很多了。当然,为了能够做到这一点,Sentry 进程就必须扮演一个 Guest Kernel 的角色,负责执行用户程序,发起系统调用。而这些系统调用被 KVM 拦截下来,还是继续交给 Sentry 进行处理。只不过在这时候,Sentry 就切换成了一个普通的宿主机进程的角色,来向宿主机发起它所需要的系统调用。

可以看到,在这种实现里,Sentry 并不会真的像虚拟机那样去虚拟出硬件设备、安装 Guest 操作系统。它只是借助 KVM 进行系统调用的拦截,以及处理地址空间切换等细节。值得一提的是,在 Google 内部,他们也是使用的第二种基于 HypervisorgVisor 实现。只不过 Google 内部有自己研发的 Hypervisor,所以要比 KVM 实现的性能还要好。

4. 对比

通过以上的讲述,相信你对 Kata Containers 和 gVisor 的实现原理,已经有一个感性的认识了。需要指出的是,到目前为止,gVisor 的实现依然不是非常完善,有很多 Linux 系统调用它还不支持;有很多应用,在 gVisor 里还没办法运行起来。 此外,gVisor 也暂时没有实现一个 Pod 多个容器的支持。当然,在后面的发展中,这些工程问题一定会逐渐解决掉的。

另外,你可能还听说过 AWS 在 2018 年末发布的一个叫做 Firecracker 的安全容器项目。这个项目的核心,其实是一个用 Rust 语言重新编写的 VMM(即:虚拟机管理器)。这就意味着, FirecrackerKata Containers 的本质原理,其实是一样的。只不过, Kata Containers 默认使用的 VMM 是 Qemu,而 Firecracker,则使用自己编写的 VMM。所以,理论上,Kata Containers 也可以使用 Firecracker 运行起来。

在性能上,KataContainers 和 KVM 实现的 gVisor 基本不分伯仲,在启动速度和占用资源上,基于用户态内核的 gVisor 还略胜一筹。但是,对于系统调用密集的应用,比如重 I/O 或者重网络的应用,gVisor 就会因为需要频繁拦截系统调用而出现性能急剧下降的情况。此外,gVisor 由于要自己使用 Sentry 去模拟一个 Linux 内核,所以它能支持的系统调用是有限的,只是 Linux 系统调用的一个子集。

不过,gVisor 虽然现在没有任何优势,但是这种通过在用户态运行一个操作系统内核,来为应用进程提供强隔离的思路,的确是未来安全容器进一步演化的一个非常有前途的方向。值得一提的是,Kata Containers 团队在 gVisor 之前,就已经 Demo 了一个名叫 Linuxd 的项目。这个项目,使用了 User Mode Linux (UML) 技术,在用户态运行起了一个真正的 Linux Kernel 来为应用进程提供强隔离,从而避免了重新实现 Linux Kernel 带来的各种麻烦。


MacOS 更改默认截图文件名

Mac中默认的中文截图名太难看/难以快速理解/自动化软件复用了。

截屏2022-02-12 上午10.15.29.png

这里记录下几个配置,多数为在终端中输入命令,可以按需更改。

一、更换前缀

defaults write com.apple.screencapture name "TheNameDesired"

重启服务

killall SystemUIServer

二、更换“下午”为英文名称

系统偏好设置->语言与地区->高级:

image-20220219104722333

把上午下午改为am/pm即可。

image-20220219104827899

三、更换默认截图的图片格式

OS X 默认识别以下图片格式:「.jpg」「.gif」「.pdf」「.png」和「.tiff」,所以你可以设置截图文件格式为上述 5 种。

defaults write com.apple.screencapture type jpg;killall SystemUIServer
defaults write com.apple.screencapture type gif;killall SystemUIServer
defaults write com.apple.screencapture type PDF;killall SystemUIServer
defaults write com.apple.screencapture type png;killall SystemUIServer
defaults write com.apple.screencapture type tiff;killall SystemUIServer

四、移除时间戳

如果不需要时间,也可以直接移除:

defaults write com.apple.screencapture "include-date" 0
killall SystemUIServer

参考资料


MacOS 文件默认列表展示

这不算个重要的东西,但是还是记录一下,太久不用Mac已经忘记如何设置了。

标题栏->显示->查看显示选项

image-20220219105944564

勾选始终以列表视图打开,然后点击用于默认

image-20220219110038527


Hidden Bar:一键折叠,给 macOS 菜单栏解压 - 少数派

原文:https://sspai.com/post/58194

一直觉得 Windows 托盘区可收纳的功能非常人性化,不像 macOS 的菜单栏势要把顶部的长条填满。

weixin

冬天看着菜单栏这些图标抱团取暖,没准还能让你感觉一丝暖意。但大部分时候都是有些碍眼,甚至在与某些软件菜单栏重合会直接隐藏。

部分软件运行在后台完全可以通过快捷键操作,例如 Magnet,这样看来不是很必要在菜单栏占坑,然而真正支持隐藏菜单栏图标的软件并不多。 Apple 官方也迟迟没有加入菜单栏收纳的功能,只能依赖第三方工具了。

Xnip2019-12-30_22-38-12

此前,大家推荐最多的这类工具是 Bartender 3,售价 15 美元。而在 macOS 10.15 上,有用户反馈会索取「全屏录制」权限(官方回应并不会记录用户隐私信息),出于隐私考虑他换用了 Hidden Bar

Xnip2019-12-30_22-43-11

Hidden Bar 是一款免费开源的工具,已经上架 Mac App Store,并且在最新的版本中加入了中文的支持。

Mockup

直接看效果,Hidden Bar 在展开的状态下还是会占用菜单栏空间,打开后菜单栏会出现「|」以及「>」或者「<」(分别代表展开和折叠两种状态)。

Mockup2

使用起来也比较简单,按住 Command 键,拖动想要折叠的图标移到「|」符号左侧就可以了,点击「>」就可以直接折叠,菜单栏也瞬间清爽了。

截屏2019-12-30下午11.15.37

「|」和「>」是可以分开的,你可以自由放置,比如,把「>」拖到最右侧,点击之后折叠的还是「|」左侧的所有图标。

不过要注意一个逻辑问题,「>」不能放置在「|」左侧,毕竟它不能把自己也折叠进去。

Xnip2019-12-30_23-22-10

Hidden Bar 可以设置用全局快捷键实现折叠/展开,使用更便捷;同时支持延时折叠菜单栏图标。

Xnip2019-12-30_23-26-13

略有一丢丢不完美的是,Hiden Bar 中文支持并不完整,不过也基本不影响使用,估计很快会完善了。

如果你也在为 macOS 菜单栏位置紧张烦恼,这款小工具或许可以给你解压,而且还是免费的,甚至会吸引一部分Bartender 3 付费用户。我也发现 GitHub 上还有一款 Dozer 的应用,在功能上与 Hidden Bar 几乎一致,只是显示图标上略有不同,暂且不知道两者是否有关联,大家有兴趣的也可以尝试。


MacOS 无共享密钥使用 L2tp

先前有过两篇 L2tp相关的配置,分别是 WinLinux 的。这篇讲一下 Mac 下的。不知道为什么,我配置的总是需要手动添加路由。有可能是哪一部分工作漏掉了,不过只要它能跑起来就行。It just works。

一、配置 L2tp

左下角新增网络连接,选择L2tp

image-20220211110945026

填写账户信息。

屏幕快照 2022-02-11 上午11.16.18

鉴定设置:

屏幕快照 2022-02-11 上午11.24.17

公司给的VPN没有共享密钥,如果你也没有,要在 /etc/ppp/ 下创建 options 文件:

sudo vim /etc/ppp/options

文件内容如下:

plugin L2TP.ppp
l2tpnoipsec

image-20220211112958756

然后开始连接。

二、添加路由

当前路由表内容如下:

netstat -rn

image-20220211094208106

无效内容太多了,只看ppp0的。

image-20220211094611522

看得出来网关地址应该是 192.168.100.254

将要访问的ip声明为走 ppp0 网卡,使用下面的命令添加路由,我假定我们要代理的ip为 10.187.0.0/24

# 添加路由
sudo route -n add -net 10.187.0.0 -netmask 255.255.255.0 192.168.100.254

# 备忘,删除路由命令
sudo route -v delete -net 10.187.0.0/24 -gateway 192.168.100.254

添加好路由,就可以访问我们想访问的资源了。完成。

参考资料


Golang 简洁架构实战 - luozhiyun

原文:https://www.luozhiyun.com/archives/640

由于golang不像java一样有一个统一的编码模式,所以我们和其他团队一样,采用了 Go 面向包的设计和架构分层这篇文章介绍的一些理论,然后再结合以往的项目经验来进行分包:

├── cmd/
│   └── main.go //启动函数
├── etc
│   └── dev_conf.yaml              // 配置文件 
├── global
│   └── global.go //全局变量引用,如数据库、kafka等
├── internal/
│       └── service/
│           └── xxx_service.go //业务逻辑处理类
│           └── xxx_service_test.go 
│       └── model/
│           └── xxx_info.go//结构体
│       └── api/
│           └── xxx_api.go//路由对应的接口实现
│       └── router/
│           └── router.go//路由
│       └── pkg/
│           └── datetool//时间工具类
│           └── jsontool//json 工具类

其实上面的这个划分只是简单的将功能分了一下包,在项目实践的过程中还是有很多问题。比如:

对于功能实现我是通过 function 的参数传递还是通过结构体的变量传递?

使用一个数据库的全局变量引用传递是否安全?是否存在过度耦合?

在代码实现过程中几乎全部都是依赖于实现,而不是依赖于接口,那么将MySQL切换为 MongDB 是不是要修改所有的实现?

所以现在在我们工作中随着代码越来越多,代码中各种 init,function,struct,全局变量感觉也越来越乱。

每个模块不独立,看似按逻辑分了模块,但没有明确的上下层关系,每个模块里可能都存在配置读取,外部服务调用,协议转换等。

久而久之服务不同包函数之间的调用慢慢演变成网状结构,数据流的流向和逻辑的梳理变得越来越复杂,很难不看代码调用的情况下搞清楚数据流向。

image-20211226215524784

不过就像《重构》中所说:先让代码工作起来-如果代码不能工作,就不能产生价值;然后再试图将它变好-通过对代码进行重构,让我们自己和其他人更好地理解代码,并能按照需求不断地修改代码。

所以我觉得是时候自我改变一下。

The Clean Architecture

在简洁架构里面对我们的项目提出了几点要求:

  1. 独立于框架。该架构不依赖于某些功能丰富的软件库的存在。这允许你把这些框架作为工具来使用,而不是把你的系统塞进它们有限的约束中。
  2. 可测试。业务规则可以在没有UI、数据库、Web服务器或任何其他外部元素的情况下被测试。
  3. 独立于用户界面。UI可以很容易地改变,而不用改变系统的其他部分。例如,一个Web UI可以被替换成一个控制台UI,而不改变业务规则。
  4. 独立于数据库。你可以把Oracle或SQL Server换成Mongo、BigTable、CouchDB或其他东西。你的业务规则不受数据库的约束。
  5. 独立于任何外部机构。事实上,你的业务规则根本不知道外部世界的任何情况。

img

上图中同心圆代表各种不同领域的软件。一般来说,越深入代表你的软件层次越高。外圆是战术实现机制,内圆的是战略核心策略。

对于我们的项目来说,代码依赖应该由外向内,单向单层依赖,这种依赖包含代码名称,或类的函数,变量或任何其他命名软件实体。

对于简洁架构来说分为了四层:

  • Entities:实体
  • Usecase:表达应用业务规则,对应的是应用层,它封装和实现系统的所有用例;
  • Interface Adapters:这一层的软件基本都是一些适配器,主要用于将用例和实体中的数据转换为外部系统如数据库或Web使用的数据;
  • Framework & Driver:最外面一圈通常是由一些框架和工具组成,如数据库Database, Web框架等;

那么对于我的项目来说,也分为了四层:

  • models
  • repo
  • service
  • api

代码分层

models

封装了各种实体类对象,与数据库交互的、与UI交互的等等,任何的实体类都应该放在这里。如:

import "time"

type Article struct {
    ID        int64     `json:"id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    UpdatedAt time.Time `json:"updated_at"`
    CreatedAt time.Time `json:"created_at"`
}

repo

这里存放的是数据库操作类,数据库CRUD都在这里。需要注意的是,这里不包含任何的业务逻辑代码,很多同学喜欢将业务逻辑也放到这里。

如果使用 ORM,那么这里放入的ORM操作相关的代码;如果使用微服务,那么这里放的是其他服务请求的代码;

service

这里是业务逻辑层,所有的业务过程处理代码都应该放在这里。这一层会决定是请求 repo 层的什么代码,是操作数据库还是调用其他服务;所有的业务数据计算也应该放在这里;这里接受的入参应该是controller传入的。

api

这里是接收外部请求的代码,如:gin对应的handler、gRPC、其他REST API 框架接入层等等。

面向接口编程

除了 models 层,层与层之间应该通过接口交互,而不是实现。如果要用 service 调用 repo 层,那么应该调用 repo 的接口。那么修改底层实现的时候我们上层的基类不需要变更,只需要更换一下底层实现即可。

例如我们想要将所有文章查询出来,那么可以在 repo 提供这样的接口:

package repo

import (
    "context"
    "my-clean-rchitecture/models"
    "time"
)

// IArticleRepo represent the article's repository contract
type IArticleRepo interface {
    Fetch(ctx context.Context, createdDate time.Time, num int) (res []models.Article, err error)
}

这个接口的实现类就可以根据需求变更,比如说当我们想要 mysql 来作为存储查询,那么只需要提供一个这样的基类:

type mysqlArticleRepository struct {
    DB *gorm.DB
}

// NewMysqlArticleRepository will create an object that represent the article.Repository interface
func NewMysqlArticleRepository(DB *gorm.DB) IArticleRepo {
    return &mysqlArticleRepository{DB}
}

func (m *mysqlArticleRepository) Fetch(ctx context.Context, createdDate time.Time,
    num int) (res []models.Article, err error) {

    err = m.DB.WithContext(ctx).Model(&models.Article{}).
        Select("id,title,content, updated_at, created_at").
        Where("created_at > ?", createdDate).Limit(num).Find(&res).Error
    return
}

如果改天想要换成 MongoDB 来实现我们的存储,那么只需要定义一个结构体实现 IArticleRepo 接口即可。

那么在 service 层实现的时候就可以按照我们的需求来将对应的 repo 实现注入即可,从而不需要改动 service 层的实现:

type articleService struct {
    articleRepo repo.IArticleRepo
}

// NewArticleService will create new an articleUsecase object representation of domain.ArticleUsecase interface
func NewArticleService(a repo.IArticleRepo) IArticleService {
    return &articleService{
        articleRepo: a,
    }
}

// Fetch
func (a *articleService) Fetch(ctx context.Context, createdDate time.Time, num int) (res []models.Article, err error) {
    if num == 0 {
        num = 10
    }
    res, err = a.articleRepo.Fetch(ctx, createdDate, num)
    if err != nil {
        return nil, err
    }
    return
}

依赖注入 DI

依赖注入,英文名dependency injection,简称 DI 。DI 以前在java工程里面经常遇到,但是在 go 里面很多人都说不需要,但是我觉得在大型软件开发过程中还是有必要的,否则只能通过全局变量或者方法参数来进行传递。

至于具体什么是 DI,简单来说就是被依赖的模块,在创建模块时,被注入到(即当作参数传入)模块的里面。想要更加深入的了解什么是 DI 这里再推荐一下 Dependency injection Inversion of Control Containers and the Dependency Injection pattern 这两篇文章。

如果不用 DI 主要有两大不方便的地方,一个是底层类的修改需要修改上层类,在大型软件开发过程中基类是很多的,一条链路改下来动辄要修改几十个文件;另一方面就是就是层与层之间单元测试不太方便。

因为采用了依赖注入,在初始化的过程中就不可避免的会写大量的new,比如我们的项目中需要这样:

package main

import (
    "my-clean-rchitecture/api"
    "my-clean-rchitecture/api/handlers"
    "my-clean-rchitecture/app"
    "my-clean-rchitecture/repo"
    "my-clean-rchitecture/service"
)

func main() { 
    // 初始化db
    db := app.InitDB() 
    //初始化 repo
    repository := repo.NewMysqlArticleRepository(db)
    //初始化service
    articleService := service.NewArticleService(repository)
    //初始化api
    handler := handlers.NewArticleHandler(articleService)
    //初始化router
    router := api.NewRouter(handler)
    //初始化gin
    engine := app.NewGinEngine()
    //初始化server
    server := app.NewServer(engine, router)
    //启动
    server.Start()
}

那么对于这么一段代码,我们有没有办法不用自己写呢?这里我们就可以借助框架的力量来生成我们的注入代码。

在 go 里面 DI 的工具相对来说没有 java 这么方便,技术框架一般主要有:wire、dig、fx 等。由于wire是使用代码生成来进行注入,性能会比较高,并且它是 google 推出的 DI 框架,所以我们这里使用 wire 进行注入。

wire的要求很简单,新建一个wire.go文件(文件名可以随意),创建我们的初始化函数。比如,我们要创建并初始化一个server对象,我们就可以这样:

//+build wireinject

package main

import (
    "github.com/google/wire"
    "my-clean-rchitecture/api"
    "my-clean-rchitecture/api/handlers"
    "my-clean-rchitecture/app"
    "my-clean-rchitecture/repo"
    "my-clean-rchitecture/service"
)

func InitServer() *app.Server {
    wire.Build(
        app.InitDB,
        repo.NewMysqlArticleRepository,
        service.NewArticleService,
        handlers.NewArticleHandler,
        api.NewRouter,
        app.NewServer,
        app.NewGinEngine)
    return &app.Server{}
}

需要注意的是,第一行的注解:+build wireinject,表示这是一个注入器。

在函数中,我们调用wire.Build()将创建 Server 所依赖的类型的构造器传进去。写完wire.go文件之后执行wire命令,就会自动生成一个wire_gen.go文件。

// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//+build !wireinject

package main

import (
    "my-clean-rchitecture/api"
    "my-clean-rchitecture/api/handlers"
    "my-clean-rchitecture/app"
    "my-clean-rchitecture/repo"
    "my-clean-rchitecture/service"
)

// Injectors from wire.go:

func InitServer() *app.Server {
    engine := app.NewGinEngine()
    db := app.InitDB()
    iArticleRepo := repo.NewMysqlArticleRepository(db)
    iArticleService := service.NewArticleService(iArticleRepo)
    articleHandler := handlers.NewArticleHandler(iArticleService)
    router := api.NewRouter(articleHandler)
    server := app.NewServer(engine, router)
    return server
}

可以看到wire自动帮我们生成了InitServer方法,此方法中依次初始化了所有要初始化的基类。之后在我们的main函数中就只需调用这个InitServer即可。

func main() {
    server := InitServer()
    server.Start()
}

测试

在上面我们定义好了每一层应该做什么,那么对于每一层我们应该都是可单独测试的,即使另外一层不存在。

  • models 层:这一层就很简单了,由于没有依赖任何其他代码,所以可以直接用go 的单测框架直接测试即可;
  • repo 层:对于这一层来说,由于我们使用了 mysql 数据库,那么我们需要 mock mysql,这样即使不用连mysql 也可以正常测试,我这里使用 github.com/DATA-DOG/go-sqlmock 这个库来 mock 我们的数据库;
  • service 层:因为 service 层依赖了 repo 层,因为它们之间是通过接口来关联,所以我这里使用 github.com/golang/mock/gomock 来 mock repo 层;
  • api 层:这一层依赖 service 层,并且它们之间是通过接口来关联,所以这里也可以使用 gomock 来 mock service 层。不过这里稍微麻烦了一点,因为我们接入层用的是 gin,所以还需要在单测的时候模拟发送请求;

由于我们是通过 github.com/golang/mock/gomock 来进行 mock ,所以需要执行一下代码生成,生成的mock 代码我们放入到 mock 包中:

mockgen -destination .\mock\repo_mock.go -source .\repo\repo.go -package mock

mockgen -destination .\mock\service_mock.go -source .\service\service.go -package mock

上面这两个命令会通过接口帮我自动生成 mock 函数。

repo 层测试

在项目中,由于我们用了 gorm 来作为我们的 orm库,所以我们需要使用 github.com/DATA-DOG/go-sqlmock 结合 gorm 来进行 mock:

func getSqlMock() (mock sqlmock.Sqlmock, gormDB *gorm.DB) {
    //创建sqlmock
    var err error
    var db *sql.DB
    db, mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
    if err != nil {
        panic(err)
    }
    //结合gorm、sqlmock
    gormDB, err = gorm.Open(mysql.New(mysql.Config{
        SkipInitializeWithVersion: true,
        Conn:                      db,
    }), &gorm.Config{})
    if nil != err {
        log.Fatalf("Init DB with sqlmock failed, err %v", err)
    }
    return
}

func Test_mysqlArticleRepository_Fetch(t *testing.T) {
    createAt := time.Now()
    updateAt := time.Now()
    //id,title,content, updated_at, created_at
    var articles = []models.Article{
        {1, "test1", "content", updateAt, createAt},
        {2, "test2", "content2", updateAt, createAt},
    }

    limit := 2
    mock, db := getSqlMock()

    mock.ExpectQuery("SELECT id,title,content, updated_at, created_at FROM `articles` WHERE created_at > ? LIMIT 2").
        WithArgs(createAt).
        WillReturnRows(sqlmock.NewRows([]string{"id", "title", "content", "updated_at", "created_at"}).
            AddRow(articles[0].ID, articles[0].Title, articles[0].Content, articles[0].UpdatedAt, articles[0].CreatedAt).
            AddRow(articles[1].ID, articles[1].Title, articles[1].Content, articles[1].UpdatedAt, articles[1].CreatedAt))

    repository := NewMysqlArticleRepository(db)
    result, err := repository.Fetch(context.TODO(), createAt, limit)

    assert.Nil(t, err)
    assert.Equal(t, articles, result)
}

service 层测试

这里主要就是用我们 gomock 生成的代码来 mock repo 层:

func Test_articleService_Fetch(t *testing.T) {
    ctl := gomock.NewController(t)
    defer ctl.Finish()
    now := time.Now()
    mockRepo := mock.NewMockIArticleRepo(ctl)

    gomock.InOrder(
        mockRepo.EXPECT().Fetch(context.TODO(), now, 10).Return(nil, nil),
    )

    service := NewArticleService(mockRepo)

    fetch, _ := service.Fetch(context.TODO(), now, 10)
    fmt.Println(fetch)
}

api 层测试

对于这一层,我们不仅要 mock service 层,还需要发送 httptest 来模拟请求发送:

func TestArticleHandler_FetchArticle(t *testing.T) {

    ctl := gomock.NewController(t)
    defer ctl.Finish()
    createAt, _ := time.Parse("2006-01-02", "2021-12-26")
    mockService := mock.NewMockIArticleService(ctl)

    gomock.InOrder(
        mockService.EXPECT().Fetch(gomock.Any(), createAt, 10).Return(nil, nil),
    )

    article := NewArticleHandler(mockService)

    gin.SetMode(gin.TestMode)

    // Setup your router, just like you did in your main function, and
    // register your routes
    r := gin.Default()
    r.GET("/articles", article.FetchArticle)

    req, err := http.NewRequest(http.MethodGet, "/articles?num=10&create_date=2021-12-26", nil)
    if err != nil {
        t.Fatalf("Couldn't create request: %v\n", err)
    }

    w := httptest.NewRecorder()
    // Perform the request
    r.ServeHTTP(w, req)

    // Check to see if the response was what you expected
    if w.Code != http.StatusOK {
        t.Fatalf("Expected to get status %d but instead got %d\n", http.StatusOK, w.Code)
    }
}

总结

以上就是我对 golang 的项目中发现问题的一点点总结与思考,思考的先不管对不对,总归是解决了我们当下的一些问题。不过,项目总归是需要不断重构完善的,所以下次有问题的时候下次再改呗。

对于我上面的总结和描述感觉有不对的地方,请随时指出来一起讨论。

项目代码位置:https://github.com/devYun/go-clean-architecture

Reference


脚本化 tmux

这是很久前想整理的文档,太久了已经遗忘了,这里就只记录我的脚本吧。一些命令可以直接参考手册。

自动化tmux主要场景是快速启动工作台,有些命令已经记不清了,也一股脑放上来,以后如果有需要再修正好了。

可以查看 man 手册对照:https://man7.org/linux/man-pages/man1/tmux.1.html

比如最常用的和 panes 相关的操作,可以查看 https://man7.org/linux/man-pages/man1/tmux.1.html#WINDOWS_AND_PANES

# kelu.sh

#!/bin/bash

name="kelu"
time=$(date +%Y-%m-%d)
cmd1="tail -f ~/Workspace/xxs.log"
cmd2="iftop"
cmd3="docker ps -a | grep -E 'openresty|nginx'"

create_w (){
  client=$1
  tmux_name="$name:$client"
  tmux neww -a -n "$client" -t $name
  tmux send -t $tmux_name "ssh sh4" Enter
  tmux send -t $tmux_name "$cmd1" Enter
  tmux split-window -h -t $tmux_name                         # 纵向
  tmux send -t $tmux_name "ssh sh2" Enter
  tmux send -t $tmux_name "$cmd2" Enter
  tmux send -t $tmux_name "LTttt3BD"
  tmux split-window -v -t $tmux_name                         # 横向
#  tmux send -t $tmux_name "ssh sh1" Enter
  tmux send -t $tmux_name "$cmd2" Enter
  tmux send -t $tmux_name "LTttt3BD"

  tmux select-pane -t 0                                      # 切换第一个
  tmux split-window -v -t $tmux_name                         # 横向
  
  # 开启新的 windows
  tmux neww -a -n "blog" -t $name
  tmux send -t "$name:blog" "cd Workspace/blog" Enter
  tmux send -t "$name:blog" "nvim" Enter
  tmux send -t "$name:blog" ":NvimTreeToggle" Enter
  
  # 选择第一个 pannel
  tmux selectw -t $tmux_name
}

tmux new -s $name -d # 新tmux session
create_w main # 新windows,多个panel
# 实际上可以创建多个windows,在这里我起了一个统一的名叫 kelu 的session, main是第一个windows,例如我创建main2这个windows如下:
# create_w main2

tmux attach -t $name
exit

在命令行中运行

kelu.sh

即可自动运行预设的窗口和命令行。

如果我启动了多个windows,我希望他们能每隔一段时间自动切换,相应脚本如下:

# switch.sh

#!/bin/bash

init=1
num=$1
tm=$2

w=$init
if [ ! $num ]; then
  num=6
fi

if [ ! $tm ]; then
  tm=10
fi

while true; do
  w=$(( $w + 1 ))
  tmux select-window -t $name:$w
  if [ $w -gt $num ]; then
    w=$init
  fi
  sleep $tm;
done

参考资料