普通视图

发现新文章,点击刷新页面。
昨天以前火丁笔记

关于一个打包下载的需求

作者 老王
2022年7月1日 14:01

前些天遇到一个「打包下载」的需求,在调研过程中走了一些弯路,本文记录一下。

比如说某网站有一个文件列表,用户点哪个就可以下载哪个,如果用户想下载多个,无非就是多点几次而已。于是需求来了:当用户想下载多个文件的时候,可以通过一次点击完成打包下载操作。

听起来似乎并不复杂,服务端可以把用户想要下载的文件打包成一个新文件,然后用户点一次就可以下载了,但是这样做有以下几个缺点:

  • 浪费了时间,多了创建新文件的流程。
  • 浪费了空间,同样的文件被多次存储。
  • 用户体验差,下载必须要等到新文件创建好才能开始。

不难得出结论:动态流式下载才是正解,同事提到 tar 可以搞定,于是研究一下:

shell> cat test_0.txt 
xxx
xxx
shell> cat test_1.txt 
yyy
yyy
shell> tar cf test.tar test_0.txt test_1.txt
shell> cat test.tar 
test_0.txt00006440...01014257504126011510 0ustar rootrootxxx
xxx
test_1.txt00006440...01014257504241011507 0ustar rootrootyyy
yyy

如上可见,tar 文件的格式非常简单,多个文件的内容从上到下依次排列,只不过每个文件内容的前面附加了一个头,其中保存了诸如文件名,权限之类的信息。

看上去用 tar 的话确实可以搞定动态流式下载,不过 tar 有个缺点,普通用户搞不清 tar 文件类型是什么东西,相比较而言,他们更乐于接受 zip 文件类型。

不过 zip 文件类型的格式可要比 tar 复杂,我从 wikipedia 找到下图:

zip

zip

对于凡夫俗子的我来说,想要通过手撸 zip 格式来实现动态流式下载绝非易事,就在举棋不定之际,我突然发现 golang 的 zip 标准库已经实现了 Writer 接口,这就意味着,我们只要结合使用 zip.NewWriter 和 http.ResponseWriter 就能实现我们的目的:

package main

import (
	"archive/zip"
	"fmt"
	"io"
	"net/http"
	"os"
)

func main() {
	http.HandleFunc("/test", test)
	http.ListenAndServe(":8080", nil)
}

func test(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/zip")
	w.Header().Set("Content-Disposition", "attachment; filename=test.zip")
	writer := zip.NewWriter(w)
	for i := 0; i < 2; i++ {
		name := fmt.Sprintf("test_%d.txt", i)
		srcFile, err := os.Open(name)
		if err != nil {
			panic(err)
		}
		defer srcFile.Close()
		dstFile, err := writer.Create(name)
		if err != nil {
			panic(err)
		}
		if _, err := io.Copy(dstFile, srcFile); err != nil {
			panic(err)
		}
	}
	writer.Close()
}

如上代码编译运行后,打开浏览器,执行 http://localhost:8080/test 即可看到效果。

一个没什么用的转义技巧

作者 老王
2021年11月16日 15:54

最近我用命令行工具来测试 rpc 服务,因为此命令行工具要求输入数据是 json 格式,所以免不了要在 shell 环境构造一些 json 字符串:

shell> echo '{"content": "$(base64 foo.docx)", "type": "docx"}'

如上,我想把文件 foo.docx 的内容通过 base64 编码,然后放到 json 字符串里,但是它并不能正常工作,因为它是一个单引号字符串,命令在单引号里的是不能展开的,那换成双引号可不可以?当然可以,但是因为 json 本身包含很多双引号,所以免不了转义:

shell> echo "{\"content\": \"$(base64 foo.docx)\", \"type\": \"docx\"}"

不瞒大家说,我最开始写出如上代码的时候,脑瓜子嗡嗡的,好在最后我想到了一个绝妙的解决方法:既然用双引号字符串不可避免会带来转义问题,那么就放弃双引号字符串,而是使用单引号字符串,然后把里面的命令用单引号包起来:

shell> echo '{"content": "'$(base64 foo.docx)'", "type": "docx"}'

为什么这样可以?其实如上单引号字符串实际上是三个字符串,分别是:

  • 「'{“content”: “‘」
  • 「$(base64 foo.docx)」
  • 「'”, “type”: “docx”}’」

与其说是用单引号把命令包起来,倒不如说是用单引号把命令隔离出来,有点四两拨千斤的感觉,脑瓜子再也不会嗡嗡的了,整个世界清静了…

在docker环境导入私有仓库的问题

作者 老王
2021年8月24日 15:45

最近我遇到了一个在 docker 环境导入私有仓库的问题:一个 Golang 项目,使用 gitlab ci 来发布,通过 gitlab runner 调用 docker-compose 来打包,但是在构建时失败了。

让我们重回案发现场,看看问题是怎么产生的:

首先是 .gitlab-ci.yml 文件,其相关代码片段内容如下:

build_job:
  stage: build
  script:
    - make docker-build

然后是 Makefile 文件,其相关代码片段内容如下:

.PHONY: docker-build
docker-build:
	@docker-compose build

接着是 docker-compose.yml 文件,其相关代码片段内容如下:

build:
  context: .
  dockerfile: Dockerfile

最后是 Dockfile 文件,其相关代码片段内容一下:

FROM golang:1.17 AS builder
WORKDIR /go/src/app
COPY . .
RUN go build

结果在 build 的时候报错了:

fatal: could not read Username for ‘https://git.domain.com’: terminal prompts disabled

因为 git.domain.com 是一个私有仓库,所以问题乍一看上去会以为是 GOPRIVATE 和 GOPROXY 的配置有问题,不过我的配置都是 OK 的:

shell> go env -w GOPRIVATE=git.domain.com
shell> go env -w GOPROXY=https://goproxy.cn,direct

实际上,根本原因是因为访问私有仓库的时候是需要用户名和密码的,但是在 docker 容器里获取不到用户名密码,所以就报错了。下面看看我是如何解决问题的:

第一次尝试

既然问题出在用户名密码上,那么把仓库改成公开的不就可以了么?可惜结果报错:

Visibility level public is not allowed in a private group.

我用的是 gitlab,它不允许在私有组里搞一个公开项目。

第二次尝试

既然搞不成公开项目,那么就想办法传递用户名密码吧,不过我们在使用 git 的时候,一般不会直接使用用户名密码,而是使用 KEY 来访问仓库,下面举例说明一下如何传递私钥参数 SSH_PRIVATE_KEY(其中牵扯到一个 docker 构建参数的概念):

首先因为此类信息比较敏感,所以应该避免硬编码,我们选择在 gitlab 里创建它:

Secret variables: settings > Pipelines

Secret variables: settings > Pipelines

接着是 docker-compose.yml 文件,其相关代码片段内容如下:

build:
  context: .
  dockerfile: Dockerfile
  args:
    - SSH_PRIVATE_KEY

最后是 Dockfile 文件,其相关代码片段内容一下:

FROM golang:1.17 AS builder
ARG SSH_PRIVATE_KEY
WORKDIR /go/src/app
COPY . .
RUN umask 0077 \
    && mkdir -p ~/.ssh \
    && echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa \
    && ssh-keyscan git.domain.com >> ~/.ssh/known_hosts \
    && git config --global url."git@git.domain.com:".insteadOf https://git.domain.com/
RUN go build

此方法可以解决问题,但是把敏感信息传来传去总觉得不安心,容易出问题,资料:Access Private Repositories from Your Dockerfile Without Leaving Behind Your SSH Keys

第三次尝试

如果不想把敏感信息传来传去,那么还有没有安全的解决方案呢?答案是肯定的!我们只要在 gitlab runner 里执行「go mod vendor」就可以了,这是因为 gitlab runner 已经缓存了 git 认证信息,它可以访问所有的私有仓库,当执行「go mod vendor」后,项目依赖就都被放到 vendor 目录里了,接下来当执行到 Dockerfile 的 COPY 指令时,依赖就被自然而然的拷贝到了容器中,从而不用再联网执行 git 下载。

下面是修改后的 .gitlab-ci.yaml 文件,其相关代码片段内容如下:

build_job:
  stage: build
  script:
    - go mod vendor
    - make docker-build

也就是说,我们只加了一行代码「go mod vendor」,就解决了问题,是不是很简洁。最后友情提示一下:记得把 vendor 目录放到 .gitignore 里哦。

记又一次对Makefile的重构

作者 老王
2021年8月21日 14:34

我平常有一个习惯,就是不断看以前写的代码,想着有没有哪些方面可以改进,如果每天能把代码可读性量变​ 1%,那么日积月累就是质变:前些天我们写过一次对 Makefile 的重构,去掉了一处重复代码的坏味道,没过多久我便又发现了一处重复代码的坏味道,本文就让我们看看如何消灭它!

让我们先把问题的来龙去脉搞清楚,在 Golang 项目里,一般推荐在根目录创建一个名为 tools.go 的文件,里面记录本项目依赖的相关工具,比如我的某个项目的 tools.go 如下:

// +build tools

package tools

import (
	// _ "github.com/cosmtrek/air"
	// _ "github.com/goreleaser/goreleaser"
	_ "github.com/bufbuild/buf/cmd/buf"
	_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
	_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
	_ "github.com/securego/gosec/v2/cmd/gosec"
	_ "github.com/tomwright/dasel/cmd/dasel"
	_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
	_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)

如此一来,当执行「go mod tidy」的时候,依赖工具的版本信息就会记录到 go.mod,接下来一般推荐在 Makefile 里创建一个 dep 操作,用来安装(make dep)依赖工具:

.PHONY: dep
dep:
	@go install \
		github.com/bufbuild/buf/cmd/buf \
		github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway \
		github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2 \
		github.com/securego/gosec/v2/cmd/gosec \
		github.com/tomwright/dasel/cmd/dasel \
		google.golang.org/grpc/cmd/protoc-gen-go-grpc \
		google.golang.org/protobuf/cmd/protoc-gen-go

看上去不错,但是细心的你估计已经发现重复代码的坏味道了:tools.go 和 Makefile 文件内容重复了,以后如果想要增加一个依赖工具的话,那么两个文件都要改!

下面让我们看看如何重构:tools.go 和 Makefile 比起来,肯定 tools.go 更重要,它是不能改的,所以我们要去掉 Makefile 里的重复代码,更具体点来说是最好能在 Makefile 里通过 解析 tools.go 来确定想要执行的 go install 操作,这不就是 awk 擅长的工作么:

.PHONY: dep
dep:
	@awk '$$1 == "_" { print $$2 | "xargs go install" }' ./tools.go

看,通过一行 awk 代码,我们神奇的去掉了原本一坨重复代码,完美!

关于OCR项目的流水账

作者 老王
2021年8月16日 16:58

最近一直在开发某个 OCR 项目:底层用的是 ABBYY 提供的 FineReader 引擎,应用层把 FineReader 包装成 gRPC 对外提供服务,因为 FineReader 项目是 C++ 实现的,而我们团队使用的编程语言是 Golang,所以二者间通过 CGO 来完成交互。整个项目没有什么特殊的需求,只是鉴于 OCR 耗时较长,为了提升产品体验,要求在处理过程中:客户端可以主动退出;服务端能够实时返回已处理百分比。下面是根据需求画出来的流程图:

流程图

流程图

看上去很简单,不过我还是遇到不少问题,虽然这些问题主要都是一些细枝末节,基本上和 OCR 没什么关系,但是对别的项目还是会有所帮助的,下面让我一一道来。

代码冗长

编程里最常见的坏味道就是代码冗长,比如我的 main.go 就是如此,它足足有几百行代码之多,里面充斥着各种初始化配置,日志之类的操作。

为了规避此类问题,我引入了一个 initializer 的概念,用来统一初始化操作,比如 viper:

package initializer

import (
	"strings"

	"github.com/fsnotify/fsnotify"
	log "github.com/sirupsen/logrus"
	"github.com/spf13/viper"
)

func Viper(env string) error {
	if env == "" {
		env = "development"
	}
	viper.AutomaticEnv()
	viper.SetConfigName(env)
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	viper.AddConfigPath(".")
	viper.AddConfigPath("./configs")
	viper.AddConfigPath("../configs")
	if err := viper.ReadInConfig(); err != nil {
		return err
	}
	viper.WatchConfig()
	viper.OnConfigChange(func(e fsnotify.Event) {
		log.Debugf("config file changed: %s", e.Name)
	})
	return nil
}

有了 initializer 之后,原本挤在一起的代码就可以分而治之,同时因为函数签名统一返回 error,所以可以统一进行错误处理,最终 main.go 代码行数大大降低:

var version string

func main() {
	var env string
	cobra.EnableCommandSorting = false
	cobra.OnInitialize(func() {
		check(initializer.Viper(env))
		check(initializer.Logrus())
		// ...
	})
	rootCmd := &cobra.Command{
		Use:     filepath.Base(os.Args[0]),
		Version: version,
	}
	rootCmd.PersistentFlags().StringVarP(
		&env, "env", "e", os.Getenv("SERVICE_ENV"), "env",
	)
	rootCmd.AddCommand(cmd.NewServerCmd())
	check(rootCmd.Execute())
}

func check(err error) {
	if err != nil {
		panic(err)
	}
}

除了 initializer 以外,其实我还引入了一个 provider 的概念,用来获取 sarama 等实例,也可以降低代码冗长的坏味道,提升复用性,篇幅所限,本文就不做赘述了。

同步异步

因为我之前一直在学习 Kafka,所以最初在架构选型的时候完全忽略了 gRPC 之类的同步架构,一门心思的想要以 Kafka 为中心打造一个基于事件的异步架构。此类极端的思想往往是个坏信号,实际上这就跟政治一样,不管是极左还是极右,通常都不可取。关于同步和异步,各取所长才是最合理的选择,判断方法:如果是业务逻辑的实现部分,那么倾向于选择使用同步;如果是业务逻辑完成之后的后续通知部分:强烈建议选择使用异步。具体请参考「走出微服务误区:避免从单体到分布式单体」。

Kafka 客户端

既然 Kafka 在架构中的地位如此重要,那么需要选择一下用哪个客户端,其 Golang 客户端主要有:saramaconfluent-kafka-gokafka-go,优缺点如下:

  • sarama:它是最流行也是最难用的,文档很烂,API 封装太低级,暴露了过多 Kafka 协议的细节,而且还不支持 context 等新的 Golang 特色,实现上它把所有值都当指针传递,导致过多的动态内存分配,频繁的垃圾回收,大量的内存使用。
  • confluent-kafka-go:它是基于 librdkafka 实现的 CGO,这意味着使用了这个包,你的代码就会依赖 C 库,和 sarama 相比,它的文档更好,但是同样不支持 context。
  • kafka-go:前面关于 saram 和 confluent-kafka-go 的坏话都是它说的。

看上去似乎 kafka-go 最好,confluent-kafka-go 次之,sarama 最烂,可是当我问一个鹅厂小伙伴的时候,他说他们都用 sarama,信大厂得永生,于是乎我也决定选 sarama 了,事后证明这可能是一个糟糕的选择,sarama 虽然很流行,但是确实很难用。但是不管怎么说,使用 sarama 的案例相对更多,用起来也更安心些,不过用之前要清楚坑在哪:

Sarama 的版本

一开始用 sarama 的时候,就遭到了当头棒喝,遇到了如下错误:

ERROR: Failed to open Kafka producer: kafka: client has run out of available brokers to talk to (Is your cluster reachable?)

反复确认才发现是版本问题,我们的服务端版本比较低(0.11.0.0),翻看 sarama 的 changelog,发现是在 1.27.1 开始切换到高版本的,如此说来只要使用 1.27.0 就可以了,同时务必记得把版本依赖写入 go.mod 文件中:

replace github.com/Shopify/sarama => github.com/Shopify/sarama v1.27.0

多个 goroutines 的协同

前面提到 sarama 有一个问题是暴露了过多 Kafka 协议的细节,这一点在使用 consumer 的时候可见一斑:因为 sarama 暴露了分区的细节,所以带来了很多麻烦,比如要关闭 consumer 的话,不得不先关闭每一个分区上的 PartitionConsumer,最后才可以关闭 consumer。不过话说回来,正好可以借机练习一下多个 goroutines 的协同:

type Watchman struct {
	waitGroup sync.WaitGroup
	consumer  sarama.Consumer
	closing   chan struct{}
}

func NewWatchmanFromConsumer(c sarama.Consumer) *Watchman {
	return &Watchman{
		consumer: c,
		closing:  make(chan struct{}),
	}
}

func (w *Watchman) Watch(topic string) (<-chan *sarama.ConsumerMessage, error) {
	msg := make(chan *sarama.ConsumerMessage)
	pids, err := w.consumer.Partitions(topic)
	if err != nil {
		return nil, err
	}
	for _, pid := range pids {
		pc, err := w.consumer.ConsumePartition(topic, pid, sarama.OffsetNewest)
		if err != nil {
			return nil, err
		}
		w.waitGroup.Add(1)
		go func() {
			defer w.waitGroup.Done()
			for {
				select {
				case message := <-pc.Messages():
					msg <- message
				case <-w.closing:
					pc.Close()
					return
				}
			}
		}()
	}
	return msg, nil
}

func (w *Watchman) Close() {
	close(w.closing)
	w.waitGroup.Wait()
	w.consumer.Close()
}

说明:留意代码中是如何通过 waitGroup 和 closing 来处理多个 goroutines 的协同的。

编译错误

一般编译 Golang 代码不会遇到什么错误,但是因为我们的项目牵扯到 C++,所以在编译过程中还是遇到了一些莫名其妙的问题,下面逐一记录一下:

error adding symbols: DSO missing from command line:

在老版本的 binutils 里,ld 会自动递归地解析链接的 lib,不过从 2.22(ld -v)开始,ld 缺省激活了 –no-copy-dt-needed-entries 选项,如此一来,ld 不会再自动递归地解析链接的 lib,而是需要由用户来手动指定。知道了来龙去脉,不难想到如下解决方案:

  • 手动:通过 -l 选项手动加载需要的库,比如需要 libz.so,就设置 -lz
  • 自动:在 LDFLAGS 里添加 -Wl,–copy-dt-needed-entries 选项

推荐资料:libpthread.so.0: error adding symbols: DSO missing from command line

undefined reference to `__cxa_throw_bad_array_new_length’:

编译 libstdc++ 时,会使用命令 msgfmt。而 msgfmt 依赖 libstdc++.so.6,但编译时,gcc的编译系统会把 msgfmt 的依赖指向其自身的 libstdc++.so.6,而不是系统自带的libstdc++.so.6。如果 gcc 的版本比较老,就会导致 libstdc++.so.6 与 msgfmt 不兼容。

知道了来龙去脉,不难想到解决方案就是使用新版 gcc,更具体一点说是使用版本不低于 4.9 的 gcc(CentOS 7 上的 gcc 版本一般是 4.8.5),不过不推荐直接从源代码安装新版 gcc,其困难程度不是一般人能接受的,相对更可取的方法是通过 scl 安装 devtoolset:

shell> gcc -v
gcc version 4.8.5
shell> yum install centos-release-scl
shell> yum install devtoolset-7
shell> scl enable devtoolset-7 bash
shell> gcc -v
gcc version 7.3.1
shell> exit
shell> gcc -v
gcc version 4.8.5

关于 devtoolset 还有一个冷知识:devtoolset 和 gcc 的版本对应关系如下:

  • devtoolset-3: gcc 4.9
  • devtoolset-4: gcc 5
  • devtoolset-6: gcc 6
  • devtoolset-7: gcc 7
  • devtoolset-8: gcc 8

你会发现没有版本 5,原因在 Release Notes for Red Hat Developer Toolset 6.0 里说了:

The version number of Red Hat Developer Toolset has been raised from 4.1 to 6.0 to align with the major version of GCC. There is no Red Hat Developer Toolset 5.

嗯,我承认这个无聊的问题困扰了我好几年,最终知道原因后感觉真是怅然若失啊。

条件编译

因为我们的服务底层是 FineReader 引擎,而且我们只有其 Linux 版本的 SDK,加上我们的本地开发环境是 MAC 系统,所以一开始我们在本地是没办法编译的,每次修改完代码我都会把代码传到 Linux 上编译,真是让人焦躁啊,好在 Golang 支持通过文件名来进行条件编译,比如我把原本的 abbyy.go 文件按操作系统拆分出 _linux.go 和 _darwin.go:

abbyy_linux.go:

package doc

// #cgo CFLAGS: -I .
// #cgo LDFLAGS: ${SRCDIR}/vendor/libabbyy.a -L /opt/ABBYY/FREngine12/Bin -lFREngine -lPortLayer -lstdc++
// #include <stdlib.h>
/*
void loadAbbyy();
int runAbbyy(const char *source, const char *destination, const char *status);
void unloadAbbyy();
*/
import "C"
import "unsafe"

func doJob(source, destination, status string) bool {
	csource := C.CString(source)
	cdestination := C.CString(destination)
	cstatus := C.CString(status)
	C.loadAbbyy()
	defer func() {
		C.unloadAbbyy()
		C.free(unsafe.Pointer(csource))
		C.free(unsafe.Pointer(cdestination))
		C.free(unsafe.Pointer(cstatus))
	}()
	return C.runAbbyy(csource, cdestination, cstatus) == 0
}

abbyy_darwin.go:

package doc

func doJob(source, destination, status string) bool {
	return false
}

拆分后,虽然我的 MAC 系统还是不能使用 FineReader 引擎,但是至少能够在本地开发环境正常编译了,处理一些非 CGO 类的问题绰绰有余了。

测试 gRPC

开发完成 gRPC 服务后,免不了要时不时的测试它,最开始我用的是 grpcurl,类似:

shell> grpcurl -plaintext -emit-defaults \
    -d '{"source":"/tmp/01.pdf","destination":"/tmp/02.pdf"}' \
    <address> doc.v1.AbbyyService.OCR

不过命令行用起来总是不如 web 方便,于是借助 grpc-gateway 集成了 swagger:

syntax = "proto3";

package doc.v1;

option go_package = "gitlab.test.com/doc/pkg/proto/doc/v1";

import "google/api/annotations.proto";
import "protoc-gen-openapiv2/options/annotations.proto";

option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
    info: {
        version: "1.0";
    };
};

service AbbyyService {
    rpc OCR(OCRRequest) returns (stream OCRResponse) {
        option (google.api.http) = {
            post: "/ocr"
            body: "*"
        };
    }
}

message OCRRequest {
    string source = 10;
    string destination = 20;
}

message OCRResponse {
    string action = 10;
    int32 percentage = 20;
}

通过 protoc 编译:

shell> protoc -I /path/to/proto \
    --go_out=./pkg/proto \
    --go_opt=paths=source_relative \
    --go-grpc_out=./pkg/proto \
    --go-grpc_opt=paths=source_relative \
    --grpc-gateway_out=./pkg/proto \
    --grpc-gateway_opt=paths=source_relative \
    --openapiv2_out=./api \
    /path/to/proto/*.proto

其中 protoc-gen-openapiv2 插件能够生成 swagger 所需的 json文件,更多 openapiv2 的使用例子可以参考:Complete list of swagger options to protobuf file,最终效果如下:

swagger

swagger

顺便说一句,为了部署方便,我用「//go:embed *」语法把整个 swagger ui 打包进二进制文件了,不得不说,embed 真是爽啊,有兴趣的可以参考:Go embed 简明教程

公共 proto

在编写 proto 的时候,我们用到了 googleapisgrpc-gateway 等项目里的公共 proto,这里牵扯到一个如何导入公共 proto 的问题,最常见的方法是把这些公共 proto 直接拷贝到项目目录中,但是如果有很多的项目需要用到这些公共 proto 的话,那么就不得不拷贝很多个副本,于是又有人把公共 proto 统一保存到独立的仓库中,然后其他项目在构建的时候都引用它,如此也不错,不过总觉得差点啥,最终我发现了完美的解决方案 buf

先编写 buf.yaml 文件,主要用来声明依赖那些公共 proto:

version: v1beta1
deps:
  - buf.build/beta/googleapis
  - buf.build/grpc-ecosystem/grpc-gateway
build:
  roots:
    - ./pkg/proto

再编写 buf.gen.yaml 文件,主要用来声明使用哪些插件,如何生成需要的文件:

version: v1beta1
plugins:
  - name: go
    out: ./pkg/proto
    opt:
      - paths=source_relative
  - name: go-grpc
    out: ./pkg/proto
    opt:
      - paths=source_relative
  - name: grpc-gateway
    out: ./pkg/proto
    opt:
      - paths=source_relative
  - name: openapiv2
    out: ./api

准备好后,先用「buf mod update」命令生成 buf.lock 锁定版本信息,再用「buf generate」命令就可以生成我们要的各种 go 文件和 json 文件了:

shell> buf mod update
shell> buf generate

可见使用 buf 比直接使用 protoc 要方便很多,而且还有很多高级功能,详见 buf 文档

依赖工具

在使用 grpc-gateway 的时候,我们用到了其中的 protoc-gen-openapiv2 工具,实际上,grpc-gateway 有两个大版本,protoc-gen-openapiv2 在 v2 版本中,而在 v1 版本中对应的工具叫做 protoc-gen-swagger,很容易混淆,可见明确依赖工具的版本非常重要。

目前推荐的方法是在项目根目录创建名为 tools.go 的文件来记录依赖工具,比如:

// +build tools

package tools

import (
	// _ "github.com/cosmtrek/air"
	// _ "github.com/Shopify/sarama/tools/kafka-console-consumer"
	// _ "github.com/Shopify/sarama/tools/kafka-console-producer"
	_ "github.com/bufbuild/buf/cmd/buf"
	_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
	_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
	_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
	_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)

如此一来,当执行「go mod tidy」的时候,依赖工具的版本信息也会被 go.mod 记录下来,后续别人接手项目后,就很清楚的知道依赖什么工具,分别是什么版本了。

实战CGO

作者 老王
2021年7月3日 14:55

某项目要集成 PDF 文件的 OCR 功能,不过由于此功能技术难度太大,网络上找不到靠谱的开源实现,最终不得不选择 ABBYY FineReader Engine 的付费服务。可惜 ABBYY 只提供了 C++ 和 Java 两种编程语言的 SDK,而我们的项目采用的编程语言是 Golang,此时通常的集成方法是使用 C++ 或 Java 实现一个服务,然后在 Golang 项目里通过 RPC 调用服务,不过如此一来明显增加了系统的复杂度,好在 Golang 支持 CGO,让我们可以很方便的在 Golang 中使用 C 模块,本文总结了我在学习 CGO 过程中的心得体会。

Hello World

让我们看看一个 CGO 版本的 Hello, world 大概长什么样:

package main

/*
#include <stdio.h>

void say(const char *s) {
    puts(s);
}
*/
import "C"

func main() {
    hello()
}

func hello() {
    s := C.CString("Hello, World\n")
    C.say(s)
}

如上所示,通过「import “C”」来激活 CGO,并且所有 C 语言相关的代码都以注释的形式放在此行之上,中间不允许有空行,这样我们就可以在 Golang 代码里使用 C 模块了,看上去很简单,不过代码里存在内存泄漏,让我们修改一下代码,使问题更明显一点:

package main

/*
#include <stdio.h>

void say(const char *s) {
    puts(s);
}
*/
import "C"

func main() {
    for {
        hello()
    }
}

func hello() {
    s := C.CString("Hello, World\n")
    C.say(s)
}

运行程序后,我们可以单独开一个命令行窗口,通过运行 top 命令来监控进程的内存变化,会发现在循环调用 C 模块之后,进程的内存占用不断增加,究其原因,是因为通过 C.CString 创建的变量,会在 C 语言层面上分配内存,而在 Golang 语言层面上是不会负责管理相关内存的,所以我们需要通过 C.free 手动释放相关内存:

package main

/*
#include <stdio.h>
#include <stdlib.h>

void say(const char *s) {
    puts(s);
}
*/
import "C"
import "unsafe"

func main() {
    for {
        hello()
    }
}

func hello() {
    s := C.CString("Hello, World\n")
    defer C.free(unsafe.Pointer(s))
    C.say(s)
}

说明:代码中的 unsafe.Pointer 相当于 C 语言中的 void *。

In Action

有些读者看到这里可能会有疑问:虽然 CGO 让我们可以在 Golang 里使用 C,但是文章开头提到的 ABBYY 并没有 C 的 SDK,只有 C++ 的 SDK,那么 CGO 支持 C++ 么?答案是否定的,不过我们可以通过 C 来适配 C++。

以 ABBYY 为例,假设它的安装目录是 /opt/ABBYY/FREngine12,并且通过 ldconfig 把 /opt/ABBYY/FREngine12/Bin 目录加入到动态链接库的查找目录:

shell> echo "/opt/ABBYY/FREngine12/Bin" > /etc/ld.so.conf.d/abbyy.conf
shell> ldconfig

准备工作做好后使用 /opt/ABBYY/FREngine12/Samples/Hello 例子做代码范本:

先编写 OCR.cpp 文件的内容,不用在意技术细节,我放这些代码只是为了备份:

#include <string>
#include "AbbyyException.h"
#include "BstrWrap.h"
#include "FREngineLoader.h"
#include "./OCR.h"

using namespace std;

void load() {
    LoadFREngine();
}

void unload() {
    UnloadFREngine();
}

void process(const char *inPath, const char *outPath) {
    string file = outPath;
    string extension = file.substr(file.find_last_of(".") + 1);
    FileExportFormatEnum format;

    if (extension == "pdf") {
        format = FEF_PDF;
    } else if (extension == "doc" || extension == "docx") {
        format = FEF_DOCX;
    } else if (extension == "ppt" || extension == "pptx") {
        format = FEF_PPTX;
    } else if (extension == "xls" || extension == "xlsx") {
        format = FEF_XLSX;
    } else {
        return;
    }

    const wchar_t *language = L"ChinesePRC,ChineseTaiwan,English";
    CSafePtr<IFRDocument> frDocument = 0;
    CSafePtr<IDocumentProcessingParams> documentProcessingParams;
    CSafePtr<IPageProcessingParams> pageProcessingParams;
    CSafePtr<IRecognizerParams> recognizerParams;

    try {
        CheckResult(FREngine->CreateFRDocumentFromImage(CBstr(inPath), 0, &frDocument));
        CheckResult(FREngine->CreateDocumentProcessingParams(&documentProcessingParams));
        CheckResult(documentProcessingParams->get_PageProcessingParams(&pageProcessingParams));
        CheckResult(pageProcessingParams->get_RecognizerParams(&recognizerParams));
        CheckResult(recognizerParams->SetPredefinedTextLanguage(CBstr(language)));
        CheckResult(frDocument->Process(documentProcessingParams));
        CheckResult(frDocument->Export(CBstr(outPath), format, 0));
    } catch (...) {
        return;
    }
}

再编写 OCR.h 文件的内容,要特别注意其中的「extern “C”」,有了它,当编译的时候,就会把 C++ 中的方法名链接成 C 的风格,如此一来,CGO 才能识别它:

#ifdef __cplusplus
extern "C" {
#endif
void load();
void unload();
void process(const char *inPath, const char *outPath);
#ifdef __cplusplus
}
#endif

我们可以通过 nm 命令查看某个方法名在使用 extern “C” 前后的差异:

// Before
shell> nm OCR.o | grep process
0000000000000016 T _Z7processPKcS0_
// After
shell> nm OCR.o | grep process
0000000000000016 T process

最后编写 OCR.go 文件的内容,因为 C/C++ 代码量比较大,所以在使用 CGO 的时候直接把 C/C++ 代码写在注释中就显得不合适了,此时更合适的方法是链接库:

package main

// #cgo CFLAGS: -I .
// #cgo LDFLAGS: -L . -L /opt/ABBYY/FREngine12/Bin/ -lFREngine -lOCR -lstdc++
// #include <stdlib.h>
// #include "OCR.h"
import "C"
import (
	"flag"
	"os"
	"unsafe"
)

func main() {
	flag.Parse()

	if flag.NArg() != 2 {
		os.Exit(1)
	}

	C.load()
	inPath := C.CString(flag.Arg(0))
	outPath := C.CString(flag.Arg(1))

	defer func() {
		C.unload()
		C.free(unsafe.Pointer(inPath))
		C.free(unsafe.Pointer(outPath))
	}()

	C.process(inPath, outPath)
}

假设目标文件都已经就绪,那么让我们分别看看如何构建静态链接库和动态链接库:

先看静态链接库,只要通过如下 ar 命令即可,在最终编译程序的时候,静态链接库会被编译到程序里,所以运行时不存在依赖问题,当然代价就是文件尺寸相对较大:

shell> ar -r libOCR.a *.o

再看动态链接库,只要通过如下 gcc 命令即可,和静态链接库相比,虽然它运行时存在依赖问题,但是它生成的文件尺寸相对较小,不过需要提醒的是,在之前编译目标文件的时候,需要在 CFLAGS 或 CXXFLAGS 参数中需要加入 -fpic 或者 -fPIC 选项,以便实现地址无关,至于 -fpic 和 -fPIC 的区别,可以参考 Shared Libraries

shell> gcc -shared -o libOCR.so *.o
shell> cp libOCR.so /opt/ABBYY/FREngine12/Bin/

动态链接库还有一个优点是更新方便,如果多个程序依赖同一个动态链接库的时候,那么当动态链接库有问题的时候,直接更新它即可,相反如果多个程序依赖同一个静态链接库,那么当静态链接库有问题的时候,你不得不重新编译每一个程序。不过动态链接库的依赖关系本身很容易出问题,下图是我的 OCR 程序依赖关系,有点复杂啊:

动态链接

动态链接

本文仅是 CGO 的入门笔记,想进一步了解的话,推荐阅读「CGO 编程」,收摊儿。

浅谈pprof

作者 老王
2021年6月6日 15:37

对于大多数 Gopher 而言,一般平时最主要的工作内容除了实现各种无聊的业务逻辑之外,剩下的就是解决各种琐碎的问题。比如:查询性能瓶颈在哪里?查询内存泄漏在哪里?好在 pprof 是处理此类问题的利器,共有两套标准库,分别适用于不同的场景:

命令行工具「go test」就包含了 runtime/pprof,相关参数请参考「go help testflag」:

shell> go test -cpuprofile cpu.out -memprofile mem.out -bench .

不过和 runtime/pprof 相比,更常用的是 net/http/pprof,接下来我们主要通过它来解决一些常见问题,想要激活 net/http/pprof 的话很简单,只要导入对应的包并启动服务即可:

import _ "net/http/pprof"

func main() {
	_ = http.ListenAndServe("localhost:6060", nil)
}

需要注意的是,千万别让外网访问到 pprof,否则可能会导致出现安全问题。有兴趣的读者可以尝试通过 google 搜索「intitle:/debug/pprof/ inurl:/debug/pprof/」看看反面例子。

Profile

pprof 预置了很多种不同类型的 profile,我们可以按照自己的需要选择:

  • allocs:A sampling of all past memory allocations
  • block:Stack traces that led to blocking on synchronization primitives
  • goroutine:Stack traces of all current goroutines
  • heap:A sampling of memory allocations of live objects
  • mutex:Stack traces of holders of contended mutexes
  • profile:CPU profile
  • threadcreate:Stack traces that led to the creation of new OS threads

其中最常用的是 profile 和 heap,分别用来诊断 CPU 和内存问题。

CPU Profiling

演示代码模拟了 CPU 密集型任务(onCPU)和耗时的网络请求(offCPU):

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
	"runtime"
	"time"

	"github.com/felixge/fgprof"
)

const cpuTime = 1000 * time.Millisecond

func main() {
	runtime.SetBlockProfileRate(1)
	runtime.SetMutexProfileFraction(1)

	go func() {
		http.Handle("/debug/fgprof", fgprof.Handler())
		log.Println(http.ListenAndServe(":6060", nil))
	}()

	for {
		cpuIntensiveTask()
		slowNetworkRequest()
	}
}

func cpuIntensiveTask() {
	start := time.Now()

	for time.Since(start) <= cpuTime {
		for i := 0; i < 1000; i++ {
			_ = i
		}
	}
}

func slowNetworkRequest() {
	resp, err := http.Get("http://httpbin.org/delay/1")

	if err != nil {
		log.Fatal(err)
	}

	defer resp.Body.Close()
}

通过 go tool pprof 查看 /debug/pprof/profile:

go tool pprof -http :8080 http://localhost:6060/debug/pprof/profile

结果发现 profile 只能检测到 onCPU(也就是 cpuIntensiveTask)部分,却不能检测到 offCPU (也就是 slowNetworkRequest)部分:

profile

profile

为了检测 offCPU 部分,我们引入 fgprof,通过 go tool pprof 查看 /debug/fgprof:

go tool pprof -http :8080 http://localhost:6060/debug/fgprof

结果发现 fgprof 不仅能检测到 onCPU(也就是 cpuIntensiveTask)部分,还能检测到 offCPU (也就是 slowNetworkRequest)部分:

fgprof

fgprof

实际应用中,最好对你的瓶颈是 onCPU 还是 offCPU 有一个大体的认识,进而选择合适的工具,如果不确定就直接用 fgprof,不过需要注意的是 fgprof 对性能的影响较大。

Memory Profiling

演示代码模拟了一段有内存泄漏问题的程序:

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
	"time"
)

func main() {
	go func() {
		log.Println(http.ListenAndServe(":6060", nil))
	}()

	for {
		leak()
	}
}

func leak() {
	s := make([]string, 10)

	for i := 0; i < 10000000; i++ {
		s = append(s, "leak")

		if (i % 10000) == 0 {
			time.Sleep(1 * time.Second)
		}

		_ = s
	}
}

通过 go tool pprof 查看 /debug/pprof/heap(这次不用 web,用命令行):

heap

heap

通过 top 命令可以很直观的看出哪里可能出现了内存泄漏问题。不过这里有一个需要说明的问题是内存占用大的地方本身可能是正常的,与内存的绝对值大小相比,我们更应该关注的是不同时间点内存相对变化大小,这里可以使用参数 base 或者 diff_base:

heap with base

heap with base

需要说明的是,内存采样大小依据的是 runtime.MemProfileRate,缺省值是 512KB,也就是说每分配 512KB 内存,采样一次。有时候,我们查询 heap 的结果为空,多半就是因为这个配置太大的缘故,可以通过类似 GODEBUG=”memprofilerate=1″ 来调整大小。

本文篇幅有限,无法列举更多的例子,有兴趣的读者推荐参考「golang pprof 实战」。

❌
❌