<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>火丁笔记</title>
	<atom:link href="http://huoding.com/feed" rel="self" type="application/rss+xml" />
	<link>https://huoding.com</link>
	<description>多研究些问题，少谈些主义。</description>
	<lastBuildDate>Fri, 01 Jul 2022 06:27:51 +0000</lastBuildDate>
	<language>zh-CN</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.2.2</generator>
	<item>
		<title>关于一个打包下载的需求</title>
		<link>https://huoding.com/2022/07/01/984</link>
					<comments>https://huoding.com/2022/07/01/984#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Fri, 01 Jul 2022 06:01:41 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=984</guid>

					<description><![CDATA[前些天遇到一个「打包下载」的需求，在调研过程中走了一些弯路，本文记录一下。 比如 &#8230; <a href="https://huoding.com/2022/07/01/984">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>前些天遇到一个「打包下载」的需求，在调研过程中走了一些弯路，本文记录一下。</p>
<p><span id="more-984"></span></p>
<p>比如说某网站有一个文件列表，用户点哪个就可以下载哪个，如果用户想下载多个，无非就是多点几次而已。于是需求来了：当用户想下载多个文件的时候，可以通过一次点击完成打包下载操作。</p>
<p>听起来似乎并不复杂，服务端可以把用户想要下载的文件打包成一个新文件，然后用户点一次就可以下载了，但是这样做有以下几个缺点：</p>
<ul>
<li>浪费了时间，多了创建新文件的流程。</li>
<li>浪费了空间，同样的文件被多次存储。</li>
<li>用户体验差，下载必须要等到新文件创建好才能开始。</li>
</ul>
<p>不难得出结论：动态流式下载才是正解，同事提到 tar 可以搞定，于是研究一下：</p>
<pre>shell&gt; cat test_0.txt 
xxx
xxx
shell&gt; cat test_1.txt 
yyy
yyy
shell&gt; tar cf test.tar test_0.txt test_1.txt
shell&gt; cat test.tar 
test_0.txt00006440...01014257504126011510 0ustar rootrootxxx
xxx
test_1.txt00006440...01014257504241011507 0ustar rootrootyyy
yyy</pre>
<p>如上可见，tar 文件的格式非常简单，多个文件的内容从上到下依次排列，只不过每个文件内容的前面附加了一个头，其中保存了诸如文件名，权限之类的信息。</p>
<p>看上去用 tar 的话确实可以搞定动态流式下载，不过 tar 有个缺点，普通用户搞不清 tar 文件类型是什么东西，相比较而言，他们更乐于接受 zip 文件类型。</p>
<p>不过 zip 文件类型的格式可要比 tar 复杂，我从 <a href="https://en.wikipedia.org/wiki/ZIP_(file_format)" target="_blank" rel="noopener">wikipedia</a> 找到下图：</p>
<div id="attachment_985" style="width: 1034px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2022/07/zip.png"><img aria-describedby="caption-attachment-985" decoding="async" class="wp-image-985 size-full" src="https://huoding.com/wp-content/uploads/2022/07/zip.png" alt="zip" width="1024" height="555" /></a><p id="caption-attachment-985" class="wp-caption-text">zip</p></div>
<p>对于凡夫俗子的我来说，想要通过手撸 zip 格式来实现动态流式下载绝非易事，就在举棋不定之际，我突然发现 golang 的 zip 标准库已经实现了 Writer 接口，这就意味着，我们只要结合使用 zip.NewWriter 和 http.ResponseWriter 就能实现我们的目的：</p>
<pre>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 &lt; 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()
}
</pre>
<p>如上代码编译运行后，打开浏览器，执行 http://localhost:8080/test 即可看到效果。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2022/07/01/984/feed</wfw:commentRss>
			<slash:comments>4</slash:comments>
		
		
			</item>
		<item>
		<title>如何用eBPF分析Golang应用</title>
		<link>https://huoding.com/2021/12/12/970</link>
					<comments>https://huoding.com/2021/12/12/970#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Sun, 12 Dec 2021 05:06:01 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=970</guid>

					<description><![CDATA[当医生遇到疑难杂症时，那么可以上 X 光机，有没有病？病在哪里？一照便知！当程序 &#8230; <a href="https://huoding.com/2021/12/12/970">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>当医生遇到疑难杂症时，那么可以上 X 光机，有没有病？病在哪里？一照便知！当程序员遇到疑难杂症时，那么多半会查日志，不过日志的位置都是预埋的，可故障的位置却总是随机的，很多时候当我们查到关键的地方时却总是发现没有日志，此时就无能为力了，如果改代码加日志重新发布的话，那么故障往往就不能稳定复现了。回想医生的例子，他们可没有给病人加日志，可为什么他们能找到问题的，因为他们有 X 光机，所以对程序员来说，我们也需要有我们的 X 光机，它就是 <a href="https://ebpf.io/" target="_blank" rel="noopener">eBPF</a>。</p>
<p><span id="more-970"></span></p>
<p>为了降低使用 eBPF 的门槛，社区开发了 <a href="https://github.com/iovisor/bcc" target="_blank" rel="noopener">bcc</a>，<a href="https://github.com/iovisor/bpftrace" target="_blank" rel="noopener">bpftrace</a> 等工具，因为 bpftrace 在语法上贴近 awk，所以我一眼就爱上了，本文将通过它来讲解如何用 eBPF 分析 Golang 应用。</p>
<h2>通过 bpftrace 分析 golang 方法的参数和返回值</h2>
<p>下面是演示代码 main.go，我们的目标是通过 bpftrace 分析 sum 方法的输入输出：</p>
<pre>package main

func main() {
	println(sum(11, 22))
}

func sum(a, b int) int {
	return a + b
}</pre>
<p>在编译的时候，记得关闭内联，否则一旦 sum 被内联了，eBPF 就没法加探针了：</p>
<pre>shell&gt; go build -gcflags="-l" ./main.go
shell&gt; objdump -t ./main | grep -w sum
000000000045dd60 g F .text 0000000000000033 main.sum</pre>
<p>准备工作做好之后，我们就可以通过如下 bpftrace 命令来监控 sum 的输入输出了：</p>
<pre>shell&gt; bpftrace -e '
    uprobe:./main:main.sum {printf("a: %d b: %d\n", sarg0, sarg1)}
    uretprobe:./main:main.sum {printf("retval: %d\n", retval)}
'
a: 11 b: 22
retval: 33</pre>
<p>不过测试发现，如上 bpftrace 命令仅在 go1.17 之前的版本工作正常，在 go1.17 之后的版本，sargx 变量取不到数据，这是因为从 go.1.17 开始，参数不再保存在栈里，而是保存在寄存器中，关于这一点在 <a href="https://go.googlesource.com/go/+/refs/heads/dev.regabi/src/cmd/compile/internal-abi.md" target="_blank" rel="noopener">Go internal ABI specification</a> 中有详细的描述：</p>
<blockquote><p>amd64 architecture<br />
The amd64 architecture uses the following sequence of 9 registers for integer arguments and results:<br />
RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11</p></blockquote>
<p>让我们通过 gdb 来验证这一点：</p>
<pre>shell&gt; gdb ./main
(gdb) # 设置断点
(gdb) b main.sum
(gdb) # 运行
(gdb) r
(gdb) # 查看寄存器
(gdb) i r
rax 0xb 11
rbx 0x16 22</pre>
<p>如上可见：main.sum 的第一个参数保存在 rax 寄存器，第二个参数保存在 rbx 寄存器，和 Go internal ABI specification 中的描述一致。</p>
<p>搞清楚这些之后，我们就知道在 go1.17 以后的版本，如何用 bpftrace 监控输入输出了：</p>
<pre>shell&gt; bpftrace -e '
    uprobe:./main:main.sum {printf("a: %d b: %d\n", reg("ax"), reg("bx"))}
    uretprobe:./main:main.sum {printf("retval: %d\n", retval)}
'
a: 11 b: 22
retval: 33</pre>
<p>说到这，细心的读者可能已经发现：我们一直在讨论整形，如果是字符串该怎么办？我们不妨构造一个字符串的例子再来测试一下，本次测试是在 go1.17 下进行的：</p>
<p>下面是演示代码 main.go，我们的目标是通过 bpftrace 分析 concat 方法的输入输出：</p>
<pre>package main

func main() {
	println(concat("ab", "cd"))
}

func concat(a, b string) string {
	return a + b
}</pre>
<p>让我们通过 gdb 来看看 go1.17 中字符串参数是怎么传递的：</p>
<pre>shell&gt; go build -gcflags="-l" ./main.go
shell&gt; gdb ./main
(gdb) # 设置断点
(gdb) b main.concat
(gdb) # 运行
(gdb) r
(gdb) # 查看参数
(gdb) i args
x = 0x461513 "ab"
y = 0x461515 "cd"
(gdb) # 查看寄存器
(gdb) i r
rax 0x461513 4592915
rbx 0x2 2
rcx 0x461515 4592917
rdi 0x2 2
(gdb) # 检查地址 0x461513
(gdb) x/2cb 0x461513
0x461513: 97 'a' 98 'b'
(gdb) # 检查地址 0x461515
(gdb) x/2cb 0x461515
0x461515: 99 'c' 100 'd'
(gdb) # 查看寄存器
(gdb) i r
rax 0xc00001a0e0 824633827552
rbx 0x4 4
(gdb) # 检查地址 0xc00001a0e0
(gdb) x/4cb 0xc00001a0e0
0xc00001a0e0: 97 'a' 98 'b' 99 'c' 100 'd'</pre>
<p>如上可见：当我们给 main.sum 方法传递两个字符串参数的时候，实际上是占用 4 个寄存器，每个字符串参数占用两个寄存器，分别是地址和长度，正好贴合字符串的数据结构：</p>
<pre>type StringHeader struct {
	Data uintptr
	Len  int
}</pre>
<p>了解了相关知识之后，我们就可以通过如下 bpftrace 命令来监控 sum 的输入输出了：</p>
<pre>shell&gt; bpftrace -e '
    uprobe:./main:main.concat {
        printf("a: %s b: %s\n",
            str(reg("ax"), reg("bx")),
            str(reg("cx"), reg("di"))
        )
    }
    uretprobe:./main:main.concat {
        printf("retval: %s\n", str(reg("ax"), reg("bx")))
        // printf("retval: %s\n", str(retval))
    }
'
a: ab b: cd
retval: abcd</pre>
<p>以上，我们介绍了当参数和返回值是整形或字符串时，如何用 bpftrace 分析 golang 程序，如果类型更复杂的话，比如说是一个 struct，那么原理也是类似的，篇幅所限，本文就不再赘述了，有兴趣的读者可以参考文章后面的相关链接。</p>
<p>补充说明：通过 uretprobe 检查 golang 方法的返回值可能存在风险。这是因为 uretprobe 是通过修改栈来加入探针的， 这和 golang 本身对栈的管理存在冲突的可能：</p>
<ul>
<li><a href="https://github.com/golang/go/issues/22008" target="_blank" rel="noopener">runtime: ebpf uretprobe support</a></li>
<li><a href="https://github.com/iovisor/bcc/issues/1320" target="_blank" rel="noopener">Go crash with uretprobe</a></li>
</ul>
<p>虽然在 golang 程序中使用 uretprobe 是不安全的，但是好在 uprobe 还可以放心用。其实换个角度看，即便我们不使用 uretprobe，依然有办法获取返回时，比如我们可以通过在 本方法 return 的时候或者在一个方法开始的时候设置一个 uprobe 来获取返回值。</p>
<h2>通过 bpftrace 分析 golang 中 slice 是如何扩容的</h2>
<p>本例代码依然以 go1.17 版本为例，它的逻辑就是不断追加数据，迫使 slice 扩容：</p>
<pre>package main

import "time"

func main() {
	var s []int
	for range time.Tick(time.Microsecond) {
		s = append(s, 1)
	}
	_ = s
}</pre>
<p>控制 slice 扩容行为的方法是 runtime.growslice，对应的签名如下：</p>
<pre>// It is passed the slice element type, the old slice,
// and the desired new minimum capacity,
func growslice(et *_type, old slice, cap int) slice

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}
</pre>
<p>这里面，看上去 cap 是我们最关心的参数，不过从源代码注释中看，此 cap 的含义是「the desired new minimum capacity」，并不是真正的 cap，实际上 old 参数里也有一个 cap，它才是我们需要的 cap：</p>
<ul>
<li>et *_type 是一个指针，占用一个寄存器</li>
<li>old slice 是一个 struct，占用三个寄存器，分别是 slice 类型定义中的 array, len, cap</li>
<li>cap int 是一个整形，占用一个寄存器</li>
</ul>
<p>所以我们需要的 cap 实际保存在第 4 个寄存器，也就是 RDI，我们用 reg(&#8220;di&#8221;) 就可以拿到对应的数据：</p>
<pre>shell&gt; bpftrace -e '
    uprobe:./main:runtime.growslice {printf("cap: %d\n", reg("di"))}
'
cap: 0
cap: 0
cap: 2
cap: 0
cap: 1
cap: 2
cap: 0
cap: 0
cap: 0
cap: 1
cap: 2
cap: 4
cap: 8
cap: 16
cap: 32
cap: 64
cap: 128
cap: 256
cap: 512
cap: 1024
cap: 1280
cap: 1696
cap: 2304
cap: 3072
cap: 4096
cap: 5120
cap: 7168
cap: 9216</pre>
<p>前面有一些噪音数据，可以忽略，从 1 开始，每次扩容都会翻倍，一直到 1024，接着从 1024 扩容到 1280，是 1.25 倍，然后从 1280 扩容到 1696，是 1.325 倍。整个分析过程中，我们没有手动加任何日志，仅依赖 bpftrace 观测到的数据。</p>
<p>本文介绍了 eBPF 最基本的用法，想深入了解 eBPF 的话推荐大家继续阅读如下资料：</p>
<ul>
<li><a href="http://shangzhibo.tv/watch/10201448" target="_blank" rel="noopener">聊聊风口上的 eBPF</a></li>
<li><a href="https://www.bilibili.com/video/BV19U4y1N7PM" target="_blank" rel="noopener">eBPF 与 Go，超能力组合</a></li>
<li><a href="https://chenjiandongx.me/2021/02/06/bpf-crash-in-golang/" target="_blank" rel="noopener">BPF 在 Golang 中 Crash 导致用户进程奔溃</a></li>
<li><a href="https://github.com/DavadDi/bpf_study" target="_blank" rel="noopener">bpf study</a></li>
<li><a href="https://www.brendangregg.com/blog/2017-01-31/golang-bcc-bpf-function-tracing.html" target="_blank" rel="noopener">Golang bcc/BPF Function Tracing</a></li>
<li><a href="https://medium.com/bumble-tech/bpf-and-go-modern-forms-of-introspection-in-linux-6b9802682223" target="_blank" rel="noopener">BPF and Go: Modern forms of introspection in Linux</a></li>
<li><a href="https://blog.0x74696d.com/posts/challenges-of-bpf-tracing-go/" target="_blank" rel="noopener">Challenges of BPF Tracing Go</a></li>
</ul>
<p>我会不定期的汇总上面的资料，大家如果有好的资料也请告诉我，谢谢。</p>
<p>&nbsp;</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/12/12/970/feed</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title>白话Golang单元测试</title>
		<link>https://huoding.com/2021/11/28/968</link>
					<comments>https://huoding.com/2021/11/28/968#respond</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Sun, 28 Nov 2021 03:27:27 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=968</guid>

					<description><![CDATA[最近学习某个 Golang 单元测试的课程，发现其中推荐使用 gomonkey  &#8230; <a href="https://huoding.com/2021/11/28/968">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>最近学习某个 Golang 单元测试的课程，发现其中推荐使用 <a href="https://github.com/agiledragon/gomonkey" target="_blank" rel="noopener">gomonkey</a> 这种黑科技，让人略感意外，毕竟在软件开发领域，诸如依赖注入之类的概念已经流传了几十年了，本文希望通过一个例子的演化过程，来总结出 Golang 单元测试的最佳实战。</p>
<p><span id="more-968"></span></p>
<p>既然是白话，那么我们得想一个通俗易懂的例子，就拿普通人来说吧：活着是为了什么，好好学习，买房，结婚，任意一个环节出现意外，整个人生就会偏离轨道。下面我用 Golang 代码来描述活着的过程，其中好好学习，买房，结婚都可能受到不可控外界因素的影响，比如好好学习遇上教培跑路，买房遇上银行限贷，结婚遇上彩礼涨价。</p>
<p>下面问题来了：请为「Live」编写单元测试，要求覆盖率达到 100%。</p>
<pre>package main

import (
	"errors"
	"math/rand"
)

// Live 活着
func Live(money1, money2, money3 int64) error {
	if err := GoodGoodStudy(money1); err != nil {
		return err
	}
	if err := BuyHouse(money2); err != nil {
		return err
	}
	if err := Marry(money3); err != nil {
		return err
	}
	return nil
}

// GoodGoodStudy 好好学习
func GoodGoodStudy(money int64) error {
	if rand.Intn(100) &gt; 0 {
		return errors.New("error")
	}
	_ = money
	return nil
}

// BuyHouse 买房
func BuyHouse(money int64) error {
	if rand.Intn(100) &gt; 0 {
		return errors.New("error")
	}
	_ = money
	return nil
}

// Marry 结婚
func Marry(money int64) error {
	if rand.Intn(100) &gt; 0 {
		return errors.New("error")
	}
	_ = money
	return nil
}</pre>
<p>既然单元测试要求达到 100% 的覆盖率，那么我们就必须测试每一个可能的分支：</p>
<ul>
<li>GoodGoodStudy 异常</li>
<li>GoodGoodStudy 正常；BuyHouse 异常</li>
<li>GoodGoodStudy 正常；BuyHouse 正常；Marry 异常</li>
<li>GoodGoodStudy 正常；BuyHouse 正常；Marry 正常</li>
</ul>
<h2>第一版单元测试</h2>
<p>对 Live 而言，GoodGoodStudy，BuyHouse 和 Marry 都属于外部依赖，通过使用 gomonkey，我们可以在运行时动态替换掉他们的实现，从而确保流程进入预定分支。在断言部分我们使用了 <a href="https://github.com/stretchr/testify" target="_blank" rel="noopener">testify</a>，它比直接使用标准库中的 <a href="https://pkg.go.dev/testing" target="_blank" rel="noopener">testing</a> 包方便很多。</p>
<pre>package main

import (
	"errors"
	"testing"

	"github.com/stretchr/testify/assert"

	. "github.com/agiledragon/gomonkey/v2"
)

func Test_Live1(t *testing.T) {
	patches := NewPatches()
	// GoodGoodStudy error
	patches.ApplyFunc(GoodGoodStudy, func(int64) error {
		return errors.New("error")
	})
	assert.Error(t, Live(100, 100, 100))
	patches.Reset()
	// BuyHouse error
	patches.ApplyFunc(GoodGoodStudy, func(int64) error {
		return nil
	})
	patches.ApplyFunc(BuyHouse, func(int64) error {
		return errors.New("error")
	})
	assert.Error(t, Live(100, 100, 100))
	patches.Reset()
	// Marry error
	patches.ApplyFunc(GoodGoodStudy, func(int64) error {
		return nil
	})
	patches.ApplyFunc(BuyHouse, func(int64) error {
		return nil
	})
	patches.ApplyFunc(Marry, func(int64) error {
		return errors.New("error")
	})
	assert.Error(t, Live(100, 100, 100))
	patches.Reset()
	// ok
	patches.ApplyFunc(GoodGoodStudy, func(int64) error {
		return nil
	})
	patches.ApplyFunc(BuyHouse, func(int64) error {
		return nil
	})
	patches.ApplyFunc(Marry, func(int64) error {
		return nil
	})
	assert.NoError(t, Live(100, 100, 100))
	patches.Reset()
}
</pre>
<p>第一版单元测试存在的问题：原始代码十几行，单元测试代码几十行。在大话西游中，至尊宝在梦中叫了晶晶的名字 98 次，叫了紫霞的名字 784 次。而在我们的单元测试中，GoodGoodStudy 正常的状态写了三次，BuyHouse 正常的状态写了两次，虽然远比至尊宝重复的次数少，但重复始终是个坏味道。</p>
<h2>第二版单元测试</h2>
<p>通过使用 OutputCell，我们可以一次性控制多个状态变化，从而去除重复的坏味道：</p>
<pre>package main

import (
	"errors"
	"testing"

	"github.com/stretchr/testify/assert"

	. "github.com/agiledragon/gomonkey/v2"
)

func Test_Live2(t *testing.T) {
	patches := NewPatches()
	defer patches.Reset()
	output := []OutputCell{
		{Values: Params{errors.New("error")}, Times: 1},
		{Values: Params{nil}, Times: 3},
	}
	patches.ApplyFuncSeq(GoodGoodStudy, output)
	output = []OutputCell{
		{Values: Params{errors.New("error")}, Times: 1},
		{Values: Params{nil}, Times: 2},
	}
	patches.ApplyFuncSeq(BuyHouse, output)
	output = []OutputCell{
		{Values: Params{errors.New("error")}, Times: 1},
		{Values: Params{nil}, Times: 1},
	}
	patches.ApplyFuncSeq(Marry, output)
	// GoodGoodStudy error
	assert.Error(t, Live(100, 100, 100))
	// BuyHouse error
	assert.Error(t, Live(100, 100, 100))
	// Marry error
	assert.Error(t, Live(100, 100, 100))
	// ok
	assert.NoError(t, Live(100, 100, 100))
}
</pre>
<p>第二版单元测试存在的问题：原始代码逻辑中不同分支是有层次感的，浏览代码的时候可以很自然的看出流程的走向，但是在单元测试代码中，这种层次感消失了，如果不写注释，单纯看断言代码，那么我们很可能搞不清楚自己在干什么。</p>
<h2>第三版单元测试</h2>
<p>虽然 testify 的断言很强大，但是在表达的层次感上却是无力的，此时我们可以考虑用 <a href="https://github.com/smartystreets/goconvey" target="_blank" rel="noopener">goconvey</a> 取代 testfy，它支持嵌套，这正是我们想要得到的层次感。</p>
<pre>package main

import (
	"errors"
	"testing"

	. "github.com/agiledragon/gomonkey/v2"
	. "github.com/smartystreets/goconvey/convey"
)

func Test_Live3(t *testing.T) {
	patches := NewPatches()
	defer patches.Reset()
	output := []OutputCell{
		{Values: Params{errors.New("error")}, Times: 1},
		{Values: Params{nil}, Times: 3},
	}
	patches.ApplyFuncSeq(GoodGoodStudy, output)
	output = []OutputCell{
		{Values: Params{errors.New("error")}, Times: 1},
		{Values: Params{nil}, Times: 2},
	}
	patches.ApplyFuncSeq(BuyHouse, output)
	output = []OutputCell{
		{Values: Params{errors.New("error")}, Times: 1},
		{Values: Params{nil}, Times: 1},
	}
	patches.ApplyFuncSeq(Marry, output)
	Convey("Live", t, func() {
		t.Log("LOG: Live")
		Convey("GoodGoodStudy error", func() {
			t.Log("LOG: GoodGoodStudy error")
			So(Live(100, 100, 100), ShouldBeError)
		})
		Convey("GoodGoodStudy ok", func() {
			t.Log("LOG: GoodGoodStudy ok")
			Convey("BuyHouse error", func() {
				t.Log("LOG: BuyHouse error")
				So(Live(100, 100, 100), ShouldBeError)
			})
			Convey("BuyHouse ok", func() {
				t.Log("LOG: BuyHouse ok")
				Convey("Marry error", func() {
					t.Log("LOG: Marry error")
					So(Live(100, 100, 100), ShouldBeError)
				})
				Convey("Marry ok", func() {
					t.Log("LOG: Marry ok")
					So(Live(100, 100, 100), ShouldBeNil)
				})
			})
		})
	})
}
</pre>
<p>补充说明： 如果你没看过 goconvey 的文档，那么很可能会误解其运行机制，我在代码里加了很多 t.Log，大家不妨猜猜它们的输出顺序是什么样的。了解这一点对实现 setup，teardown 很重要，篇幅所限，本文就不深入讨论了，有兴趣的朋友请自行查阅。</p>
<p>第三版单元测试存在的问题：虽然 gomonkey 可以通过 OutputCell 一次性控制多个状态变化，但是这些状态却是静态的，被替换方法的参数和返回值没有关联。</p>
<h2>关于 Gomonkey 的原罪</h2>
<p>在单元测试领域，关于如何替换掉外部依赖，主要有两种技术，分别是 mock 和 stub：mock 通过接口可以动态调整外部依赖的返回值，而 stub 只能在运行时静态调整外部依赖的返回值，可以说 mock 包含了 stub，或者说 stub 是 mock 的子集，从本质上讲，gomonkey 属于 stub 技术，它存在诸多缺点，比如：</p>
<ul>
<li>它违反了开闭原则。</li>
<li>运行时必须关闭内联「go test -gcflags=all=-l」。</li>
<li>运行时需要很高的权限，并且不同的硬件需要不同的<a href="https://github.com/agiledragon/gomonkey/releases/tag/v2.2.0" target="_blank" rel="noopener">黑科技</a>实现。</li>
</ul>
<p>对 gomonkey 来说，我的看法很明确：虽然黑科技很神奇，但是能不用就不用！一旦发现不得不用，那么多半意味着你的代码设计本身存在问题。</p>
<h2>最终版单元测试</h2>
<p>很多人买电脑的时候为了省钱买了集成显卡的电脑，结果等到需要换显卡的时候才发现可拔插性的重要性，如果上天再给他们一次机会，我猜他们一定会买独立显卡的电脑。</p>
<p>Golang 崇尚接口，有了接口，我们就可以很自然的使用 mock 技术，而不是 stub 技术。在这里，mock 就相当于独立显卡，而 stub 就相当于集成显卡。</p>
<p>下面让我们通过接口重构原始代码，其中使用 <a href="https://github.com/golang/mock" target="_blank" rel="noopener">gomock</a> 生成了 mock 对象：</p>
<pre>package main

//go:generate mockgen -package main -source foo.go -destination=foo_mock.go

// Life 人生
type Life interface {
	// GoodGoodStudy 好好学习
	GoodGoodStudy(money int64) error
	// BuyHouse 买房
	BuyHouse(money int64) error
	// Marry 结婚
	Marry(money int64) error
}

// Person 普通人
type Person struct {
	life Life
}

// Live 活着
func (p *Person) Live(money1, money2, money3 int64) error {
	if err := p.life.GoodGoodStudy(money1); err != nil {
		return err
	}
	if err := p.life.BuyHouse(money2); err != nil {
		return err
	}
	if err := p.life.Marry(money3); err != nil {
		return err
	}
	return nil
}
</pre>
<p>有了 mock 对象以后，我们就好像置身在元宇宙中一样，不再有 stub 的限制：</p>
<pre>package main

import (
	"errors"
	"testing"

	gomock "github.com/golang/mock/gomock"

	. "github.com/smartystreets/goconvey/convey"
)

func Test_Live(t *testing.T) {
	ctrl := gomock.NewController(t)
	life := NewMockLife(ctrl)
	handler := func(money int64) error {
		if money &lt;= 0 {
			return errors.New("error")
		}
		return nil
	}
	life.EXPECT().GoodGoodStudy(gomock.Any()).AnyTimes().DoAndReturn(handler)
	life.EXPECT().BuyHouse(gomock.Any()).AnyTimes().DoAndReturn(handler)
	life.EXPECT().Marry(gomock.Any()).AnyTimes().DoAndReturn(handler)
	Convey("Live", t, func() {
		person := &amp;Person{
			life: life,
		}
		Convey("GoodGoodStudy error", func() {
			So(person.Live(0, 100, 100), ShouldBeError)
		})
		Convey("GoodGoodStudy ok", func() {
			Convey("BuyHouse error", func() {
				So(person.Live(100, 0, 100), ShouldBeError)
			})
			Convey("BuyHouse ok", func() {
				Convey("Marry error", func() {
					So(person.Live(100, 100, 0), ShouldBeError)
				})
				Convey("Marry ok", func() {
					So(person.Live(100, 100, 100), ShouldBeNil)
				})
			})
		})
	})
}
</pre>
<p>最后让我们讨论一下到底哪些依赖需要 mock，哪些不需要 mock。简单点说：所有可能出现不可控情况的依赖都需要 mock，这里的不可控主要分两种：</p>
<ul>
<li>一种是运行时间的不可控：比如一个高 CPU 任务，单次执行需要一分钟，但是有一百个测试用例要跑，此时就需要 mock。</li>
<li>一种是运行结果的不可控：比如 mysql，redis 之类的 IO 请求，虽然它们可能运行的很快，但是因为网络本身的限制有可能失败，此时需要 mock。</li>
</ul>
<p>不过 mock 虽好，但不要贪杯，千万不要手里拿着锤子，看哪都像钉子。举个例子：Golang 里最流行的配置工具 <a href="https://github.com/spf13/viper" target="_blank" rel="noopener">Viper</a>，其最常用的使用方式都是静态调用，比如：「viper.GetXxx」，并没有使用接口，自然 mock 也就无从谈起，不过我们可以通过「viper.Set」很简单的替换方法的返回值，此时 mock 与否也就不再重要了。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/11/28/968/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>一个没什么用的转义技巧</title>
		<link>https://huoding.com/2021/11/16/966</link>
					<comments>https://huoding.com/2021/11/16/966#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Tue, 16 Nov 2021 07:54:16 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=966</guid>

					<description><![CDATA[最近我用命令行工具来测试 rpc 服务，因为此命令行工具要求输入数据是 json &#8230; <a href="https://huoding.com/2021/11/16/966">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>最近我用命令行工具来测试 rpc 服务，因为此命令行工具要求输入数据是 json 格式，所以免不了要在 shell 环境构造一些 json 字符串：</p>
<pre>shell&gt; echo '{"content": "$(base64 foo.docx)", "type": "docx"}'</pre>
<p>如上，我想把文件 foo.docx 的内容通过 base64 编码，然后放到 json 字符串里，但是它并不能正常工作，因为它是一个单引号字符串，命令在单引号里的是不能展开的，那换成双引号可不可以？当然可以，但是因为 json 本身包含很多双引号，所以免不了转义：</p>
<pre>shell&gt; echo "{\"content\": \"$(base64 foo.docx)\", \"type\": \"docx\"}"</pre>
<p>不瞒大家说，我最开始写出如上代码的时候，脑瓜子嗡嗡的，好在最后我想到了一个绝妙的解决方法：既然用双引号字符串不可避免会带来转义问题，那么就放弃双引号字符串，而是使用单引号字符串，然后把里面的命令用单引号包起来：</p>
<pre>shell&gt; echo '{"content": "'$(base64 foo.docx)'", "type": "docx"}'</pre>
<p>为什么这样可以？其实如上单引号字符串实际上是三个字符串，分别是：</p>
<ul>
<li>「'{&#8220;content&#8221;: &#8220;&#8216;」</li>
<li>「$(base64 foo.docx)」</li>
<li>「'&#8221;, &#8220;type&#8221;: &#8220;docx&#8221;}&#8217;」</li>
</ul>
<p>与其说是用单引号把命令包起来，倒不如说是用单引号把命令隔离出来，有点四两拨千斤的感觉，脑瓜子再也不会嗡嗡的了，整个世界清静了&#8230;</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/11/16/966/feed</wfw:commentRss>
			<slash:comments>4</slash:comments>
		
		
			</item>
		<item>
		<title>聊一个string和[]byte转换问题</title>
		<link>https://huoding.com/2021/10/14/964</link>
					<comments>https://huoding.com/2021/10/14/964#respond</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Thu, 14 Oct 2021 06:36:10 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=964</guid>

					<description><![CDATA[前几天闲聊的时候，景埕说网上很多 string 和 []byte 的转换都是有问 &#8230; <a href="https://huoding.com/2021/10/14/964">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>前几天闲聊的时候，<a href="https://github.com/diogin" target="_blank" rel="noopener">景埕</a>说网上很多 string 和 []byte 的转换都是有问题的，当时并没有在意，转过身没几天我偶然看到<a href="https://mp.weixin.qq.com/s/Xoaoiotl7ZQoG2iXo9_DWg" target="_blank" rel="noopener">字节跳动的一篇文章</a>，其中提到了他们是如何优化 string 和 []byte 转换的，我便问景埕有没有问题，讨论过程中学到了很多，于是便有了这篇总结。</p>
<p><span id="more-964"></span></p>
<p>让我们看看问题代码，类似的 string 和 []byte 转换代码在网上非常常见：</p>
<pre>func StringToSliceByte(s string) []byte {
	l := len(s)
	return *(*[]byte)(unsafe.Pointer(&amp;reflect.SliceHeader{
		Data: (*(*reflect.StringHeader)(unsafe.Pointer(&amp;s))).Data,
		Len:  l,
		Cap:  l,
	}))
}</pre>
<p>大家之所以不愿意直接通过 []byte(string) 把 string 转换为 []byte，是因为那样会牵扯内存拷贝，而通过 <a href="https://pkg.go.dev/unsafe#Pointer" target="_blank" rel="noopener">unsafe.Pointer</a> 来做类型转换，没有内存拷贝，从而达到提升性能的目的。</p>
<p>问题代码到底有没有问题？其实当我把代码拷贝到 vscode 之后就有提示了：</p>
<blockquote><p>SliceHeader is the runtime representation of a slice. It cannot be used safely or portably and its representation may change in a later release. Moreover, the Data field is not sufficient to guarantee the data it references will not be garbage collected, so programs must keep a separate, correctly typed pointer to the underlying data.</p></blockquote>
<p>首先，<a href="https://pkg.go.dev/reflect#SliceHeader" target="_blank" rel="noopener">reflect.SliceHeader</a> 作为 slice 的运行时表示，以后可能会改变，直接使用它存在风险；其次，Data 字段无法保证它指向的数据不被 GC 垃圾回收。</p>
<p>前一个问题还好说，但是后面提的 GC 问题则是个大问题！为什么会存在 GC 问题，我们不妨看看 reflect.SliceHeader 和 reflect.StringHeader 的定义：</p>
<pre>type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

type StringHeader struct {
	Data uintptr
	Len  int
}</pre>
<p>如上所示，Data 的类型是 uintptr，虽然有一个 ptr 后缀，但是它本质上还是一个整型，并不是指针，也就是说，它并不会持有它指向的数据，所以数据可能会被 GC 回收。</p>
<p>知道了前因后果，那么让我们构造一段代码来证明存在 GC 问题：</p>
<pre>package main

import (
	"fmt"
	"reflect"
	"runtime"
	"unsafe"
)

func main() {
	fmt.Printf("%s\n", test())
}

func test() []byte {
	defer runtime.GC()
	x := make([]byte, 5)
	x[0] = 'h'
	x[1] = 'e'
	x[2] = 'l'
	x[3] = 'l'
	x[4] = 'o'
	return StringToSliceByte(string(x))
}

func StringToSliceByte(s string) []byte {
	l := len(s)
	return *(*[]byte)(unsafe.Pointer(&amp;reflect.SliceHeader{
		Data: (*(*reflect.StringHeader)(unsafe.Pointer(&amp;s))).Data,
		Len:  l,
		Cap:  l,
	}))
}</pre>
<p>注：因为静态字符串存储在 TEXT 区，不会被 GC 回收，所以使用了动态字符串。</p>
<p>当我们运行上面的代码，并不会输出 hello，而是会输出乱码，原因是对应的数据已经被 GC 回收了，如果我们去掉 runtime.GC() 再运行，那么输出大概率会恢复正常。</p>
<p>由此可见，因为 Data 是 uintptr 类型，所以任何对它的赋值都是不安全的。原本问题到这里就应该告一段落了，但是 unsafe.Pointer 文档里恰好就有一个直接对 Data 赋值的例子：Conversion of a reflect.SliceHeader or reflect.StringHeader Data field to or from Pointer.</p>
<pre>var s string
hdr := (*reflect.StringHeader)(unsafe.Pointer(&amp;s))
hdr.Data = uintptr(unsafe.Pointer(p))
hdr.Len = n</pre>
<p>到底是文档有误，还是我们的推断错了，继续看文档里的说明：</p>
<blockquote><p>the reflect data structures SliceHeader and StringHeader declare the field Data as a uintptr to keep callers from changing the result to an arbitrary type without first importing &#8220;unsafe&#8221;. However, this means that SliceHeader and StringHeader are only valid when interpreting the content of an actual slice or string value.</p></blockquote>
<p>也就是说，只有当操作实际存在的 slice 或 string 的时候，SliceHeader 或 StringHeader 才是有效的，回想最初的代码，因为操作 reflect.SliceHeader 的时候，并没有实际存在的 slice，所以是不符合 unsafe.Pointer 使用规范的（<a href="https://groups.google.com/g/golang-nuts/c/Zsfk-VMd_fU/m/qJzdycRiCwAJ" target="_blank" rel="noopener">golang-nuts</a>），按照要求调整一下：</p>
<pre>func StringToSliceByte(s string) []byte {
	var b []byte
	l := len(s)
	p := (*reflect.SliceHeader)(unsafe.Pointer(&amp;b))
	p.Data = (*reflect.StringHeader)(unsafe.Pointer(&amp;s)).Data
	p.Len = l
	p.Cap = l
	return b
}</pre>
<p>再用测试代码跑一下，结果发现输出正常了。不过有人可能会问了，之前不是说了 uintptr 不是指针，不能阻止数据被 GC 回收，可是为什么 GC 没有效果？实际上这是因为编译器对 *reflect.{Slice,String}Header 做了<a href="https://github.com/golang/go/issues/19168" target="_blank" rel="noopener">特殊处理</a>，具体细节不展开了。</p>
<p>如果你想验证是否存在特殊处理，可以使用自定义的类型反向验证一下：</p>
<pre>type StringHeader struct {
	Data uintptr
	Len  int
}

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

func StringToSliceByte(s string) []byte {
	var b []byte
	l := len(s)
	p := (*SliceHeader)(unsafe.Pointer(&amp;b))
	p.Data = (*StringHeader)(unsafe.Pointer(&amp;s)).Data
	p.Len = l
	p.Cap = l
	return b
}</pre>
<p>你会发现，如果没有使用 reflect 里的类型，那么输出就又不正常了。从而反向验证了编译器确实对 *reflect.{Slice,String}Header 做了特殊处理。</p>
<p>现在，我们基本搞清楚了 string 和 []byte 转换中的各种坑，下面看看如何写出准确的转换代码，虽然编译器在其中耍了一些小动作，但是我们不应该依赖这些暗箱操作。</p>
<p>既然 uintptr 不是指针，那么我们改用 unsafe.Pointer，如此数据就不会被 GC 回收了：</p>
<pre>type StringHeader struct {
	Data unsafe.Pointer
	Len  int
}

type SliceHeader struct {
	Data unsafe.Pointer
	Len  int
	Cap  int
}

func StringToSliceByte(s string) []byte {
	var b []byte
	l := len(s)
	p := (*SliceHeader)(unsafe.Pointer(&amp;b))
	p.Data = (*StringHeader)(unsafe.Pointer(&amp;s)).Data
	p.Len = l
	p.Cap = l
	return b
}</pre>
<p>上面的代码稍显臃肿，更简单的写法可以参考 <a href="https://github.com/gin-gonic/gin/blob/master/internal/bytesconv/bytesconv.go" target="_blank" rel="noopener">gin</a> 或 <a href="https://github.com/valyala/fasthttp/blob/master/bytesconv.go" target="_blank" rel="noopener">fasthttp</a> 中的实现：</p>
<pre>// gin
func StringToBytes(s string) []byte {
	return *(*[]byte)(unsafe.Pointer(
		&amp;struct {
			string
			Cap int
		}{s, len(s)},
	))
}

func BytesToString(b []byte) string {
	return *(*string)(unsafe.Pointer(&amp;b))
}

// fasthttp
func s2b(s string) (b []byte) {
	/* #nosec G103 */
	bh := (*reflect.SliceHeader)(unsafe.Pointer(&amp;b))
	/* #nosec G103 */
	sh := (*reflect.StringHeader)(unsafe.Pointer(&amp;s))
	bh.Data = sh.Data
	bh.Cap = sh.Len
	bh.Len = sh.Len
	return b
}

func b2s(b []byte) string {
	/* #nosec G103 */
	return *(*string)(unsafe.Pointer(&amp;b))
}
</pre>
<p>至此，我们完美解决了 string 和 []byte 的转换问题。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/10/14/964/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>从一个data race问题学到的</title>
		<link>https://huoding.com/2021/10/11/960</link>
					<comments>https://huoding.com/2021/10/11/960#respond</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Mon, 11 Oct 2021 05:07:15 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=960</guid>

					<description><![CDATA[前几天我在学习内存屏障的时候搜到一篇文章「Golang Memory Model &#8230; <a href="https://huoding.com/2021/10/11/960">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>前几天我在学习<a href="https://zh.wikipedia.org/wiki/%E5%86%85%E5%AD%98%E5%B1%8F%E9%9A%9C" target="_blank" rel="noopener">内存屏障</a>的时候搜到一篇文章「<a href="https://fanlv.wiki/2020/06/09/golang-memory-model/" target="_blank" rel="noopener">Golang Memory Model</a>」，其中在介绍 CPU 缓存一致性的时候提到一个例子，带给我一些困惑，本文记录下解惑过程。</p>
<p><span id="more-960"></span></p>
<p>既然是在介绍 CPU 缓存一致性的时候举的例子，那么理所应当与此有关，看代码：</p>
<pre>package main

import "time"

func main() {
	running := true
	go func() {
		println("start thread1")
		count := 1
		for running {
			count++
		}
		println("end thread1: count =", count)
	}()
	go func() {
		println("start thread2")
		for {
			running = false
		}
	}()
	time.Sleep(time.Hour)
}</pre>
<p>当我们通过「go run main.go」运行代码的时候，会发现第一个 goroutine 永远不会结束，就好像 running = false 没有生效一样。对此，文章把原因归结为 CPU 缓存一致性中的线程可见性问题，可是我前后看了几遍也没有看出个所以然来。细心的小伙伴不难发现代码存在 data race 问题：多个 goroutine 并发读写 running 变量，不过当我们通过「go run -race main.go」再次运行代码的时候，有趣的事情发生了，第一个 goroutine 正常结束了！</p>
<p>理论上，既然存在 data race 问题，那么出现什么结果都可能，但是好奇心驱使我继续研究了一下，这次使用的工具是 <a href="https://github.com/golang/go/blob/master/src/cmd/compile/internal/ssa/README.md" target="_blank" rel="noopener">SSA</a>（<a href="https://sitano.github.io/2018/03/18/howto-read-gossa/" target="_blank" rel="noopener">how to read</a>），它可以展现出从源代码到汇编的过程中，编译器都做了哪些工作，并且可以把结果生成 html 文件：</p>
<pre>shell&gt; GOSSAFUNC=main go build -gcflags="-N -l" ./main.go</pre>
<p>SSA 工具最方便的地方是它可以把源代码和汇编通过颜色对应起来：</p>
<div id="attachment_961" style="width: 2060px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/10/ssa_main.png"><img aria-describedby="caption-attachment-961" decoding="async" loading="lazy" class="size-full wp-image-961" src="https://huoding.com/wp-content/uploads/2021/10/ssa_main.png" alt="main 函数的 ssa" width="2050" height="1520" /></a><p id="caption-attachment-961" class="wp-caption-text">main 函数的 ssa</p></div>
<p>说明：Golang 中的<a href="https://github.com/go-internals-cn/go-internals/blob/master/chapter1_assembly_primer/README.md" target="_blank" rel="noopener">汇编</a>一般指 Plan9 汇编，推荐阅读「<a href="https://github.com/cch123/golang-notes/blob/master/assembly.md" target="_blank" rel="noopener">plan9 assembly 完全解析</a>」。</p>
<p>不过为什么「running = false」这行源代码没有对应的汇编呢？这是因为 SSA 的工作单位是函数，上面的结果是 main 函数的结果，而「running = false」实际上属于 main 函数里第 2 个 goroutine，相当于 main.func2，重新运行 SSA：</p>
<pre>shell&gt; GOSSAFUNC=main.func2 go build -gcflags="-N -l" ./main.go</pre>
<p>如此一来就能看到「running = false」这行源代码对应的汇编了：</p>
<div id="attachment_962" style="width: 2038px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/10/ssa_main_func2.png"><img aria-describedby="caption-attachment-962" decoding="async" loading="lazy" class="wp-image-962 size-full" src="https://huoding.com/wp-content/uploads/2021/10/ssa_main_func2.png" alt="main.func2 函数的 ssa" width="2028" height="1050" /></a><p id="caption-attachment-962" class="wp-caption-text">main.func2 函数的 ssa</p></div>
<p>其中，PCDATA 是编译器插入的和 GC 相关的信息，在本例中可以忽略，剩下的几个 JMP 跳来跳去，好像是个圈哦，就是一个空 for，和「running = false」完全没有关系。</p>
<p>不过既然带有 race 检测的代码工作正常，那么不妨一并生成 SSA 看看结果如何：</p>
<pre>shell&gt; GOSSAFUNC=main.func2 go build -race -gcflags="-N -l" ./main.go</pre>
<p>结果如下图所示，除了 JMP，还有 MOV 操作，正好对应「running = false」：</p>
<div id="attachment_963" style="width: 2100px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/10/ssa_main_func2_with_race.png"><img aria-describedby="caption-attachment-963" decoding="async" loading="lazy" class="size-full wp-image-963" src="https://huoding.com/wp-content/uploads/2021/10/ssa_main_func2_with_race.png" alt="main.func2 函数的 ssa" width="2090" height="1110" /></a><p id="caption-attachment-963" class="wp-caption-text">main.func2 函数的 ssa</p></div>
<p>如此一来，我们的困惑终于解开了。问题代码中的循环之所以不会结束，和所谓的「CPU 缓存一致性中的线程可见性问题」并没有任何关系，只是因为编译器把部分代码看成死代码，直接优化掉了，这个过程称之为「<a href="https://en.wikipedia.org/wiki/Dead_code_elimination" target="_blank" rel="noopener">Dead code elimination</a>」，不过当激活 race 检测的时候，编译器并没有执行优化死代码的流程，所以看上去又正常了。</p>
<p>最后，推荐一篇文章，和本文的例子相似：谈谈 <a href="https://ms2008.github.io/2019/05/12/golang-data-race/" target="_blank" rel="noopener">Golang 中的 Data Race</a>（及<a href="https://ms2008.github.io/2019/05/22/golang-data-race-cont/" target="_blank" rel="noopener">续</a>），还有一篇：<a href="https://liudanking.com/arch/go-%e4%b8%ad%e4%b8%80%e4%b8%aa%e9%9d%9e%e5%85%b8%e5%9e%8b%e4%b8%8d%e5%8a%a0%e9%94%81%e8%af%bb%e5%86%99%e5%8f%98%e9%87%8f%e6%a1%88%e4%be%8b%e5%88%86%e6%9e%90/">Go 中一个非典型不加锁读写变量案例分析</a>。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/10/11/960/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>为什么会有atomic.LoadInt32</title>
		<link>https://huoding.com/2021/10/08/958</link>
					<comments>https://huoding.com/2021/10/08/958#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Fri, 08 Oct 2021 09:06:50 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=958</guid>

					<description><![CDATA[前些天我们聊了 Golang 内存对齐的话题，后来我突然想到另一个问题：为什么会 &#8230; <a href="https://huoding.com/2021/10/08/958">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>前些天我们聊了 <a href="https://huoding.com/2021/09/29/951" target="_blank" rel="noopener">Golang 内存对齐</a>的话题，后来我突然想到另一个问题：为什么会有 <a href="https://pkg.go.dev/github.com/hslam/atomic#LoadInt32" target="_blank" rel="noopener">atomic.LoadInt32</a>？可能你觉得思维太跳跃了，容我慢慢道来：首先，有 <a href="https://pkg.go.dev/github.com/hslam/atomic#LoadInt64" target="_blank" rel="noopener">atomic.LoadInt64</a> 很正常，因为对一个 int64 来说，它的大小是 8 个字节，如果是 32 位平台的话（字长 4 字节），CPU 一次最多操作 4 个字节，需要两次才能拿到全部数据，所以封装一个 atomic.LoadInt64 来实现原子操作；但是，对一个 int32 数据来说，它的大小是 4 字节，不管是 32 位平台（字长 4 字节），还是 64 位平台（字长 8 字节），CPU 应该都可以保证一次操作拿到数据，换句话说，如果读取一个 int32 数据，那么本身就应该是原子的，可是为什么会有 atomic.LoadInt32，这不是脱了裤子放屁么？</p>
<p><span id="more-958"></span></p>
<p>有病没病走两步，让我们写一段代码来验证一下：</p>
<pre>package main

import "sync/atomic"

var v = int32(0)

func main() {
	var x int32
	x = v // main.go:9
	_ = x
	x = atomic.LoadInt32(&amp;v) // main.go:11
	_ = x
}</pre>
<p>通过「go tool compile」运行代码，拿到对应的汇编结果：</p>
<pre>shell&gt; go tool compile -N -l -S main.go

0x0016 00022 (main.go:9)        MOVL    "".v(SB), AX
0x001c 00028 (main.go:9)        MOVL    AX, "".t+4(SP)
0x0020 00032 (main.go:11)       MOVL    "".v(SB), AX
0x0026 00038 (main.go:11)       MOVL    AX, "".t+4(SP)
</pre>
<p>不管是「x = v」还是「x = atomic.LoadInt32(&amp;v)」，对应的汇编结果一摸一样，带着困惑，让我们继续看看是否能从 <a href="https://github.com/golang/go/tree/master/src/sync/atomic" target="_blank" rel="noopener">sync/atomic</a> 的源代码中找到答案：</p>
<p>Golang 代码中只有函数声明，实际上是使用汇编实现的：</p>
<pre>// doc.go
func LoadInt32(addr *int32) (val int32)

// asm.s
TEXT ·LoadInt32(SB),NOSPLIT,$0
	JMP runtime∕internal∕atomic·Load(SB)</pre>
<p>顺着路径，跳转到 <a href="https://github.com/golang/go/tree/master/src/runtime/internal/atomic" target="_blank" rel="noopener">runtime/internal/atomic</a>，会发现每个平台都有独立的 Load 实现：</p>
<p>在 amd64 平台，Load 是用 Golang 实现的，等价于直接读取：</p>
<pre>func Load(ptr *uint32) uint32 {
	return *ptr
}</pre>
<p>在 arm64 平台，Load 是用汇编实现的，并不是简单的一次操作：</p>
<pre>TEXT ·Load(SB),NOSPLIT,$0-12
	MOVD	ptr+0(FP), R0
	LDARW	(R0), R0
	MOVW	R0, ret+8(FP)
	RET</pre>
<p>如上可见，atomic.LoadInt32 之所以存在，是因为某些平台存在特殊性，所以我们需要封装一个统一的操作，如此更有利于我们写出平台无关的代码。</p>
<p>本文仅讨论了 atomic 的<a href="https://www.1024cores.net/home/lock-free-algorithms/so-what-is-a-memory-model-and-how-to-cook-it" target="_blank" rel="noopener">原子性</a>，实际上它还保证了<a href="https://www.1024cores.net/home/lock-free-algorithms/so-what-is-a-memory-model-and-how-to-cook-it/visibility" target="_blank" rel="noopener">可见性</a>，<a href="https://www.1024cores.net/home/lock-free-algorithms/so-what-is-a-memory-model-and-how-to-cook-it/ordering" target="_blank" rel="noopener">有序性</a>，有兴趣的朋友可以搜索内存屏障相关内容，这是一个很复杂的主题，我就不献丑了，推荐阅读：<a href="https://fanlv.wiki/2020/06/09/golang-memory-model/" target="_blank" rel="noopener">Golang Memory Model</a>。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/10/08/958/feed</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title>再谈Golang内存对齐</title>
		<link>https://huoding.com/2021/09/30/955</link>
					<comments>https://huoding.com/2021/09/30/955#respond</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Thu, 30 Sep 2021 11:05:40 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=955</guid>

					<description><![CDATA[关于 Golang 内存对齐，昨天已经写了一篇「浅谈Golang内存对齐」，可惜 &#8230; <a href="https://huoding.com/2021/09/30/955">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>关于 Golang 内存对齐，昨天已经写了一篇「<a href="https://huoding.com/2021/09/29/951" target="_blank" rel="noopener">浅谈Golang内存对齐</a>」，可惜对一些细节问题的讨论语焉不详，于是便有了今天这篇「再谈Golang内存对齐」。</p>
<p><span id="more-955"></span></p>
<p>让我们回想一下 <a href="https://github.com/golang/groupcache/blob/master/groupcache.go" target="_blank" rel="noopener">groupcache</a> 和 <a href="https://github.com/golang/go/blob/master/src/sync/waitgroup.go" target="_blank" rel="noopener">sync.WaitGroup</a> 中的做法，为了规避在 32 位环境下 atomic 操作 64 位数的 BUG，它们采取了截然不同的做法：</p>
<pre>// groupcache
type Group struct {
	name string
	getter Getter
	peersOnce sync.Once
	peers PeerPicker
	cacheBytes int64
	mainCache cache
	hotCache cache
	loadGroup flightGroup
	_ int32 // force Stats to be 8-byte aligned on 32-bit platforms
	Stats Stats
}

// sync.WaitGroup
type WaitGroup struct {
	noCopy noCopy

	// 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
	// 64-bit atomic operations require 64-bit alignment, but 32-bit
	// compilers do not ensure it. So we allocate 12 bytes and then use
	// the aligned 8 bytes in them as state, and the other 4 as storage
	// for the sema.
	state1 [3]uint32
}

func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
	if uintptr(unsafe.Pointer(&amp;wg.state1))%8 == 0 {
		return (*uint64)(unsafe.Pointer(&amp;wg.state1)), &amp;wg.state1[2]
	} else {
		return (*uint64)(unsafe.Pointer(&amp;wg.state1[1])), &amp;wg.state1[0]
	}
}
</pre>
<p><strong>问题：为什么 groupcache 不用考虑外部地址，只要内部对齐就可以实现 64 位对齐？</strong></p>
<p>为了搞清楚这个问题，让我们回想一下 <a href="https://pkg.go.dev/sync/atomic" target="_blank" rel="noopener">atomic</a> 文档最后关于 64 位对齐的相关描述：</p>
<blockquote><p>On ARM, 386, and 32-bit MIPS, it is the caller’s responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically. The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.</p></blockquote>
<p>其中我们关心的是最后一句话：变量、结构体、数组、切片的第一个字是 64 位对齐的。为了验证这一点，我构造了一个包含 int64 的 struct，看它的地址是否是 8 的倍数：</p>
<pre>package main

import (
	"fmt"
	"time"
	"unsafe"
)

type foo struct {
	bar int64
}

// GOARCH=386 go run main.go
func main() {
	for range time.Tick(time.Second) {
		f := &amp;foo{}
		p := uintptr(unsafe.Pointer(f))
		fmt.Printf("%p: %d, %d\n", f, p, p%8)
	}
}
</pre>
<p>按照常理来说，当我们在 32 位环境（GOARCH=386）下运行的时候，struct 的地址应该只能满足 32 位对齐，也就是 4 的倍数，不过测试发现，struct 的地址竟然满足 64 位对齐，也就是是 8 的倍数。既然外部已经是对齐的了，那么只要内部对齐就可以实现 64 位对齐。</p>
<p><strong>问题：为什么 sync.WaitGroup 不像 groupcache 那样实现 64 位对齐。</strong></p>
<p>两者之所以采用了不同的 64 位对齐实现方式，是因为两者的使用场景不同。在实际使用的时候，sync.WaitGroup 可能会被嵌入到别的 struct 中，因为不知道嵌入的具体位置，所以不可能通过预先加入 padding 的方式来实现 64 位对齐，只能在运行时动态计算。而 groupcache 则不会被嵌入到别的 struct 中，如果你硬要嵌入，可能会出问题：</p>
<pre>package main

import (
	"github.com/golang/groupcache"
)

type foo struct {
	bar int32
	g groupcache.Group
}

// GOARCH=386 go run main.go
func main() {
	f := foo{}
	f.g.Stats.Gets.Add(1)
}</pre>
<p>当我们在 32 位环境（GOARCH=386）下运行的时候，会看到如下 panic 信息：</p>
<blockquote><p>panic: unaligned 64-bit atomic operation</p></blockquote>
<p>当我们在 32 位环境，按 4 字节对齐，所以 g 的偏移量是 4 而不是 8，如此一来，虽然 groupcache 内部通过 _ int32 实现了相对的 64 位对齐，但是因为外部没有实现 64 位对齐，所以在执行 atomic 操作的时候，还是会 panic（如果 bar 是 int64 就不会 panic）。</p>
<p><strong>问题：为什么 sync.WaitGroup 中的 state1 不换成 一个 int64 加一个 int32？</strong></p>
<p>更新：之前解释有误，现在新版已经换了 <img src="https://s.w.org/images/core/emoji/14.0.0/72x72/1f642.png" alt="🙂" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>
<p><strong>问题：为什么 sync.WaitGroup 中的 state1 不换成 一个[12]byte？</strong></p>
<p>原方案中 state1 的类型是 [3]uint32，取两个 uint32 做 statep，剩下的一个 uint32 做 semap。为什么不换成 [12]byte，取 8 个 byte 做 statep，剩下的 4 个 byte 做 semap？</p>
<p>想要搞清楚这个问题，我们需要回顾一下 golang 关于内存对齐保证的描述：</p>
<ul>
<li>For a variable x of any type: unsafe.Alignof(x) is at least 1.</li>
<li>For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.</li>
<li>For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array’s element type.</li>
</ul>
<p>其中的重点是：对 struct 而言，它的对齐取决于其中所有字段对齐的最大值；对于 array 而言，它的对齐等于元素类型本身的对齐。因为 noCopy 的大小是 0，所以 struct 的对齐实际上就取决于 state1 字段的对齐。</p>
<ul>
<li>当 state1 的类型是 [3]uint32 的时候，那么 struct 的对齐就是 4。</li>
<li>当 state1 的类型是 [12]byte 的时候，那么 struct 的对齐就是 1。</li>
</ul>
<p>如果 state1 换成 [12]byte，那么因为 struct 的对齐是 1，会导致 struct 的地址不再是 4 的倍数，uintptr(unsafe.Pointer(&amp;wg.state1))%8 的结果可能是从 0 到 7 的任意数，如此一来需要 padding 的时候，你就无法实现剩余空间恰好可以充当 padding 的效果了。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/09/30/955/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>浅谈Golang内存对齐</title>
		<link>https://huoding.com/2021/09/29/951</link>
					<comments>https://huoding.com/2021/09/29/951#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Wed, 29 Sep 2021 03:15:19 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=951</guid>

					<description><![CDATA[如果你在 golang spec 里以「alignment」为关键字搜索的话，那 &#8230; <a href="https://huoding.com/2021/09/29/951">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>如果你在 <a href="https://golang.org/ref/spec" target="_blank" rel="noopener">golang spec</a> 里以「alignment」为关键字搜索的话，那么会发现与此相关的内容并不多，只是在结尾介绍 unsafe 包的时候提了一下，不过别忘了字儿越少事儿越大：</p>
<p>Computer architectures may require memory addresses to be aligned; that is, for addresses of a variable to be a multiple of a factor, the variable&#8217;s type&#8217;s alignment. The function Alignof takes an expression denoting a variable of any type and returns the alignment of the (type of the) variable in bytes. For a variable x:</p>
<pre>uintptr(unsafe.Pointer(&amp;x)) % unsafe.Alignof(x) == 0</pre>
<p>The following minimal alignment properties are guaranteed:</p>
<ul>
<li>For a variable x of any type: unsafe.Alignof(x) is at least 1.</li>
<li>For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.</li>
<li>For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array&#8217;s element type.</li>
</ul>
<p>当然，如果你以前没有接触过内存对齐的话，那么对你来说上面的内容可能过于言简意赅，在继续学习之前我建议你阅读以下资料，有助于消化理解：</p>
<ul>
<li><a href="https://gfw.go101.org/article/memory-layout.html" target="_blank" rel="noopener">内存布局</a></li>
<li><a href="http://blog.newbmiao.com/slides/%E5%9B%BE%E8%A7%A3Go%E4%B9%8B%E5%86%85%E5%AD%98%E5%AF%B9%E9%BD%90.pdf" target="_blank" rel="noopener">图解 Go 之内存对齐</a></li>
<li><a href="http://blog.newbmiao.com/2020/02/10/dig101-golang-struct-memory-align.html" target="_blank" rel="noopener">Dig101-Go之聊聊struct的内存对齐</a></li>
<li><a href="https://eddycjy.gitbook.io/golang/di-1-ke-za-tan/go-memory-align" target="_blank" rel="noopener">在 Go 中恰到好处的内存对齐</a></li>
<li><a href="https://www.liwenzhou.com/posts/Go/struct_memory_layout/" target="_blank" rel="noopener">Go 结构体的内存布局</a></li>
<li><a href="https://ms2008.github.io/2019/08/01/golang-memory-alignment/" target="_blank" rel="noopener">Golang 是否有必要内存对齐</a></li>
</ul>
<p><span id="more-951"></span></p>
<h2>测试</h2>
<p>我构造了一个 struct，它有一个特征：字段按照一小一大的顺序排列，如果不看注释中的 Sizeof、Alignof、Offsetof 信息（通过 unsafe 获取），你能否说出它占用多少个字节？</p>
<pre>package main

import (
	"fmt"
	"unsafe"
)

type memAlign struct {
	a byte     // Sizeof: 1  Alignof: 1 Offsetof: 0
	b int      // Sizeof: 8  Alignof: 8 Offsetof: 8
	c byte     // Sizeof: 1  Alignof: 1 Offsetof: 16
	d string   // Sizeof: 16 Alignof: 8 Offsetof: 24
	e byte     // Sizeof: 1  Alignof: 1 Offsetof: 40
	f []string // Sizeof: 24 Alignof: 8 Offsetof: 48
}

func main() {
	var m memAlign
	fmt.Println(unsafe.Sizeof(m))
}
</pre>
<p>初学者往往会认为 struct 的大小应该等于内部各个字段大小的和，于是得出本例的答案是 51（1+8+1+16+1+24=51），不过实际上答案却是 72！究其原因是因为内存对齐的缘故导致各个字段之间可能存在 padding。那么有没有简单的方法来减少 padding 呢？我们不妨把字段按照从大到小的顺序排列，再试一试：</p>
<pre>package main

import (
	"fmt"
	"unsafe"
)

type memAlign struct {
	f []string // Sizeof: 24 Alignof: 8 Offsetof: 0
	d string   // Sizeof: 16 Alignof: 8 Offsetof: 24
	b int      // Sizeof: 8  Alignof: 8 Offsetof: 40
	a byte     // Sizeof: 1  Alignof: 1 Offsetof: 48
	c byte     // Sizeof: 1  Alignof: 1 Offsetof: 49
	e byte     // Sizeof: 1  Alignof: 1 Offsetof: 50
}

func main() {
	var m memAlign
	fmt.Println(unsafe.Sizeof(m))
}</pre>
<p>结果答案变成了 56，比 72 小了很多，不过还是比 51 大，说明还是存在 padding，这是因为不仅字段要内存对齐，struct 本身也要内存对齐。</p>
<p>另：我刚学 golang 的时候一直有一个疑问：为什么切片的大小是 24，字符串的大小是 16 呢？我估计别的初学者也会有类似的问题，一并解释一下，这是因为切片和字符串也是 struct，其定义分别对应 <a href="https://pkg.go.dev/reflect#SliceHeader" target="_blank" rel="noopener">SliceHeader</a> 和 <a href="https://pkg.go.dev/reflect#StringHeader" target="_blank" rel="noopener">StringHeader</a>，它们的大小分别是 24 和 16：</p>
<pre>type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

type StringHeader struct {
	Data uintptr
	Len  int
}</pre>
<p>因为 uintptr 的大小等于 int，所以切片的大小等于 3*8=24，字符串的大小等于 2*8=16。</p>
<h2>工具</h2>
<p>只要我们写点代码，调用 unsafe 包的 Sizeof、Alignof、Offsetof 等方法，那么就可以搞清楚 struct 内存对齐的各种细节，不过这毕竟是个没有技术含量的体力活，有没有相关工具可以提升我们的工作效率呢？答案是 <a href="https://github.com/dominikh/go-tools" target="_blank" rel="noopener">go-tools</a>：</p>
<pre>shell&gt; go install honnef.co/go/tools/cmd/structlayout@latest
shell&gt; go install honnef.co/go/tools/cmd/structlayout-pretty@latest
shell&gt; go install honnef.co/go/tools/cmd/structlayout-optimize@latest</pre>
<p>其中，structlayout 是用来分析数据的，pretty 是用来图形化显示的，optimize 是用来优化建议的，这里就用文章开头优化前的代码给出一个 structlayout-pretty 的例子：</p>
<pre>shell&gt; structlayout -json ./main.go memAlign | structlayout-pretty</pre>
<div id="attachment_952" style="width: 1050px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/09/structlayout-pretty.png"><img aria-describedby="caption-attachment-952" decoding="async" loading="lazy" class="size-full wp-image-952" src="https://huoding.com/wp-content/uploads/2021/09/structlayout-pretty.png" alt="structlayout-pretty" width="1040" height="1460" /></a><p id="caption-attachment-952" class="wp-caption-text">structlayout-pretty</p></div>
<p>虽然 structlayout-pretty 我们可以很直观的看到在哪里存在 padding，不过它是 ascii 风格的，有时候不太方便，此时另外一个图形化工具 <a href="https://github.com/ajstarks/svgo/tree/master/structlayout-svg" target="_blank" rel="noopener">structlayout-svg</a> 更爽：</p>
<pre>shell&gt; go install github.com/ajstarks/svgo/structlayout-svg@latest</pre>
<p>把文章开头优化前后的代码分别用 structlayout-svg 生成结果：</p>
<pre>shell&gt; structlayout -json ./main.go memAlign | structlayout-svg</pre>
<p>优化前：</p>
<div id="attachment_953" style="width: 610px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/09/before.png"><img aria-describedby="caption-attachment-953" decoding="async" loading="lazy" class="size-full wp-image-953" src="https://huoding.com/wp-content/uploads/2021/09/before.png" alt="优化前" width="600" height="850" /></a><p id="caption-attachment-953" class="wp-caption-text">优化前</p></div>
<p>优化后：</p>
<div id="attachment_954" style="width: 610px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/09/after.png"><img aria-describedby="caption-attachment-954" decoding="async" loading="lazy" class="size-full wp-image-954" src="https://huoding.com/wp-content/uploads/2021/09/after.png" alt="优化后" width="600" height="674" /></a><p id="caption-attachment-954" class="wp-caption-text">优化后</p></div>
<p>效果超赞是不是！不过如果我们要把工具集成到 CI 里，那么此类图形化工具就不合适了，好在我们的工具箱里还有宝贝，它就是 <a href="https://github.com/golang/tools/blob/master/gopls/doc/analyzers.md#fieldalignment" target="_blank" rel="noopener">fieldalignment</a>：</p>
<pre>shell&gt; go install golang.org/x/tools/...@latest</pre>
<p>把文章开头优化前后的代码分别用 fieldalignment 生成结果：</p>
<pre>shell&gt; awk '$1 == "module" {print $2}' ./go.mod | xargs fieldalignment</pre>
<p>优化前：struct of size 72 could be 56；优化后：struct with 32 pointer bytes could be 24。</p>
<p>实际集成到 CI 的时候，通常不会直接使用 fieldalignment，而是使用 <a href="https://golangci-lint.run/" target="_blank" rel="noopener">golangci-lint</a>：</p>
<pre>shell&gt; cat .golangci.yaml

linters-settings:
  govet:
    enable-all: true

shell&gt; golangci-lint run --disable-all -E govet</pre>
<p>如上可见，fieldalignment 准确判断出优化前代码的 struct size 存在优化空间；但是优化后代码的 pointer bytes 是什么鬼？按照文档中的说明，pointer bytes 的含义如下：</p>
<p>Pointer bytes is how many bytes of the object that the garbage collector has to potentially scan for pointers, for example:</p>
<pre>struct { uint32; string }</pre>
<p>have 16 pointer bytes because the garbage collector has to scan up through the string&#8217;s inner pointer.</p>
<pre>struct { string; *uint32 }</pre>
<p>has 24 pointer bytes because it has to scan further through the *uint32.</p>
<pre>struct { string; uint32 }</pre>
<p>has 8 because it can stop immediately after the string pointer.</p>
<p>看到这里，不禁让人产生疑惑：GC 不会这么傻吧，难道它还要一个字节一个字节的扫描内存么？让我们做个实验测试一下 pointer bytes 有没有影响，正所谓有病没病走两步：</p>
<pre>package main

import (
	"runtime"
	"time"
)

// pointer bytes: 8
type foo struct {
	s string
	u uint32
}

// pointer bytes: 16
type bar struct {
	u uint32
	s string
}

// GODEBUG=gctrace=1 go run main.go
func main() {
	v := make([]foo, 1e8)
	// v := make([]bar, 1e8)
	for range time.Tick(time.Second) {
		runtime.GC()
	}
	runtime.KeepAlive(v)
}</pre>
<p>代码里构造了一个巨大的切片变量，栈必然保存不了，于是变量会逃逸到堆，接着周期性的调用 runtime.GC 来手动触发 GC，然后执行的时候通过 GODEBUG=gctrace=1 获取实时的 GC 相关信息。结果显示，不管是小 pointer bytes 的 foo，还是大 pointer bytes 的 bar，最终 GC 消耗的时间差不多。换句话说，pointer bytes 的大小对 GC 的影响很小很小，在 golang 的相关 <a href="https://github.com/golang/go/issues/44877#issuecomment-794565908" target="_blank" rel="noopener">issue</a> 的讨论中，也能印证此结论，篇幅所限，这里就不多说了。</p>
<p>另：命令输出的 gctrace 信息比较多，相关格式说明可以参考 <a href="https://pkg.go.dev/runtime" target="_blank" rel="noopener">runtime</a> 中的注释信息。</p>
<h2>例子</h2>
<p>了解了内存对齐的相关知识后，让我们看看现实世界中的例子，首先是 <a href="https://github.com/golang/groupcache/blob/master/groupcache.go" target="_blank" rel="noopener">groupcache</a>：</p>
<pre>type Group struct {
	name string
	getter Getter
	peersOnce sync.Once
	peers PeerPicker
	cacheBytes int64
	mainCache cache
	hotCache cache
	loadGroup flightGroup
	_ int32 // force Stats to be 8-byte aligned on 32-bit platforms
	Stats Stats
}</pre>
<p>通过注释我们可以看到，为了强制让 Stats 在 32 位平台上按 8 字节对齐，在 Stats 字段的前面加了一个「_ int32」，换句话说，就是加了 4 个字节，那么为什么要这么做？</p>
<p>原因是 Stats 字段要参与 <a href="https://pkg.go.dev/sync/atomic" target="_blank" rel="noopener">atomic</a> 原子运算，关于 atomic，文档最后记录了如下内容：</p>
<blockquote><p>On ARM, 386, and 32-bit MIPS, it is the caller&#8217;s responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically. The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.</p></blockquote>
<p>也就是说，在 32 位平台，调用者有责任自己保证原子操作是 64 位对齐的，此外，struct 中第一个字段可以被认为是 64 位对齐的。在本例中，因为 Stats 字段要参与 atomic 运算，而且不是第一个字段，所以我们必须手动保证它是 64 位对齐的，不过加了 _ int32 就能保证是 64 位对齐的么？让我们写代码验证一下：</p>
<pre>package main

import (
	"fmt"
	"unsafe"

	"github.com/golang/groupcache"
)

// GOARCH=386 go run main.go
func main() {
	var g groupcache.Group
	fmt.Println(unsafe.Offsetof(g.Stats))
}</pre>
<p>结果显示在 32 位下运行，Stats 的 offset 是 176，是 8 的倍数，满足 64 位对齐。如果没有「_ int32」做 padding，那么 Stats 的 offset 将是 172，就不再是 8 的倍数了。</p>
<p>&#8230;</p>
<p>再看看 <a href="https://github.com/golang/go/blob/master/src/sync/waitgroup.go" target="_blank" rel="noopener">sync.WaitGroup</a> 中内存对齐的例子：</p>
<pre>type WaitGroup struct {
	noCopy noCopy

	// 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
	// 64-bit atomic operations require 64-bit alignment, but 32-bit
	// compilers do not ensure it. So we allocate 12 bytes and then use
	// the aligned 8 bytes in them as state, and the other 4 as storage
	// for the sema.
	state1 [3]uint32
}

func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
	if uintptr(unsafe.Pointer(&amp;wg.state1))%8 == 0 {
		return (*uint64)(unsafe.Pointer(&amp;wg.state1)), &amp;wg.state1[2]
	} else {
		return (*uint64)(unsafe.Pointer(&amp;wg.state1[1])), &amp;wg.state1[0]
	}
}
</pre>
<p>首先，noCopy 是什么鬼，其实它的作用就像名字一样，它是如何实现的呢，看注释：</p>
<pre>// noCopy may be embedded into structs which must not be copied
// after the first use.
//
// See https://golang.org/issues/8005#issuecomment-190753527
// for details.
type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}</pre>
<p>实际上它只是起到标识的作用，以便 go vet 能够借此发现问题，详细说明在 <a href="https://github.com/golang/go/issues/8005#issuecomment-190753527" target="_blank" rel="noopener">issue</a> 中有描述，如果你在自己的项目里有类似 noCopy 的需求，那么也可以照猫画虎，</p>
<p>接下来是内存对齐相关的重头戏了，state1 字段是一个有 3 个元素的 uint32 数组，它会保存两种数据，分别是 <span class="pl-s1">statep 和</span> <span class="pl-s1">semap，其中，statep 要参与 atomic 运算，所以我们要保证它是 64 位对齐的。如果「uintptr(unsafe.Pointer(&amp;wg.state1))%8 == 0」成立，那么取前两个 int32 做 statep，否则取后两个 int32 做 statep。</span></p>
<p><span class="pl-s1">为什么这样做？因为「uintptr(unsafe.Pointer(&amp;wg.state1))%8 == 0」成立的时候，前两个 int32 自然满足 64 位对齐；当「uintptr(unsafe.Pointer(&amp;wg.state1))%8 == 0」不成立的时候， 其运算结果必然等于 4，此时我们正好可以把第一个 int32 当作是一个 4 字节的 padding，于是后两个字节的 int32 就又满足 64 位对齐了。</span></p>
<p>如果你认为自己理解了，那么思考一下，在定义 state1 的时候，如果不用 [3]int32，而是换成一个 int64 加上一个 int32，或者是一个 [12]byte，它们都是 12 个字节，是否可以？</p>
<p>如果你搞定了上面的问题，那么不妨再想想，为什么 groupcache 通过增加一个 _ int32 来实现 64 位对齐，而 sync.WaitGroup 却是通过运行时判断来实现 64 位对齐呢？我本想一并写出答案，不过我遇到了和费马一样的问题，这里空白太少了，写不下 <img src="https://s.w.org/images/core/emoji/14.0.0/72x72/1f642.png" alt="🙂" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/09/29/951/feed</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>手把手教你用TARS</title>
		<link>https://huoding.com/2021/09/16/949</link>
					<comments>https://huoding.com/2021/09/16/949#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Thu, 16 Sep 2021 08:17:59 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=949</guid>

					<description><![CDATA[在中国，有一个简单的方法可以用来判断一个互联网公司够不够大，那就是看其是否开源过 &#8230; <a href="https://huoding.com/2021/09/16/949">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>在中国，有一个简单的方法可以用来判断一个互联网公司够不够大，那就是看其是否开源过 rpc 框架！比如阿里巴巴的 <a href="https://github.com/apache/dubbo" target="_blank" rel="noopener">dubbo</a>，或者腾讯的 <a href="https://github.com/TarsCloud/Tars" target="_blank" rel="noopener">tars</a>，小公司往往会对这些大公司的产品趋之若鹜，不过一个可悲的现实是大公司自己往往并不用他们开源的版本，这就好比皇帝总是把自己看不上眼的女人赏赐给臣民，不过能得到皇帝的赏赐总是好事，下面让我手把手教你用 tars，更具体的说是 <a href="https://github.com/TarsCloud/TarsGo" target="_blank" rel="noopener">tarsgo</a>，也就是 tars 的 golang 实现。</p>
<p><span id="more-949"></span></p>
<p>实际动手前，最好熟读<a href="https://github.com/TarsCloud/TarsDocs/blob/master/SUMMARY.md" target="_blank" rel="noopener">官方文档</a>，特别是<a href="https://github.com/TarsCloud/TarsDocs/blob/master/base/tars-concept.md" target="_blank" rel="noopener">基础概念</a>和<a href="https://github.com/TarsCloud/TarsDocs/blob/master/base/tars-protocol.md" target="_blank" rel="noopener">基础通讯协议</a>部分，假设你已经了解了这些内容，那么不妨让我们虚拟一个例子：给商城里的用户加积分！然后我们要构建一个 Shop（App），其中有一个 User（Server），其中有一个 Credit（Servant），可以简单的把 App、Server、Servant 这些概念理解成命名空间的几个层级，下面让我们用 tarsgo 内置的 create_tars_server_gomod.sh 脚本来生成项目！</p>
<pre>shell&gt; export GOPATH=$(go env GOPATH)
shell&gt; go env -w GO111MODULE=auto
shell&gt; go get -u github.com/TarsCloud/TarsGo/tars
shell&gt; $GOPATH/src/github.com/TarsCloud/TarsGo/tars/tools/create_tars_server_gomod.sh Shop User Credit foo</pre>
<p>因为 tarsgo 依赖 GOPATH 和 GO111MODULE，所以务必按照上面的步骤来操作，完成后会生成一个名为 User 的目录，其中的大概内容如下：</p>
<pre>shell&gt; tree ./User
./User
├── Credit.tars
├── Credit_imp.go
├── client
│   └── client.go
├── config.conf
├── debugtool
│   └── dumpstack.go
├── go.mod
├── main.go
├── makefile
└── start.sh</pre>
<p>编辑 Credit.tars 文件，定义好我们加积分的 Add 方法：</p>
<pre>module Shop
{
    interface Credit
    {
        int Add(int a,int b,out int c); // Some example function
    };
};
</pre>
<p>接下来就可以通过 tars 文件来生成 golang 文件了（其中调用了 tars2go 工具）：</p>
<pre>shell&gt; cd User
shell&gt; go mod tidy
shell&gt; make TARSBUILD</pre>
<p>文件生成好之后，编辑 Credit_imp.go 文件，加入我们的业务逻辑：</p>
<pre>func (imp *CreditImp) Add(ctx context.Context, a int32, b int32, c *int32) (int32, error) {
	log.Println("call add")
	return 100, nil
}</pre>
<p>编译服务端和客户端代码，运行就能看到效果了：</p>
<pre>shell&gt; go build -o user_server
shell&gt; go build -o user_client ./client/client.go
shell&gt; ./user_server -config config.conf
shell&gt; ./user_client</pre>
<p>BTW：缺省生成的客户端代码 client.go 导入的包路径不正确，需要手动调整一下。</p>
<p>总结一下，tarsgo 的开发过程比较简单，基本上就是：编写 tars 文件；用 tars2go 生成代码；实现业务逻辑。当然了，实际部署的时候会有一个管理平台，服务治理等复杂的问题都隐藏在平台里，这些细节就不是本文所考虑的了，再见。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/09/16/949/feed</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title>一个select死锁问题</title>
		<link>https://huoding.com/2021/08/29/947</link>
					<comments>https://huoding.com/2021/08/29/947#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Sun, 29 Aug 2021 13:12:17 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=947</guid>

					<description><![CDATA[话说前几天我遇到了一个死锁问题，当时想了一些办法糊弄过去了，不过并没有搞明白问题 &#8230; <a href="https://huoding.com/2021/08/29/947">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>话说前几天我遇到了一个死锁问题，当时想了一些办法糊弄过去了，不过并没有搞明白问题的细节，周末想起来便继续研究了一下，最终便有了这篇文章。</p>
<p><span id="more-947"></span></p>
<p>让我们搞一段简单的代码来重现一下当时我遇到的问题：</p>
<pre>package main

import "sync"

func main() {
	var wg sync.WaitGroup
	foo := make(chan int)
	bar := make(chan int)
	closing := make(chan struct{})
	wg.Add(1)
	go func() {
		defer wg.Done()
		select {
		case foo &lt;- &lt;-bar:
		case &lt;-closing:
			println("closing")
		}
	}()
	// bar &lt;- 123
	close(closing)
	wg.Wait()
}</pre>
<p>运行后报错，提示死锁：</p>
<blockquote><p>fatal error: all goroutines are asleep &#8211; deadlock!</p></blockquote>
<p>因为「foo &lt;- &lt;-bar」的写法不太常见，所以第一感觉是不是 select 的 case 语句只能操作一个 chan，不能同时操作多个 chan，于是我改了一下，每个 case 只读写一个 chan：</p>
<pre>package main

import "sync"

func main() {
	var wg sync.WaitGroup
	foo := make(chan int)
	bar := make(chan int)
	closing := make(chan struct{})
	wg.Add(1)
	go func() {
		defer wg.Done()
		select {
		case v := &lt;-bar:
			foo &lt;- v
		case &lt;-closing:
			println("closing")
		}
	}()
	close(closing)
	wg.Wait()
}</pre>
<p>果然死锁消失了！似乎 select 中，每个 case 确实只能读写一个 chan。为了确认到底是不是这个原因，我又修改了一下最初有问题的代码，加上了「bar &lt;- 123」，结果死锁也消失了。看来虽然我找到了解决问题的方法，但是并没有找到解释问题的原因。</p>
<p>周末在家躺在床上，想起我认识的一个 golang 大神总对我说的：一切问题的答案都在 <a href="https://golang.org/ref/spec" target="_blank" rel="noopener">spec</a> 里。于是挣扎着爬起来仔细翻阅关于 <a href="https://golang.org/ref/spec#Select_statements" target="_blank" rel="noopener">select</a> 的说明，终于发现了问题真正的原因：</p>
<blockquote><p>For all the cases in the statement, the channel operands of receive operations and the channel and right-hand-side expressions of send statements are evaluated exactly once, in source order, upon entering the &#8220;select&#8221; statement. The result is a set of channels to receive from or send to, and the corresponding values to send. Any side effects in that evaluation will occur irrespective of which (if any) communication operation is selected to proceed. Expressions on the left-hand side of a RecvStmt with a short variable declaration or assignment are not yet evaluated.</p></blockquote>
<p>结合这段话，让我们再来看看 case 中的这行代码「foo &lt;- &lt;-bar」，因为所有 chan 表达式都会被求值、所有被发送的表达式都会被求值，所以右手边表达式（&lt;-bar）会被先执行，如果拿到结果后再选择 case 执行，如果拿不到结果就会一直堵塞，于是死锁。</p>
<p>如果你还是想不明白，那么你可以认为问题代码实际上等价于如下的写法：</p>
<pre>package main

import "sync"

func main() {
	var wg sync.WaitGroup
	foo := make(chan int)
	bar := make(chan int)
	closing := make(chan struct{})
	wg.Add(1)
	go func() {
		defer wg.Done()
		v := &lt;-bar
		select {
		case foo &lt;- v:
		case &lt;-closing:
			println("closing")
		}
	}()
	// bar &lt;- 123
	close(closing)
	wg.Wait()
}</pre>
<p>如果你觉得自己已经完全明白了，那么不妨看看下面这段代码：</p>
<pre>package main

import (
	"fmt"
	"time"
)

func talk(msg string, sleep int) &lt;-chan string {
	ch := make(chan string)
	go func() {
		for i := 0; i &lt; 5; i++ {
			ch &lt;- fmt.Sprintf("%s %d", msg, i)
			time.Sleep(time.Duration(sleep) * time.Millisecond)
		}
	}()
	return ch
}

func fanIn(input1, input2 &lt;-chan string) &lt;-chan string {
	ch := make(chan string)
	go func() {
		for {
			select {
			case ch &lt;- &lt;-input1:
			case ch &lt;- &lt;-input2:
			}
		}
	}()
	return ch
}

func main() {
	ch := fanIn(talk("A", 10), talk("B", 1000))
	for i := 0; i &lt; 10; i++ {
		fmt.Printf("%q\n", &lt;-ch)
	}
}
</pre>
<p>当然会出现死锁，我的问题是为什么每次都是不多不少输出一半数据才死锁？请回答。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/08/29/947/feed</wfw:commentRss>
			<slash:comments>11</slash:comments>
		
		
			</item>
		<item>
		<title>在docker环境导入私有仓库的问题</title>
		<link>https://huoding.com/2021/08/24/944</link>
					<comments>https://huoding.com/2021/08/24/944#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Tue, 24 Aug 2021 07:45:38 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=944</guid>

					<description><![CDATA[最近我遇到了一个在 docker 环境导入私有仓库的问题：一个 Golang 项 &#8230; <a href="https://huoding.com/2021/08/24/944">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>最近我遇到了一个在 docker 环境导入私有仓库的问题：一个 Golang 项目，使用 <a href="https://docs.gitlab.com/ee/ci/" target="_blank" rel="noopener">gitlab ci</a> 来发布，通过 <a href="https://docs.gitlab.com/runner/" target="_blank" rel="noopener">gitlab runner</a> 调用 <a href="https://docs.docker.com/compose/" target="_blank" rel="noopener">docker-compose</a> 来打包，但是在构建时失败了。</p>
<p><span id="more-944"></span></p>
<p>让我们重回案发现场，看看问题是怎么产生的：</p>
<p>首先是 .gitlab-ci.yml 文件，其相关代码片段内容如下：</p>
<pre>build_job:
  stage: build
  script:
    - make docker-build</pre>
<p>然后是 Makefile 文件，其相关代码片段内容如下：</p>
<pre>.PHONY: docker-build
docker-build:
	@docker-compose build</pre>
<p>接着是 docker-compose.yml 文件，其相关代码片段内容如下：</p>
<pre>build:
  context: .
  dockerfile: Dockerfile</pre>
<p>最后是 Dockfile 文件，其相关代码片段内容一下：</p>
<pre>FROM golang:1.17 AS builder
WORKDIR /go/src/app
COPY . .
RUN go build</pre>
<p>结果在 build 的时候报错了：</p>
<blockquote><p>fatal: could not read Username for &#8216;https://git.domain.com&#8217;: terminal prompts disabled</p></blockquote>
<p>因为 git.domain.com 是一个私有仓库，所以问题乍一看上去会以为是 GOPRIVATE 和 GOPROXY 的配置有问题，不过我的配置都是 OK 的：</p>
<pre>shell&gt; go env -w GOPRIVATE=git.domain.com
shell&gt; go env -w GOPROXY=https://goproxy.cn,direct</pre>
<p>实际上，根本原因是因为访问私有仓库的时候是需要用户名和密码的，但是在 docker 容器里获取不到用户名密码，所以就报错了。下面看看我是如何解决问题的：</p>
<h2>第一次尝试</h2>
<p>既然问题出在用户名密码上，那么把仓库改成公开的不就可以了么？可惜结果报错：</p>
<blockquote><p>Visibility level public is not allowed in a private group.</p></blockquote>
<p>我用的是 gitlab，它不允许在私有组里搞一个公开项目。</p>
<h2>第二次尝试</h2>
<p>既然搞不成公开项目，那么就想办法传递用户名密码吧，不过我们在使用 git 的时候，一般不会直接使用用户名密码，而是使用 KEY 来访问仓库，下面举例说明一下如何传递私钥参数 SSH_PRIVATE_KEY（其中牵扯到一个 <a href="https://yeasy.gitbook.io/docker_practice/image/dockerfile/arg" target="_blank" rel="noopener">docker 构建参数</a>的概念）：</p>
<p>首先因为此类信息比较敏感，所以应该避免硬编码，我们选择在 gitlab 里创建它：</p>
<div id="attachment_945" style="width: 1210px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/08/variable.png"><img aria-describedby="caption-attachment-945" decoding="async" loading="lazy" class="size-full wp-image-945" src="https://huoding.com/wp-content/uploads/2021/08/variable.png" alt="Secret variables: settings &gt; Pipelines" width="1200" height="898" /></a><p id="caption-attachment-945" class="wp-caption-text">Secret variables: settings &gt; Pipelines</p></div>
<p>接着是 docker-compose.yml 文件，其相关代码片段内容如下：</p>
<pre>build:
  context: .
  dockerfile: Dockerfile
  args:
    - SSH_PRIVATE_KEY</pre>
<p>最后是 Dockfile 文件，其相关代码片段内容一下：</p>
<pre>FROM golang:1.17 AS builder
ARG SSH_PRIVATE_KEY
WORKDIR /go/src/app
COPY . .
RUN umask 0077 \
    &amp;&amp; mkdir -p ~/.ssh \
    &amp;&amp; echo "${SSH_PRIVATE_KEY}" &gt; ~/.ssh/id_rsa \
    &amp;&amp; ssh-keyscan git.domain.com &gt;&gt; ~/.ssh/known_hosts \
    &amp;&amp; git config --global url."git@git.domain.com:".insteadOf https://git.domain.com/
RUN go build</pre>
<p>此方法可以解决问题，但是把敏感信息传来传去总觉得不安心，容易出问题，资料：<a href="https://vsupalov.com/build-docker-image-clone-private-repo-ssh-key/" target="_blank" rel="noopener">Access Private Repositories from Your Dockerfile Without Leaving Behind Your SSH Keys</a>。</p>
<h2>第三次尝试</h2>
<p>如果不想把敏感信息传来传去，那么还有没有安全的解决方案呢？答案是肯定的！我们只要在 gitlab runner 里执行「go mod vendor」就可以了，这是因为 gitlab runner 已经缓存了 git 认证信息，它可以访问所有的私有仓库，当执行「go mod vendor」后，项目依赖就都被放到 vendor 目录里了，接下来当执行到 Dockerfile 的 COPY 指令时，依赖就被自然而然的拷贝到了容器中，从而不用再联网执行 git 下载。</p>
<p>下面是修改后的 .gitlab-ci.yaml 文件，其相关代码片段内容如下：</p>
<pre>build_job:
  stage: build
  script:
    - go mod vendor
    - make docker-build</pre>
<p>也就是说，我们只加了一行代码「go mod vendor」，就解决了问题，是不是很简洁。最后友情提示一下：记得把 vendor 目录放到 .gitignore 里哦。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/08/24/944/feed</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>记又一次对Makefile的重构</title>
		<link>https://huoding.com/2021/08/21/943</link>
					<comments>https://huoding.com/2021/08/21/943#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Sat, 21 Aug 2021 06:34:10 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[AWK]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=943</guid>

					<description><![CDATA[我平常有一个习惯，就是不断看以前写的代码，想着有没有哪些方面可以改进，如果每天能 &#8230; <a href="https://huoding.com/2021/08/21/943">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>我平常有一个习惯，就是不断看以前写的代码，想着有没有哪些方面可以改进，如果每天能把代码可读性量变​ 1%，那么日积月累就是质变：前些天我们写过一次对 Makefile 的重构，去掉了一处重复代码的坏味道，没过多久我便又发现了一处重复代码的坏味道，本文就让我们看看如何消灭它！</p>
<p><span id="more-943"></span></p>
<p>让我们先把问题的来龙去脉搞清楚，在 Golang 项目里，一般<a href="https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module" target="_blank" rel="noopener">推荐</a>在根目录创建一个名为 tools.go 的文件，里面记录本项目依赖的相关工具，比如我的某个项目的 tools.go 如下：</p>
<pre>// +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"
)
</pre>
<p>如此一来，当执行「go mod tidy」的时候，依赖工具的版本信息就会记录到 go.mod，接下来一般推荐在 Makefile 里创建一个 dep 操作，用来安装（make dep）依赖工具：</p>
<pre>.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</pre>
<p>看上去不错，但是细心的你估计已经发现重复代码的坏味道了：tools.go 和 Makefile 文件内容重复了，以后如果想要增加一个依赖工具的话，那么两个文件都要改！</p>
<p>下面让我们看看如何重构：tools.go 和 Makefile 比起来，肯定 tools.go 更重要，它是不能改的，所以我们要去掉 Makefile 里的重复代码，更具体点来说是最好能在 Makefile 里通过 解析 tools.go 来确定想要执行的 go install 操作，这不就是 awk 擅长的工作么：</p>
<pre>.PHONY: dep
dep:
	@awk '$$1 == "_" { print $$2 | "xargs go install" }' ./tools.go</pre>
<p>看，通过一行 awk 代码，我们神奇的去掉了原本一坨重复代码，完美！</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/08/21/943/feed</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title>记一次对Makefile的重构</title>
		<link>https://huoding.com/2021/08/19/942</link>
					<comments>https://huoding.com/2021/08/19/942#respond</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Thu, 19 Aug 2021 07:53:45 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=942</guid>

					<description><![CDATA[如果你不了解 Makefile 的话，那么推荐看看阮一峰的文章「Make 命令教 &#8230; <a href="https://huoding.com/2021/08/19/942">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>如果你不了解 Makefile 的话，那么推荐看看阮一峰的文章「<a href="https://www.ruanyifeng.com/blog/2015/02/make.html" target="_blank" rel="noopener">Make 命令教程</a>」。本文通过一个重构的例子带你写出味道更好的 Makefile，让我们开始吧！</p>
<p><span id="more-942"></span></p>
<p>假设有一个名为 foo 的项目，用 golang 开发，在 docker 上部署，其 Makefile 如下：</p>
<pre>APP = $(shell basename ${CURDIR})
TAG = $(shell git log --pretty=format:"%cd.%h" --date=short -1)

.PHONY: build
build:
	go build -ldflags "-X 'main.version=${TAG}'" -o ./tmp/${APP} .

.PHONY: docker-config
docker-config: env
	TAG=${TAG} docker-compose config

.PHONY: docker-build
docker-build: env
	TAG=${TAG} docker-compose build

.PHONY: docker-push
docker-push: env
	TAG=${TAG} docker-compose push

.PHONY: docker-up
docker-up: env
	TAG=${TAG} docker-compose up

.PHONY: docker-down
docker-down:
	TAG=${TAG} docker-compose down
</pre>
<p>看上去很简洁，唯一需要说明的是在操作 docker-compose 的时候，传递了一个名为 TAG 的环境变量，表示项目当前所属的标签，看一下对应的 docker-compose.yml 文件：</p>
<pre>version: "3.0"
services:
  server:
    image: docker.domain.com/foo:${TAG}
    build:
      context: .
      dockerfile: build/docker/Dockerfile
    ports:
      - "9090:9090"
      - "6060:6060"
</pre>
<p>此时出现了一个有待改进的地方：ports 信息重复，看一下对应的 config.toml 文件：</p>
<pre>[rpc]
port = 9090

[debug]
port = 6060</pre>
<p>其中，rpc 端口 9090，debug 端口 6060 最初是在 config.toml 文件里配置的，但是在 docker-compose.yml 文件又重复了一次，假设要修改的话，就需要修改多个地方。</p>
<p>此时我们很容易想到的解决方案是把端口信息也通过环境变量传递，就像 TAG 变量那样，确定了解决方案，让我们再看一下对应的 docker-compose.yml 文件：</p>
<pre>version: "3.0"
services:
  server:
    image: docker.domain.com/${APP}:${TAG}
    build:
      context: .
      dockerfile: build/docker/Dockerfile
    ports:
      - "${RPC_PORT}:${RPC_PORT}"
      - "${DEBUG_PORT}:${DEBUG_PORT}"
</pre>
<p>让我们再看看对应的 Makefile 文件，其中用 <a href="https://github.com/tomwright/dasel" target="_blank" rel="noopener">dasel</a> 来解析配置文件：</p>
<pre>APP = $(shell basename ${CURDIR})
TAG = $(shell git log --pretty=format:"%cd.%h" --date=short -1)
RPC_PORT   = $(shell dasel -f configs/production.toml rpc.port)
DEBUG_PORT = $(shell dasel -f configs/production.toml debug.port)

.PHONY: build
build:
	go build -ldflags "-X 'main.version=${TAG}'" -o ./tmp/${APP} .

.PHONY: docker-config
docker-config: env
	APP=${APP} TAG=${TAG} RPC_PORT=${RPC_PORT} DEBUG_PORT=${DEBUG_PORT} docker-compose config

.PHONY: docker-build
docker-build: env
	APP=${APP} TAG=${TAG} RPC_PORT=${RPC_PORT} DEBUG_PORT=${DEBUG_PORT} docker-compose build

.PHONY: docker-push
docker-push: env
	APP=${APP} TAG=${TAG} RPC_PORT=${RPC_PORT} DEBUG_PORT=${DEBUG_PORT} docker-compose push

.PHONY: docker-up
docker-up: env
	APP=${APP} TAG=${TAG} RPC_PORT=${RPC_PORT} DEBUG_PORT=${DEBUG_PORT} docker-compose up

.PHONY: docker-down
docker-down:
	APP=${APP} TAG=${TAG} RPC_PORT=${RPC_PORT} DEBUG_PORT=${DEBUG_PORT} docker-compose down
</pre>
<p>不得不说，长长的环境变量实在是太丑了，好在 <a href="https://docs.docker.com/compose/environment-variables/" target="_blank" rel="noopener">docker-compose 支持 .env 文件</a>，于是我们可以把环境变量写入 .env 文件，然后让 docker-compose 命令从其中取数据：</p>
<pre>APP = $(shell basename ${CURDIR})
TAG = $(shell git log --pretty=format:"%cd.%h" --date=short -1)
RPC_PORT   = $(shell dasel -f configs/production.toml rpc.port)
DEBUG_PORT = $(shell dasel -f configs/production.toml debug.port)

.PHONY: env
env: build
	echo "APP=${APP}" &gt; .env; \
		echo "TAG=${TAG}" &gt;&gt; .env; \
		echo "RPC_PORT=${RPC_PORT}" &gt;&gt; .env; \
		echo "DEBUG_PORT=${DEBUG_PORT}" &gt;&gt; .env

.PHONY: build
build:
	go build -ldflags "-X 'main.version=${TAG}'" -o ./tmp/${APP} .

.PHONY: docker-config
docker-config: env
	docker-compose config

.PHONY: docker-build
docker-build: env
	docker-compose build

.PHONY: docker-push
docker-push: env
	docker-compose push

.PHONY: docker-up
docker-up: env
	docker-compose up

.PHONY: docker-down
docker-down: env
	docker-compose down
</pre>
<p>在 Makefile 里，我们定义了一个 env 操作，并把它作为所有 docker-compose 操作的前置操作来执行，终于不用再写长长的环境变量了，不过记得把 .env 写到 .gitignore 里！</p>
<div id="simple-translate">
<div>
<div class="simple-translate-button isShow" style="background-image: url('chrome-extension://ibplnjkanclpjokhdolnendpplpjiace/icons/512.png'); height: 22px; width: 22px; top: 2347px; left: 26px;"></div>
<div class="simple-translate-panel " style="width: 300px; height: 200px; top: 0px; left: 0px; font-size: 13px; background-color: #ffffff;">
<div class="simple-translate-result-wrapper" style="overflow: hidden;">
<div class="simple-translate-move" draggable="true"></div>
<div class="simple-translate-result-contents">
<p class="simple-translate-candidate" dir="auto" style="color: #737373;">
</div>
</div>
</div>
</div>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/08/19/942/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>关于OCR项目的流水账</title>
		<link>https://huoding.com/2021/08/16/938</link>
					<comments>https://huoding.com/2021/08/16/938#respond</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Mon, 16 Aug 2021 08:58:15 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=938</guid>

					<description><![CDATA[最近一直在开发某个 OCR 项目：底层用的是 ABBYY 提供的 FineRea &#8230; <a href="https://huoding.com/2021/08/16/938">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>最近一直在开发某个 OCR 项目：底层用的是 ABBYY 提供的 FineReader 引擎，应用层把 FineReader 包装成 gRPC 对外提供服务，因为 FineReader 项目是 C++ 实现的，而我们团队使用的编程语言是 Golang，所以二者间通过 CGO 来完成交互。整个项目没有什么特殊的需求，只是鉴于 OCR 耗时较长，为了提升产品体验，要求在处理过程中：客户端可以主动退出；服务端能够实时返回已处理百分比。下面是根据需求画出来的流程图：</p>
<div id="attachment_939" style="width: 791px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/08/flow.png" target="_blank" rel="noopener"><img aria-describedby="caption-attachment-939" decoding="async" loading="lazy" class="wp-image-939 size-full" src="https://huoding.com/wp-content/uploads/2021/08/flow.png" alt="流程图" width="781" height="422" /></a><p id="caption-attachment-939" class="wp-caption-text">流程图</p></div>
<p>看上去很简单，不过我还是遇到不少问题，虽然这些问题主要都是一些细枝末节，基本上和 OCR 没什么关系，但是对别的项目还是会有所帮助的，下面让我一一道来。</p>
<p><span id="more-938"></span></p>
<h2>代码冗长</h2>
<p>编程里最常见的坏味道就是代码冗长，比如我的 main.go 就是如此，它足足有几百行代码之多，里面充斥着各种初始化配置，日志之类的操作。</p>
<p>为了规避此类问题，我引入了一个 initializer 的概念，用来统一初始化操作，比如 viper：</p>
<pre>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
}
</pre>
<p>有了 initializer 之后，原本挤在一起的代码就可以分而治之，同时因为函数签名统一返回 error，所以可以统一进行错误处理，最终 main.go 代码行数大大降低：</p>
<pre>var version string

func main() {
	var env string
	cobra.EnableCommandSorting = false
	cobra.OnInitialize(func() {
		check(initializer.Viper(env))
		check(initializer.Logrus())
		// ...
	})
	rootCmd := &amp;cobra.Command{
		Use:     filepath.Base(os.Args[0]),
		Version: version,
	}
	rootCmd.PersistentFlags().StringVarP(
		&amp;env, "env", "e", os.Getenv("SERVICE_ENV"), "env",
	)
	rootCmd.AddCommand(cmd.NewServerCmd())
	check(rootCmd.Execute())
}

func check(err error) {
	if err != nil {
		panic(err)
	}
}</pre>
<p>除了 initializer 以外，其实我还引入了一个 provider 的概念，用来获取 sarama 等实例，也可以降低代码冗长的坏味道，提升复用性，篇幅所限，本文就不做赘述了。</p>
<h2>同步异步</h2>
<p>因为我之前一直在学习 Kafka，所以最初在架构选型的时候完全忽略了 gRPC 之类的同步架构，一门心思的想要以 Kafka 为中心打造一个基于事件的异步架构。此类极端的思想往往是个坏信号，实际上这就跟政治一样，不管是极左还是极右，通常都不可取。关于同步和异步，各取所长才是最合理的选择，判断方法：如果是业务逻辑的实现部分，那么倾向于选择使用同步；如果是业务逻辑完成之后的后续通知部分：强烈建议选择使用异步。具体请参考「<a href="https://skyao.io/talk/202007-microservice-avoiding-distributed-monoliths/" target="_blank" rel="noopener">走出微服务误区：避免从单体到分布式单体</a>」。</p>
<h2>Kafka 客户端</h2>
<p>既然 Kafka 在架构中的地位如此重要，那么需要选择一下用哪个客户端，其 Golang 客户端主要有：<a href="https://github.com/Shopify/sarama" target="_blank" rel="noopener">sarama</a>、<a href="https://github.com/confluentinc/confluent-kafka-go" target="_blank" rel="noopener">confluent-kafka-go</a>、<a href="https://github.com/segmentio/kafka-go" target="_blank" rel="noopener">kafka-go</a>，优缺点如下：</p>
<ul>
<li>sarama：它是最流行也是最难用的，文档很烂，API 封装太低级，暴露了过多 Kafka 协议的细节，而且还不支持 context 等新的 Golang 特色，实现上它把所有值都当指针传递，导致过多的动态内存分配，频繁的垃圾回收，大量的内存使用。</li>
<li>confluent-kafka-go：它是基于 librdkafka 实现的 CGO，这意味着使用了这个包，你的代码就会依赖 C 库，和 sarama 相比，它的文档更好，但是同样不支持 context。</li>
<li>kafka-go：前面关于 saram 和 confluent-kafka-go 的坏话都是它说的。</li>
</ul>
<p>看上去似乎 kafka-go 最好，confluent-kafka-go 次之，sarama 最烂，可是当我问一个鹅厂小伙伴的时候，他说他们都用 sarama，信大厂得永生，于是乎我也决定选 sarama 了，事后证明这可能是一个糟糕的选择，sarama 虽然很流行，但是确实很难用。但是不管怎么说，使用 sarama 的案例相对更多，用起来也更安心些，不过用之前要清楚坑在哪：</p>
<ul>
<li style="list-style-type: none;">
<ul>
<li><a href="https://www.cnblogs.com/wishFreedom/p/15131600.html" target="_blank" rel="noopener">Golang中如何正确的使用sarama包操作Kafka？</a></li>
<li><a href="https://help.aliyun.com/document_detail/266782.html" target="_blank" rel="noopener">为什么不推荐使用Sarama Go客户端收发消息？</a></li>
</ul>
</li>
</ul>
<h2>Sarama 的版本</h2>
<p>一开始用 sarama 的时候，就遭到了当头棒喝，遇到了如下错误：</p>
<blockquote><p>ERROR: Failed to open Kafka producer: kafka: client has run out of available brokers to talk to (Is your cluster reachable?)</p></blockquote>
<p>反复确认才发现是版本问题，我们的服务端版本比较低（0.11.0.0），翻看 <a href="https://github.com/Shopify/sarama/releases/tag/v1.27.1" target="_blank" rel="noopener">sarama 的 changelog</a>，发现是在 1.27.1 开始切换到高版本的，如此说来只要使用 1.27.0 就可以了，同时务必记得把版本依赖写入 go.mod 文件中：</p>
<pre>replace github.com/Shopify/sarama =&gt; github.com/Shopify/sarama v1.27.0</pre>
<h2>多个 goroutines 的协同</h2>
<p>前面提到 sarama 有一个问题是暴露了过多 Kafka 协议的细节，这一点在使用 consumer 的时候可见一斑：因为 sarama 暴露了分区的细节，所以带来了很多麻烦，比如要关闭 consumer 的话，不得不先关闭每一个分区上的 PartitionConsumer，最后才可以关闭 consumer。不过话说回来，正好可以借机练习一下多个 goroutines 的协同：</p>
<pre>type Watchman struct {
	waitGroup sync.WaitGroup
	consumer  sarama.Consumer
	closing   chan struct{}
}

func NewWatchmanFromConsumer(c sarama.Consumer) *Watchman {
	return &amp;Watchman{
		consumer: c,
		closing:  make(chan struct{}),
	}
}

func (w *Watchman) Watch(topic string) (&lt;-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 := &lt;-pc.Messages():
					msg &lt;- message
				case &lt;-w.closing:
					pc.Close()
					return
				}
			}
		}()
	}
	return msg, nil
}

func (w *Watchman) Close() {
	close(w.closing)
	w.waitGroup.Wait()
	w.consumer.Close()
}</pre>
<p>说明：留意代码中是如何通过 waitGroup 和 closing 来处理多个 goroutines 的协同的。</p>
<h2>编译错误</h2>
<p>一般编译 Golang 代码不会遇到什么错误，但是因为我们的项目牵扯到 C++，所以在编译过程中还是遇到了一些莫名其妙的问题，下面逐一记录一下：</p>
<p>error adding symbols: DSO missing from command line：</p>
<p>在老版本的 binutils 里，ld 会自动递归地解析链接的 lib，不过从 2.22（ld -v）开始，ld 缺省激活了 &#8211;no-copy-dt-needed-entries 选项，如此一来，ld 不会再自动递归地解析链接的 lib，而是需要由用户来手动指定。知道了来龙去脉，不难想到如下解决方案：</p>
<ul>
<li>手动：通过 -l 选项手动加载需要的库，比如需要 libz.so，就设置 -lz</li>
<li>自动：在 LDFLAGS 里添加 -Wl,&#8211;copy-dt-needed-entries 选项</li>
</ul>
<p>推荐资料：<a href="https://stackoverflow.com/questions/19901934/libpthread-so-0-error-adding-symbols-dso-missing-from-command-line" target="_blank" rel="noopener">libpthread.so.0: error adding symbols: DSO missing from command line</a></p>
<p>undefined reference to `__cxa_throw_bad_array_new_length&#8217;：</p>
<p>编译 libstdc++ 时，会使用命令 msgfmt。而 msgfmt 依赖 libstdc++.so.6，但编译时，gcc的编译系统会把 msgfmt 的依赖指向其自身的 libstdc++.so.6，而不是系统自带的libstdc++.so.6。如果 gcc 的版本比较老，就会导致 libstdc++.so.6 与 msgfmt 不兼容。</p>
<p>知道了来龙去脉，不难想到解决方案就是使用新版 gcc，更具体一点说是使用版本不低于 4.9 的 gcc（CentOS 7 上的 gcc 版本一般是 4.8.5），不过不推荐直接从源代码安装新版 gcc，其困难程度不是一般人能接受的，相对更可取的方法是通过 <a href="https://www.softwarecollections.org/en/" target="_blank" rel="noopener">scl</a> 安装 devtoolset：</p>
<pre>shell&gt; gcc -v
gcc version 4.8.5
shell&gt; yum install centos-release-scl
shell&gt; yum install devtoolset-7
shell&gt; scl enable devtoolset-7 bash
shell&gt; gcc -v
gcc version 7.3.1
shell&gt; exit
shell&gt; gcc -v
gcc version 4.8.5</pre>
<p>关于 devtoolset 还有一个冷知识：devtoolset 和 gcc 的版本对应关系如下：</p>
<ul>
<li>devtoolset-3: gcc 4.9</li>
<li>devtoolset-4: gcc 5</li>
<li>devtoolset-6: gcc 6</li>
<li>devtoolset-7: gcc 7</li>
<li>devtoolset-8: gcc 8</li>
</ul>
<p>你会发现没有版本 5，原因在 <a href="https://access.redhat.com/documentation/en-us/red_hat_developer_toolset/6/html-single/6.0_release_notes/index" target="_blank" rel="noopener">Release Notes for Red Hat Developer Toolset 6.0</a> 里说了：</p>
<blockquote><p>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.</p></blockquote>
<p>嗯，我承认这个无聊的问题困扰了我好几年，最终知道原因后感觉真是怅然若失啊。</p>
<h2>条件编译</h2>
<p>因为我们的服务底层是 FineReader 引擎，而且我们只有其 Linux 版本的 SDK，加上我们的本地开发环境是 MAC 系统，所以一开始我们在本地是没办法编译的，每次修改完代码我都会把代码传到 Linux 上编译，真是让人焦躁啊，好在 Golang 支持通过文件名来进行条件编译，比如我把原本的 abbyy.go 文件按操作系统拆分出 _linux.go 和 _darwin.go：</p>
<p>abbyy_linux.go：</p>
<pre>package doc

// #cgo CFLAGS: -I .
// #cgo LDFLAGS: ${SRCDIR}/vendor/libabbyy.a -L /opt/ABBYY/FREngine12/Bin -lFREngine -lPortLayer -lstdc++
// #include &lt;stdlib.h&gt;
/*
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
}</pre>
<p>abbyy_darwin.go：</p>
<pre>package doc

func doJob(source, destination, status string) bool {
	return false
}</pre>
<p>拆分后，虽然我的 MAC 系统还是不能使用 FineReader 引擎，但是至少能够在本地开发环境正常编译了，处理一些非 CGO 类的问题绰绰有余了。</p>
<h2>测试 gRPC</h2>
<p>开发完成 gRPC 服务后，免不了要时不时的测试它，最开始我用的是 <a href="https://github.com/fullstorydev/grpcurl" target="_blank" rel="noopener">grpcurl</a>，类似：</p>
<pre>shell&gt; grpcurl -plaintext -emit-defaults \
    -d '{"source":"/tmp/01.pdf","destination":"/tmp/02.pdf"}' \
    &lt;address&gt; doc.v1.AbbyyService.OCR</pre>
<p>不过命令行用起来总是不如 web 方便，于是借助 <a href="https://github.com/grpc-ecosystem/grpc-gateway" target="_blank" rel="noopener">grpc-gateway</a> 集成了 swagger：</p>
<pre>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;
}
</pre>
<p>通过 protoc 编译：</p>
<pre>shell&gt; 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</pre>
<p>其中 protoc-gen-openapiv2 插件能够生成 swagger 所需的 json文件，更多 openapiv2 的使用例子可以参考：<a href="https://blog.bullgare.com/2020/07/complete-list-of-swagger-options-to-protobuf-file/" target="_blank" rel="noopener">Complete list of swagger options to protobuf file</a>，最终效果如下：</p>
<div id="attachment_940" style="width: 1608px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/08/swagger.png"><img aria-describedby="caption-attachment-940" decoding="async" loading="lazy" class="size-full wp-image-940" src="https://huoding.com/wp-content/uploads/2021/08/swagger.png" alt="swagger" width="1598" height="1318" /></a><p id="caption-attachment-940" class="wp-caption-text">swagger</p></div>
<p>顺便说一句，为了部署方便，我用「//go:embed *」语法把整个 <a href="https://swagger.io/tools/swagger-ui/" target="_blank" rel="noopener">swagger ui</a> 打包进二进制文件了，不得不说，embed 真是爽啊，有兴趣的可以参考：<a href="https://colobu.com/2021/01/17/go-embed-tutorial/" target="_blank" rel="noopener">Go embed 简明教程</a>。</p>
<p><strong>公共 proto</strong></p>
<p>在编写 proto 的时候，我们用到了 <a href="https://github.com/googleapis/googleapis" target="_blank" rel="noopener">googleapis</a>，<a href="https://github.com/grpc-ecosystem/grpc-gateway" target="_blank" rel="noopener">grpc-gateway</a> 等项目里的公共 proto，这里牵扯到一个如何导入公共 proto 的问题，最常见的方法是把这些公共 proto 直接拷贝到项目目录中，但是如果有很多的项目需要用到这些公共 proto 的话，那么就不得不拷贝很多个副本，于是又有人把公共 proto 统一保存到独立的仓库中，然后其他项目在构建的时候都引用它，如此也不错，不过总觉得差点啥，最终我发现了完美的解决方案 <a href="https://buf.build/" target="_blank" rel="noopener">buf</a>：</p>
<p>先编写 buf.yaml 文件，主要用来声明依赖那些公共 proto：</p>
<pre>version: v1beta1
deps:
  - buf.build/beta/googleapis
  - buf.build/grpc-ecosystem/grpc-gateway
build:
  roots:
    - ./pkg/proto
</pre>
<p>再编写 buf.gen.yaml 文件，主要用来声明使用哪些插件，如何生成需要的文件：</p>
<pre>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
</pre>
<p>准备好后，先用「buf mod update」命令生成 buf.lock 锁定版本信息，再用「buf generate」命令就可以生成我们要的各种 go 文件和 json 文件了：</p>
<pre>shell&gt; buf mod update
shell&gt; buf generate</pre>
<p>可见使用 buf 比直接使用 protoc 要方便很多，而且还有很多高级功能，详见 <a href="https://docs.buf.build/" target="_blank" rel="noopener">buf 文档</a>。</p>
<h2>依赖工具</h2>
<p>在使用 grpc-gateway 的时候，我们用到了其中的 protoc-gen-openapiv2 工具，实际上，grpc-gateway 有两个大版本，protoc-gen-openapiv2 在 v2 版本中，而在 v1 版本中对应的工具叫做 protoc-gen-swagger，很容易混淆，可见明确依赖工具的版本非常重要。</p>
<p>目前<a href="https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module" target="_blank" rel="noopener">推荐</a>的方法是在项目根目录创建名为 tools.go 的文件来记录依赖工具，比如：</p>
<pre>// +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"
)</pre>
<p>如此一来，当执行「go mod tidy」的时候，依赖工具的版本信息也会被 go.mod 记录下来，后续别人接手项目后，就很清楚的知道依赖什么工具，分别是什么版本了。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/08/16/938/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>浅谈K8S下gRPC负载均衡问题</title>
		<link>https://huoding.com/2021/07/14/929</link>
					<comments>https://huoding.com/2021/07/14/929#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Wed, 14 Jul 2021 09:21:31 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=929</guid>

					<description><![CDATA[一般来说，在 K8S 下部署服务是很简单的事儿，但是如果部署的是一个 gRPC  &#8230; <a href="https://huoding.com/2021/07/14/929">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>一般来说，在 K8S 下部署服务是很简单的事儿，但是如果部署的是一个 gRPC 服务的话，那么稍不留神就可能掉坑里，个中缘由，且听我慢慢道来。</p>
<p><span id="more-929"></span></p>
<p>在 K8S 下部署服务，缺省情况下会被分配一个地址（也就是 <a href="https://kubernetes.io/docs/concepts/services-networking/service/" target="_blank" rel="noopener">ClusterIP</a>），客户端的请求会发送给它，然后再通过负载均衡转发给后端某个 pod：</p>
<div id="attachment_933" style="width: 1130px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/07/cluster_ip.png"><img aria-describedby="caption-attachment-933" decoding="async" loading="lazy" class="size-full wp-image-933" src="https://huoding.com/wp-content/uploads/2021/07/cluster_ip.png" alt="ClusterIP" width="1120" height="690" /></a><p id="caption-attachment-933" class="wp-caption-text">ClusterIP</p></div>
<p>如果是 HTTP/1.1 之类的服务，那么 ClusterIP 完全没有问题；但是如果是 gRPC 服务，那么 ClusterIP 会导致负载失衡，究其原因，是因为 gRPC 是基于 HTTP/2 的，多个请求在一个 TCP 连接上多路复用，一旦 ClusterIP 和某个 pod 建立了 gRPC 连接后，因为多路复用的缘故，所以后续其它请求也都会被转发给此 pod，结果其它 pod 则完全被忽略了。</p>
<p>看到这里，有的读者可能会有疑问：HTTP/1.1 不是实现了基于 KeepAlive 的连接复用么？为什么 HTTP/1.1 的复用没问题，而 HTTP/2 的复用就有问题？答案是 HTTP/1.1 的 复用是串行的，当请求到达的时候，如果没有空闲连接那么就新创建一个连接，如果有空闲连接那么就可以复用，同一个时间点，连接里最多只能承载一个请求，结果是 HTTP/1.1 可以连接多个 pod；而 HTTP/2 的复用是并行的，当请求到达的时候，如果没有连接那么就创建连接，如果有连接，那么不管其是否空闲都可以复用，同一个时间点，连接里可以承载多个请求，结果是 HTTP/2 仅仅连接了一个 pod。</p>
<p>了解了 K8S 下 gRPC 负载均衡问题的来龙去脉，我们不难得出如下解决方案：</p>
<p>在 Proxy 中实现负载均衡：采用 Envoy 做代理，和每台后端服务器保持长连接，当客户端请求到达时，代理服务器依照规则转发请求给后端服务器，从而实现负载均衡。</p>
<div id="attachment_935" style="width: 1130px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/07/proxy.png"><img aria-describedby="caption-attachment-935" decoding="async" loading="lazy" class="size-full wp-image-935" src="https://huoding.com/wp-content/uploads/2021/07/proxy.png" alt="Proxy" width="1120" height="688" /></a><p id="caption-attachment-935" class="wp-caption-text">Proxy</p></div>
<p>在 Client 中实现负载均衡：把服务部署成 <a href="https://kubernetes.io/docs/concepts/services-networking/service" target="_blank" rel="noopener">headless service</a>，这样服务就有了一个域名，然后客户端通过域名访问 gRPC 服务，DNS resolver 会通过 DNS 查询后端多个服务器地址，然后通过算法来实现负载均衡。</p>
<div id="attachment_934" style="width: 1130px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/07/client.png"><img aria-describedby="caption-attachment-934" decoding="async" loading="lazy" class="size-full wp-image-934" src="https://huoding.com/wp-content/uploads/2021/07/client.png" alt="Client" width="1120" height="630" /></a><p id="caption-attachment-934" class="wp-caption-text">Client</p></div>
<p>两种方案的优缺点都很明显：Proxy 方案结构清晰，客户端不需要了解后端服务器，对架构没有侵入性，但是性能会因为存在转发而打折扣；Client 方案结构复杂，客户端需要了解后端服务器，对架构有侵入性，但是性能更好。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/07/14/929/feed</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title>实战CGO</title>
		<link>https://huoding.com/2021/07/03/924</link>
					<comments>https://huoding.com/2021/07/03/924#respond</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Sat, 03 Jul 2021 06:55:03 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=924</guid>

					<description><![CDATA[某项目要集成 PDF 文件的 OCR 功能，不过由于此功能技术难度太大，网络上找 &#8230; <a href="https://huoding.com/2021/07/03/924">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>某项目要集成 PDF 文件的 OCR 功能，不过由于此功能技术难度太大，网络上找不到靠谱的开源实现，最终不得不选择 <a href="https://www.abbyy.com/ocr-sdk/" target="_blank" rel="noopener">ABBYY FineReader Engine</a> 的付费服务。可惜 ABBYY 只提供了 C++ 和 Java 两种编程语言的 SDK，而我们的项目采用的编程语言是 Golang，此时通常的集成方法是使用 C++ 或 Java 实现一个服务，然后在 Golang 项目里通过 RPC 调用服务，不过如此一来明显增加了系统的复杂度，好在 Golang 支持 <a href="https://golang.org/cmd/cgo/" target="_blank" rel="noopener">CGO</a>，让我们可以很方便的在 Golang 中使用 C 模块，本文总结了我在学习 CGO 过程中的心得体会。</p>
<p><span id="more-924"></span></p>
<h2>Hello World</h2>
<p>让我们看看一个 CGO 版本的 Hello, world 大概长什么样：</p>
<pre>package main

/*
#include &lt;stdio.h&gt;

void say(const char *s) {
    puts(s);
}
*/
import "C"

func main() {
    hello()
}

func hello() {
    s := C.CString("Hello, World\n")
    C.say(s)
}</pre>
<p>如上所示，通过「import &#8220;C&#8221;」来激活 CGO，并且所有 C 语言相关的代码都以注释的形式放在此行之上，中间不允许有空行，这样我们就可以在 Golang 代码里使用 C 模块了,看上去很简单，不过代码里存在内存泄漏，让我们修改一下代码，使问题更明显一点：</p>
<pre>package main

/*
#include &lt;stdio.h&gt;

void say(const char *s) {
    puts(s);
}
*/
import "C"

func main() {
    for {
        hello()
    }
}

func hello() {
    s := C.CString("Hello, World\n")
    C.say(s)
}</pre>
<p>运行程序后，我们可以单独开一个命令行窗口，通过运行 top 命令来监控进程的内存变化，会发现在循环调用 C 模块之后，进程的内存占用不断增加，究其原因，是因为通过 C.CString 创建的变量，会在 C 语言层面上分配内存，而在 Golang 语言层面上是不会负责管理相关内存的，所以我们需要通过 C.free 手动释放相关内存：</p>
<pre>package main

/*
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;

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)
}</pre>
<p>说明：代码中的 unsafe.Pointer 相当于 C 语言中的 void *。</p>
<h2>In Action</h2>
<p>有些读者看到这里可能会有疑问：虽然 CGO 让我们可以在 Golang 里使用 C，但是文章开头提到的 ABBYY 并没有 C 的 SDK，只有 C++ 的 SDK，那么 CGO 支持 C++ 么？答案是否定的，不过我们可以通过 C 来适配 C++。</p>
<p>以 ABBYY 为例，假设它的安装目录是 /opt/ABBYY/FREngine12，并且通过 <a href="https://linux.die.net/man/8/ldconfig" target="_blank" rel="noopener">ldconfig</a> 把 /opt/ABBYY/FREngine12/Bin 目录加入到动态链接库的查找目录：</p>
<pre>shell&gt; echo "/opt/ABBYY/FREngine12/Bin" &gt; /etc/ld.so.conf.d/abbyy.conf
shell&gt; ldconfig</pre>
<p>准备工作做好后使用 /opt/ABBYY/FREngine12/Samples/Hello 例子做代码范本：</p>
<p>先编写 OCR.cpp 文件的内容，不用在意技术细节，我放这些代码只是为了备份：</p>
<pre>#include &lt;string&gt;
#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&lt;IFRDocument&gt; frDocument = 0;
    CSafePtr&lt;IDocumentProcessingParams&gt; documentProcessingParams;
    CSafePtr&lt;IPageProcessingParams&gt; pageProcessingParams;
    CSafePtr&lt;IRecognizerParams&gt; recognizerParams;

    try {
        CheckResult(FREngine-&gt;CreateFRDocumentFromImage(CBstr(inPath), 0, &amp;frDocument));
        CheckResult(FREngine-&gt;CreateDocumentProcessingParams(&amp;documentProcessingParams));
        CheckResult(documentProcessingParams-&gt;get_PageProcessingParams(&amp;pageProcessingParams));
        CheckResult(pageProcessingParams-&gt;get_RecognizerParams(&amp;recognizerParams));
        CheckResult(recognizerParams-&gt;SetPredefinedTextLanguage(CBstr(language)));
        CheckResult(frDocument-&gt;Process(documentProcessingParams));
        CheckResult(frDocument-&gt;Export(CBstr(outPath), format, 0));
    } catch (...) {
        return;
    }
}

</pre>
<p>再编写 OCR.h 文件的内容，要特别注意其中的「<a href="https://stackoverflow.com/questions/1041866/what-is-the-effect-of-extern-c-in-c" target="_blank" rel="noopener">extern &#8220;C&#8221;</a>」，有了它，当编译的时候，就会把 C++ 中的方法名链接成 C 的风格，如此一来，CGO 才能识别它：</p>
<pre>#ifdef __cplusplus
extern "C" {
#endif
void load();
void unload();
void process(const char *inPath, const char *outPath);
#ifdef __cplusplus
}
#endif</pre>
<p>我们可以通过 nm 命令查看某个方法名在使用 extern &#8220;C&#8221; 前后的差异：</p>
<pre>// Before
shell&gt; nm OCR.o | grep process
0000000000000016 T _Z7processPKcS0_
// After
shell&gt; nm OCR.o | grep process
0000000000000016 T process</pre>
<p>最后编写 OCR.go 文件的内容，因为 C/C++ 代码量比较大，所以在使用 CGO 的时候直接把 C/C++ 代码写在注释中就显得不合适了，此时更合适的方法是链接库：</p>
<pre>package main

// #cgo CFLAGS: -I .
// #cgo LDFLAGS: -L . -L /opt/ABBYY/FREngine12/Bin/ -lFREngine -lOCR -lstdc++
// #include &lt;stdlib.h&gt;
// #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)
}</pre>
<p>假设目标文件都已经就绪，那么让我们分别看看如何构建静态链接库和动态链接库：</p>
<p>先看静态链接库，只要通过如下 ar 命令即可，在最终编译程序的时候，静态链接库会被编译到程序里，所以运行时不存在依赖问题，当然代价就是文件尺寸相对较大：</p>
<pre>shell&gt; ar -r libOCR.a *.o</pre>
<p>再看动态链接库，只要通过如下 gcc 命令即可，和静态链接库相比，虽然它运行时存在依赖问题，但是它生成的文件尺寸相对较小，不过需要提醒的是，在之前编译目标文件的时候，需要在 CFLAGS 或 CXXFLAGS 参数中需要加入 -fpic 或者 -fPIC 选项，以便实现地址无关，至于 -fpic 和 -fPIC 的区别，可以参考 <a href="https://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html" target="_blank" rel="noopener">Shared Libraries</a>：</p>
<pre>shell&gt; gcc -shared -o libOCR.so *.o
shell&gt; cp libOCR.so /opt/ABBYY/FREngine12/Bin/</pre>
<p>动态链接库还有一个优点是更新方便，如果多个程序依赖同一个动态链接库的时候，那么当动态链接库有问题的时候，直接更新它即可，相反如果多个程序依赖同一个静态链接库，那么当静态链接库有问题的时候，你不得不重新编译每一个程序。不过动态链接库的依赖关系本身很容易出问题，下图是我的 OCR 程序依赖关系，有点复杂啊：</p>
<div id="attachment_925" style="width: 1610px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/07/ld.png"><img aria-describedby="caption-attachment-925" decoding="async" loading="lazy" class="size-full wp-image-925" src="https://huoding.com/wp-content/uploads/2021/07/ld.png" alt="动态链接" width="1600" height="1020" /></a><p id="caption-attachment-925" class="wp-caption-text">动态链接</p></div>
<p>本文仅是 CGO 的入门笔记，想进一步了解的话，推荐阅读「<a href="https://chai2010.cn/advanced-go-programming-book/ch2-cgo/readme.html" target="_blank" rel="noopener">CGO 编程</a>」，收摊儿。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/07/03/924/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>浅谈pprof</title>
		<link>https://huoding.com/2021/06/06/917</link>
					<comments>https://huoding.com/2021/06/06/917#respond</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Sun, 06 Jun 2021 07:37:56 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[Golang]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=917</guid>

					<description><![CDATA[对于大多数 Gopher 而言，一般平时最主要的工作内容除了实现各种无聊的业务逻 &#8230; <a href="https://huoding.com/2021/06/06/917">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>对于大多数 Gopher 而言，一般平时最主要的工作内容除了实现各种无聊的业务逻辑之外，剩下的就是解决各种琐碎的问题。比如：查询性能瓶颈在哪里？查询内存泄漏在哪里？好在 pprof 是处理此类问题的利器，共有两套标准库，分别适用于不同的场景：</p>
<ul>
<li><a href="https://golang.org/pkg/runtime/pprof/" target="_blank" rel="noopener">runtime/pprof</a>：采集工具型应用运行数据进行分析</li>
<li><a href="https://golang.org/pkg/net/http/pprof/" target="_blank" rel="noopener">net/http/pprof</a>：采集服务型应用运行时数据进行分析</li>
</ul>
<p>命令行工具「go test」就包含了 runtime/pprof，相关参数请参考「go help testflag」：</p>
<pre>shell&gt; go test -cpuprofile cpu.out -memprofile mem.out -bench .</pre>
<p>不过和 runtime/pprof 相比，更常用的是 net/http/pprof，接下来我们主要通过它来解决一些常见问题，想要激活 net/http/pprof 的话很简单，只要导入对应的包并启动服务即可：</p>
<pre>import _ "net/http/pprof"

func main() {
	_ = http.ListenAndServe("localhost:6060", nil)
}</pre>
<p>需要注意的是，千万别让外网访问到 pprof，否则可能会导致出现安全问题。有兴趣的读者可以尝试通过 google 搜索「intitle:/debug/pprof/ inurl:/debug/pprof/」看看反面例子。</p>
<p><span id="more-917"></span></p>
<h2>Profile</h2>
<p>pprof 预置了很多种不同类型的 profile，我们可以按照自己的需要选择：</p>
<ul>
<li>allocs：A sampling of all past memory allocations</li>
<li>block：Stack traces that led to blocking on synchronization primitives</li>
<li>goroutine：Stack traces of all current goroutines</li>
<li>heap：A sampling of memory allocations of live objects</li>
<li>mutex：Stack traces of holders of contended mutexes</li>
<li>profile：CPU profile</li>
<li>threadcreate：Stack traces that led to the creation of new OS threads</li>
</ul>
<p>其中最常用的是 profile 和 heap，分别用来诊断 CPU 和内存问题。</p>
<h2>CPU Profiling</h2>
<p>演示代码模拟了 CPU 密集型任务（onCPU）和耗时的网络请求（offCPU）：</p>
<pre>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) &lt;= cpuTime {
		for i := 0; i &lt; 1000; i++ {
			_ = i
		}
	}
}

func slowNetworkRequest() {
	resp, err := http.Get("http://httpbin.org/delay/1")

	if err != nil {
		log.Fatal(err)
	}

	defer resp.Body.Close()
}
</pre>
<p>通过 go tool pprof 查看 /debug/pprof/profile：</p>
<pre>go tool pprof -http :8080 http://localhost:6060/debug/pprof/profile</pre>
<p>结果发现 profile 只能检测到 onCPU（也就是 cpuIntensiveTask）部分，却不能检测到 offCPU （也就是 slowNetworkRequest）部分：</p>
<div id="attachment_918" style="width: 1730px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/06/profile.png"><img aria-describedby="caption-attachment-918" decoding="async" loading="lazy" class="size-full wp-image-918" src="https://huoding.com/wp-content/uploads/2021/06/profile.png" alt="profile" width="1720" height="1158" /></a><p id="caption-attachment-918" class="wp-caption-text">profile</p></div>
<p>为了检测 offCPU 部分，我们引入 fgprof，通过 go tool pprof 查看 /debug/fgprof：</p>
<pre>go tool pprof -http :8080 http://localhost:6060/debug/fgprof</pre>
<p>结果发现 fgprof 不仅能检测到 onCPU（也就是 cpuIntensiveTask）部分，还能检测到 offCPU （也就是 slowNetworkRequest）部分：</p>
<div id="attachment_919" style="width: 1730px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/06/fgprof.png"><img aria-describedby="caption-attachment-919" decoding="async" loading="lazy" class="size-full wp-image-919" src="https://huoding.com/wp-content/uploads/2021/06/fgprof.png" alt="fgprof" width="1720" height="1160" /></a><p id="caption-attachment-919" class="wp-caption-text">fgprof</p></div>
<p>实际应用中，最好对你的瓶颈是 onCPU 还是 offCPU 有一个大体的认识，进而选择合适的工具，如果不确定就直接用 fgprof，不过需要注意的是 fgprof 对性能的影响较大。</p>
<h2>Memory Profiling</h2>
<p>演示代码模拟了一段有内存泄漏问题的程序：</p>
<pre>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 &lt; 10000000; i++ {
		s = append(s, "leak")

		if (i % 10000) == 0 {
			time.Sleep(1 * time.Second)
		}

		_ = s
	}
}
</pre>
<p>通过 go tool pprof 查看 /debug/pprof/heap（这次不用 web，用命令行）：</p>
<div id="attachment_920" style="width: 1670px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/06/heap.png"><img aria-describedby="caption-attachment-920" decoding="async" loading="lazy" class="size-full wp-image-920" src="https://huoding.com/wp-content/uploads/2021/06/heap.png" alt="heap" width="1660" height="578" /></a><p id="caption-attachment-920" class="wp-caption-text">heap</p></div>
<p>通过 top 命令可以很直观的看出哪里可能出现了内存泄漏问题。不过这里有一个需要说明的问题是内存占用大的地方本身可能是正常的，与内存的绝对值大小相比，我们更应该关注的是不同时间点内存相对变化大小，这里可以使用参数 base 或者 diff_base：</p>
<div id="attachment_921" style="width: 1410px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/06/heap_with_base.png"><img aria-describedby="caption-attachment-921" decoding="async" loading="lazy" class="size-full wp-image-921" src="https://huoding.com/wp-content/uploads/2021/06/heap_with_base.png" alt="heap with base" width="1400" height="580" /></a><p id="caption-attachment-921" class="wp-caption-text">heap with base</p></div>
<p>需要说明的是，内存采样大小依据的是 runtime.MemProfileRate，缺省值是 512KB，也就是说每分配 512KB 内存，采样一次。有时候，我们查询 heap 的结果为空，多半就是因为这个配置太大的缘故，可以通过类似 GODEBUG=&#8221;memprofilerate=1&#8243; 来调整大小。</p>
<p>本文篇幅有限，无法列举更多的例子，有兴趣的读者推荐参考「<a href="https://blog.wolfogre.com/posts/go-ppof-practice/" target="_blank" rel="noopener">golang pprof 实战</a>」。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/06/06/917/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>浅谈NATS消息系统</title>
		<link>https://huoding.com/2021/05/21/907</link>
					<comments>https://huoding.com/2021/05/21/907#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Fri, 21 May 2021 11:07:23 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=907</guid>

					<description><![CDATA[我用过很多消息系统，比如：简单的 Redis Streams；高效的 Kafak &#8230; <a href="https://huoding.com/2021/05/21/907">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>我用过很多消息系统，比如：简单的 <a href="https://redis.io/topics/streams-intro" target="_blank" rel="noopener">Redis Streams</a>；高效的 <a href="https://kafka.apache.org/" target="_blank" rel="noopener">Kafaka</a> 等等，不过自从我把编程语言切换到 Golang 以后，总觉得必须找个用 Golang 开发的消息系统才配得上门当户对，原本我已经和小家碧玉的 <a href="https://nsq.io/" target="_blank" rel="noopener">NSQ</a> 厮守终生，不过当我认识了上流社会 <a href="https://landscape.cncf.io/" target="_blank" rel="noopener">CNCF</a> 钦定的大家闺秀 <a href="https://nats.io/" target="_blank" rel="noopener">NATS</a> 后，刹那间就仿佛徐志摩遇到了林徽因，扭头就给结发妻子写了休书。</p>
<p><span id="more-907"></span></p>
<h2>INSTALLATION</h2>
<p>服务端 <a href="https://github.com/nats-io/nats-server" target="_blank" rel="noopener">nats-server</a>，客户端 <a href="https://github.com/nats-io/natscli" target="_blank" rel="noopener">nats</a>，监控工具 <a href="https://github.com/nats-io/nats-top" target="_blank" rel="noopener">nats-top</a>，性能测试工具 <a href="https://github.com/nats-io/nats.go/tree/master/examples/nats-bench" target="_blank" rel="noopener">nats-bench</a>：</p>
<pre>shell&gt; go get github.com/nats-io/nats-server/v2

shell&gt; git clone https://github.com/nats-io/natscli.git
shell&gt; cd natscli/nats
shell&gt; go get .

shell&gt; go get github.com/nats-io/nats-top

shell&gt; git clone https://github.com/nats-io/nats.go.git
shell&gt; cd nats.go/examples/nats-bench
shell&gt; go get .</pre>
<p>需要说明的是，关于 stream 有新旧两种架构的服务端实现，其中旧的 NATS Streaming Server 架构已经过时，如果你是初学者，直接使用新的 NATS JetStream 架构即可。</p>
<h2>BENCH</h2>
<p>开多个命令行窗口，分别启动 nats-server，nats-top，nats-bench：</p>
<pre>shell&gt; nats-server -js -m 8222
shell&gt; nats-top
shell&gt; nats-bench -n 100000000 -np 10 -ms 1 a</pre>
<div id="attachment_908" style="width: 1510px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/nats-top.png"><img aria-describedby="caption-attachment-908" decoding="async" loading="lazy" class="size-full wp-image-908" src="https://huoding.com/wp-content/uploads/2021/05/nats-top.png" alt="nats-top" width="1500" height="800" /></a><p id="caption-attachment-908" class="wp-caption-text">nats-top</p></div>
<p>如上所示，高达一千万的 MPS，我就问你 OK 不 OK！Beautiful 不 Beautiful！</p>
<h2>MODE</h2>
<h3><a href="https://docs.nats.io/nats-concepts/pubsub" target="_blank" rel="noopener">PUBLISH SUBSCRIBE</a>：</h3>
<p>NATS 实现了一对多发布订阅消息模型。当 publisher 往 subject 上发布一条消息后，此 subject 上所有 subscriber 都能收到 此消息，属于一种广播。</p>
<div id="attachment_911" style="width: 1518px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/publish_subscribe.png"><img aria-describedby="caption-attachment-911" decoding="async" loading="lazy" class="wp-image-911 size-full" src="https://huoding.com/wp-content/uploads/2021/05/publish_subscribe.png" alt="Publish-Subscribe" width="1508" height="728" /></a><p id="caption-attachment-911" class="wp-caption-text">Publish Subscribe</p></div>
<pre>shell&gt; nats sub source.subject
Subscribing on source.subject
[#1] Received on "source.subject"
ZNQz8dCWc5
[#2] Received on "source.subject"
d1EggZJYVT

shell&gt; nats sub source.subject
Subscribing on source.subject
[#1] Received on "source.subject"
ZNQz8dCWc5
[#2] Received on "source.subject"
d1EggZJYVT

shell&gt; nats pub source.subject "{{Random 10 10}}" --count 2
Published 10 bytes to "source.subject"
Published 10 bytes to "source.subject"</pre>
<h3><a href="https://docs.nats.io/nats-concepts/queue" target="_blank" rel="noopener">QUEUE GROUPS</a>：</h3>
<p>如果我们把 subscriber 分组，那么当 publisher 往 subject 上发布一条消息后，同一组里只有一个 subscriber 会收到此消息，从而实现了负载均衡。</p>
<div id="attachment_910" style="width: 1520px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/queue_groups.png"><img aria-describedby="caption-attachment-910" decoding="async" loading="lazy" class="wp-image-910 size-full" src="https://huoding.com/wp-content/uploads/2021/05/queue_groups.png" alt="Queue Groups" width="1510" height="690" /></a><p id="caption-attachment-910" class="wp-caption-text">Queue Groups</p></div>
<pre>shell&gt; nats sub source.subject --queue foo
Subscribing on source.subject
[#1] Received on "source.subject"
LFuJZBjnxV

shell&gt; nats sub source.subject --queue foo
Subscribing on source.subject
[#1] Received on "source.subject"
76kAIoUYCI

shell&gt; nats pub source.subject "{{Random 10 10}}" --count 2
Published 10 bytes to "source.subject"
Published 10 bytes to "source.subject"</pre>
<h3><a href="https://docs.nats.io/nats-concepts/reqreply" target="_blank" rel="noopener">REQUEST REPLY</a>：</h3>
<p>一般来说，消息系统是以异步的形式工作，也就是说，publisher 往 subject 上发布一条消息后，并不在意 subscriber 的 reply 是什么。如果 publisher 在意 subscriber 的 reply 是什么的话，那么消息系统就应该以同步的形式工作，在具体实现中，是通过两次发布订阅来完成的：当 publisher 发布消息后，它会订阅一个特定的 subject，当 subscriber 处理完消息后，它会把 reply 发布到这个特定的 subject。当然，整个过程对使用者是透明的。</p>
<div id="attachment_912" style="width: 1518px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/request_reply.png"><img aria-describedby="caption-attachment-912" decoding="async" loading="lazy" class="wp-image-912 size-full" src="https://huoding.com/wp-content/uploads/2021/05/request_reply.png" alt="Request-Reply" width="1508" height="798" /></a><p id="caption-attachment-912" class="wp-caption-text">Request Reply</p></div>
<pre>shell&gt; nats reply 'weather.&gt;' --command "curl -s wttr.in/{{1}}?format=1"
Listening on "weather.&gt;" in group "NATS-RPLY-22"
[#0] Received on subject "weather.beijing":

shell&gt; nats request weather.beijing ''
Sending request on "weather.beijing"
Received on "_INBOX.7mc3ox00ma7WYWyNjuBSsw.NBtCmYbp"
&#x2600; +30°C</pre>
<p>通过 weather 例子，我们可以发现 request reply 模式已经有了 RPC 的味道。</p>
<h2>MICROSERVICE</h2>
<p>正是因为 NATS 具备了 RPC 的能力，所以在微服务中采用 NATS 后，系统会更清晰。</p>
<div id="attachment_914" style="width: 1190px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/microservice1.png"><img aria-describedby="caption-attachment-914" decoding="async" loading="lazy" class="size-full wp-image-914" src="https://huoding.com/wp-content/uploads/2021/05/microservice1.png" alt="传统微服务架构" width="1180" height="780" /></a><p id="caption-attachment-914" class="wp-caption-text">传统微服务架构</p></div>
<div id="attachment_915" style="width: 1188px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/microservice2.png"><img aria-describedby="caption-attachment-915" decoding="async" loading="lazy" class="size-full wp-image-915" src="https://huoding.com/wp-content/uploads/2021/05/microservice2.png" alt="采用 NATS 的微服务架构" width="1178" height="778" /></a><p id="caption-attachment-915" class="wp-caption-text">采用 NATS 的微服务架构</p></div>
<p>说明：以上图片来自于「<a href="https://www.slideshare.net/nats_io/the-zen-of-high-performance-messaging-with-nats-76985268" target="_blank" rel="noopener">The Zen of High Performance Messaging with NATS</a>」。</p>
<h2>MONITOR</h2>
<p>说到监控，除了前面提到的 nats-top 之外，还有诸如 <a href="https://github.com/devfacet/natsboard" target="_blank" rel="noopener">natsboard</a> 之类的 UI 可供选择：</p>
<div id="attachment_909" style="width: 810px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/natsboard.png"><img aria-describedby="caption-attachment-909" decoding="async" loading="lazy" class="size-full wp-image-909" src="https://huoding.com/wp-content/uploads/2021/05/natsboard.png" alt="natsboard" width="800" height="503" /></a><p id="caption-attachment-909" class="wp-caption-text">natsboard</p></div>
<p>现实中，大家都知道，徐志摩和林徽因的结局，终究还是错付了，不过我对 NATS 的爱不会变，她是我的不二之选，至少在更好的消息系统出现前如此。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/05/21/907/feed</wfw:commentRss>
			<slash:comments>3</slash:comments>
		
		
			</item>
		<item>
		<title>浅谈微服务</title>
		<link>https://huoding.com/2021/05/11/904</link>
					<comments>https://huoding.com/2021/05/11/904#comments</comments>
		
		<dc:creator><![CDATA[老王]]></dc:creator>
		<pubDate>Tue, 11 May 2021 05:46:41 +0000</pubDate>
				<category><![CDATA[Technical]]></category>
		<category><![CDATA[microservices]]></category>
		<guid isPermaLink="false">https://blog.huoding.com/?p=904</guid>

					<description><![CDATA[虽说微服务早已是一个老生常谈的话题了，在 infoq 或者 thoughtwor &#8230; <a href="https://huoding.com/2021/05/11/904">继续阅读 <span class="meta-nav">&#8594;</span></a>]]></description>
										<content:encoded><![CDATA[<p>虽说<a href="https://martinfowler.com/articles/microservices.html" target="_blank" rel="noopener">微服务</a>早已是一个老生常谈的话题了，在 <a href="https://www.infoq.cn/topic/microservice" target="_blank" rel="noopener">infoq</a> 或者 <a href="https://insights.thoughtworks.cn/tag/microservices/" target="_blank" rel="noopener">thoughtworks</a> 上可以找到很多案例，不过可惜的是其中相当比例的案例是失败的案例，究其原因，除了<a href="https://microservices.io/index.html" target="_blank" rel="noopener">技术门槛</a>之外，主要是因为很多人脱离了实际情况，只是为了微服务而微服务。本文通过一个例子带领大家从头到尾体验一下微服务的演化过程，不仅要做到知其然，更要做到知其所以然。</p>
<p><span id="more-904"></span></p>
<p>假设我们正在开发一个在线购物项目，其主要功能包括商城、推荐、评论、用户等，它是一个典型的<a href="https://microservices.io/patterns/monolithic.html" target="_blank" rel="noopener">单体架构</a>：不同团队的技术人员工作在同一个版本库上，系统功能按模块划分，不同模块之间通过本地函数调用，通常操作同一个数据库。</p>
<div id="attachment_895" style="width: 581px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/ms01.png"><img aria-describedby="caption-attachment-895" decoding="async" loading="lazy" class="size-full wp-image-895" src="https://huoding.com/wp-content/uploads/2021/05/ms01.png" alt="微服务" width="571" height="291" /></a><p id="caption-attachment-895" class="wp-caption-text">微服务</p></div>
<p>在项目早期，单体架构往往能很好的适应快速迭代的需求，不过随着项目的发展，项目本身会变得复杂，其弊端不可避免的出现，比如下面列举的一些情况：</p>
<ul>
<li>因为大家都工作在同一个版本库上，所以可能会遇到：商城模块完成了新功能，准备上线，结果推荐模块刚提交了还没来得及测试的代码，于是不得不推迟上线。</li>
<li>不同的需求采用不同的技术栈：负责评论模块的同事想用 PHP + MySQL 来构建系统，负责用户模块的同事却想用 Golang + PostgreSQL 来构建系统。</li>
<li>有的模块需要高性能 CPU，有的模块需要大内存，因为不同的模块是耦合在一起的，所以我们的服务器不得不同时具备高性能 CPU，大内存，从而增加了成本。</li>
</ul>
<p>如何解决此类问题？<a href="https://zh.wikipedia.org/wiki/%E5%BA%B7%E5%A8%81%E5%AE%9A%E5%BE%8B" target="_blank" rel="noopener">康威定律</a>给出了很好的建议：「设计系统的架构受制于产生这些设计的组织的沟通结构」，通俗点说就是：「有什么样的组织架构就会设计出什么样的系统架构」。在本例中，因为不同的团队负责不同的模块，所以很自然的可以通过模块来把系统切分成商城、推荐、评论、用户等几个独立的服务：每个服务有自己独立的版本库和数据库，服务之间通过 RPC 来通信。不同的服务拥有自己的版本库，可以使用适合自己的技术栈和硬件，独立开发独立部署。</p>
<p>一个需要注意的问题是如何确定服务粒度的大小，虽然按照康威定律的描述只要按照组织架构的大小来确定服务的大小即可，但是如何规划一个合理的团队规模呢？实际上并没有一个精确的答案，我们需要按照客观情况来确定一个适合自己的大小适中的服务粒度，过小的粒度会导致服务之间强耦合，过大的粒度则背离了微服务的初衷，Uber 甚至还针对服务粒度大小问题发明了一个<a href="https://mp.weixin.qq.com/s/1P_5mMeZQ8YQzybLmjENLg" target="_blank" rel="noopener">宏服务</a>的概念，有兴趣的读者不妨看看。</p>
<div id="attachment_896" style="width: 491px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/ms02.png"><img aria-describedby="caption-attachment-896" decoding="async" loading="lazy" class="size-full wp-image-896" src="https://huoding.com/wp-content/uploads/2021/05/ms02.png" alt="微服务" width="481" height="261" /></a><p id="caption-attachment-896" class="wp-caption-text">微服务</p></div>
<p>当我们把单体架构切分成独立的服务之后，原本模块间本地的函数调用变成了服务间远程的 RPC 调用，我们不得不处理服务治理之类的问题，随着微服务数量的增加，问题会变得越来越棘手，好在随着云原生的发展，特别是 <a href="https://kubernetes.io/" target="_blank" rel="noopener">K8S</a> 和 <a href="https://istio.io/" target="_blank" rel="noopener">istio</a> 等技术的成熟，我们的架构可以演化到 <a href="https://www.servicemesher.com/" target="_blank" rel="noopener">service mesh</a> 阶段，通过 sidecar 透明实现服务治理。</p>
<div id="attachment_897" style="width: 431px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/ms03.png"><img aria-describedby="caption-attachment-897" decoding="async" loading="lazy" class="size-full wp-image-897" src="https://huoding.com/wp-content/uploads/2021/05/ms03.png" alt="微服务" width="421" height="361" /></a><p id="caption-attachment-897" class="wp-caption-text">微服务</p></div>
<p>如果仅仅是把原本模块间本地的函数调用变成了服务间远程的 RPC 调用的话，那么我们的微服务很可能会沦为「<a href="https://skyao.io/talk/202007-microservice-avoiding-distributed-monoliths/" target="_blank" rel="noopener">分布式单体</a>」。问题的症结在于过度使用 RPC，导致服务与服务之间强耦合，解决方法是引入 Event，通过 Event 实现服务与服务的解耦。</p>
<p>看看如何实现下面的业务逻辑：当一个用户注册后，要在商城里给用户一张优惠券。</p>
<ul>
<li>使用 RPC（强调做什么）：当用户模块创建了一个新用户的时候，通过 RPC 调用商城模块给用户一张优惠券，过程中用户模块和商城模块是强耦合的。</li>
<li>使用 Event（强调发生了什么）：当用户模块创建了一个新用户的时候，它发出一个 UserCreated 事件，商城模块观察到对应的事件后，给用户一张优惠券，过程中用户模块和商城模块是弱耦合的。</li>
</ul>
<p>实际情况中应该按需求来选择使用 RPC 或者 Event：如果是业务逻辑的实现部分，倾向于选择使用 RPC；如果是业务逻辑完成之后的后续通知部分，强烈建议选择使用 Event。</p>
<div id="attachment_898" style="width: 431px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/ms04.png"><img aria-describedby="caption-attachment-898" decoding="async" loading="lazy" class="size-full wp-image-898" src="https://huoding.com/wp-content/uploads/2021/05/ms04.png" alt="微服务" width="421" height="361" /></a><p id="caption-attachment-898" class="wp-caption-text">微服务</p></div>
<p>服务部署好了之后，接下来我们还需要考虑如何暴露服务以供前端调用，比如用户浏览某个商品的详情页，内容包括商品数据、以及对应的推荐数据和评论数据，如果直接操作服务的话，那么需要多次查询商品服务、推荐服务、评论服务，并不可取，此时可以加入 <a href="https://microservices.io/patterns/apigateway.html" target="_blank" rel="noopener">API Gateway</a> 充当代理，前端只要请求 API Gateway 一次就可以拿到数据。</p>
<div id="attachment_899" style="width: 493px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/ms05.png"><img aria-describedby="caption-attachment-899" decoding="async" loading="lazy" class="size-full wp-image-899" src="https://huoding.com/wp-content/uploads/2021/05/ms05.png" alt="微服务" width="483" height="564" /></a><p id="caption-attachment-899" class="wp-caption-text">微服务</p></div>
<p>有了 API Gateway 之后，它可以帮我们完成聚合之类的逻辑。不过有一个问题是前端可能有多种不同的类型，比如 PC 前端，Mobile 前端，它们的业务逻辑不可避免的会有各种各样的差异，如果在 API Gateway 中处理这些差异的话，那么会出现坏味道，为了解决此类问题，我们引入 <a href="https://microservices.io/patterns/apigateway.html" target="_blank" rel="noopener">BFF</a>（Backend For Frontend），每一种前端都有属于自己的 BFF，用来处理专属于自己的业务逻辑，至于 API Gateway，则只处理鉴权，日志等公共业务逻辑。</p>
<div id="attachment_900" style="width: 493px" class="wp-caption alignnone"><a href="https://huoding.com/wp-content/uploads/2021/05/ms06.png"><img aria-describedby="caption-attachment-900" decoding="async" loading="lazy" class="size-full wp-image-900" src="https://huoding.com/wp-content/uploads/2021/05/ms06.png" alt="微服务" width="483" height="681" /></a><p id="caption-attachment-900" class="wp-caption-text">微服务</p></div>
<p>微服务是个极其复杂的概念，本文仅就一些表面问题浅谈一二，其他诸如 <a href="https://microservices.io/patterns/data/saga.html" target="_blank" rel="noopener">SAGA</a> 之类的复杂问题，由于篇幅所限，并未涉猎，大家如果有兴趣的话请自行查阅。</p>
<p>最后把 <a href="https://martinfowler.com/" target="_blank" rel="noopener">Martin Fowler</a> 在 <a href="https://www.martinfowler.com/books/eaa.html" target="_blank" rel="noopener">PoEAA</a> 中提出的<a href="https://martinfowler.com/bliki/FirstLaw.html" target="_blank" rel="noopener">分布式对象第一定律</a>送给大家：不要分布你的对象！套用这个说法的话，不难引申出微服务第一定律：不要使用微服务！虽然话里有一些戏虐的成份，但是它至少告诫我们在面对微服务的时候要怀揣着一颗敬畏的心。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://huoding.com/2021/05/11/904/feed</wfw:commentRss>
			<slash:comments>5</slash:comments>
		
		
			</item>
	</channel>
</rss>
