hello云胜

技术与生活

0%

为什么java比go要消耗更多的内存

 Java比Go语言消耗更多的内存基本成为了一个共识,这是一个实践自然发现的事实。但是要说明其原因,却需要非常多细致的工作。,很多人归结于Java框架臃肿,比如spring boot的依赖就是非常多的,但是框架的臃肿是Java内存消耗巨大的核心因素吗?如果只是框架的问题,那么我们去实现或者等待别人实现一个更优质的框架即可。

  我怀着探究的态度去看来language benchmark网站上对语言的通用内存消耗对比,我简单整理了一个10种不同场景下Go和Java的内存消耗对比如下:

img

Java和Go语言在language benchmark的内存消耗对比

  我们发现除了reverse-complement场景,其他9个场景下都是Go语言大幅度领先于Java语言。那么我们基本得到一个实践事实:在一般场景和现代后端微服务场景下,Java比Go语言消耗更多的内存。

  有了事实,我们更想探究这个事实背后的因素。主要是要厘清为什么会发生这样的情况,这种情况究竟是短暂的,还是本质无法改善的?接下来,我接下来就一一拆解这背后因素。

(一)JIT和AOT的架构差异
  一个很显然的观点是认为Java运行态中包含了一个完整的解释器、一个JIT编译期以及一个垃圾回收器,这会显著地增加内存。Go语言直接编译到机器码,运行态只包含机器码和一个垃圾回收器。显然Go的运行态更小。

img

  这是一个正确的观点,但是却不是Java程序内存消耗显著大于Go程序的主要因素。事实上Java经过多年的优化和调整,解释器和JIT消耗的内存显著小于很多人的预期。我在我的笔记本上尝试了一个最小的Java可运行程序,就是一个for循环无限等待,其内存消耗如下:

img

  在不同的平台和机器上,最小的Java内存消耗可能变化,但是总体这部分的大小并不足以成为Java程序内存消耗几倍于Go程序的主导因素。

(二)面向对象的内存布局和面向值的内存布局

  Java是一种面向对象的语言,万物皆对象,这个重要的抽象原则是要付出一定的代价的。最重要的代价就是每个对象需要一个对象头,简单的示意图如下:

img

  也就是说每个Java的对象都要包含一个96 bits的对象头,比如一个32bits的integer占用的内存是多少呢?答案是96bits+32bits=128bits。

  那么Go语言每个对象消耗多少呢?首先,Go没有对象,Go不是一个面向对象的语言,Go是面向值的语言。这里简化掉Go语言中基于span的内存管理机制细节讨论,我们给出直观的结论是每一个存储到span中的值至少需要消耗2bits用于内存分配和垃圾回收管理:

img

  显然,Go在选择不支持面向对象时,选择了一种更加简单的内存布局方式,这种内存布局方式减省了内存消耗。

(三)垃圾回收机制导致的内存利用率不同

  Java的垃圾回收机制有着悠久的历史,Java的垃圾回收机制某种意义上就是现在垃圾回收机制的发展史,我们简单看看Java的垃圾回收机制历程,大致如下:

img

  其中我们真正值得关注的应该是ParallelGC,G1和ZGC这3种,ParallelGC是广泛使用的JDK8的默认垃圾回收器,G1是JDK9以后的默认垃圾回收器,ZGC是未来的默认垃圾回收器。

  我们先简单看看ParallelGC的机制,这里不打算讨论每个细节,但是需要对这套垃圾回收机制有个大概的认知,方便我们后续的讨论,这套机制下把堆内存按照如下图所示区域划分:

img

  主要来看是划分出了新生代和老年代,其运行机制如下图所示:

img

  在这套管理机制下,我们知道新生代的To区域一定要是空置的,按照正常默认的JDK配置参数,1/3的堆内存是新生代,新生代中1/8的区域是To区域,综合而言也就是说至少有1/24的内存区域是需要一直空置浪费的。

  我们再看看新的默认垃圾回收器G1,G1显著转变了区域的划分,转而使用一种基于region的管理策略,简单而言就是把内存等分成不同region,每个region在不同时刻扮演eden、survivor、old、humongous这4种不同的角色。这种划分下,理论上只要任意时刻有一个region是空置的就能够进行垃圾回收,这个比例极大概览小于1/24,所以G1的内存利用率更高。

img

  对于Java,我们最后看看下一代垃圾回收器ZGC,ZGC目前没有基于分代实现,而是显式地把内存划分成不同大小的块,不同大小的对象从不同块上去分配内存,如果我们对比后续介绍的Go的内存管理方式,你会发现2者有着一定的共通之处。当然需要指出的是,这种不同大小对象从不同地方分配的策略最早起源自C语言的各种内存分配器,只是慢慢 开始被垃圾回收器吸取精华思想。ZGC下的内存布局如下图所示:

img

  综合而言,Java在发展到G1和ZGC之后,内存的利用率已经显著增加了很多,就空间利用率而言甚至某些情况下甚至是高于Go语言的,所以垃圾回收器不是Java的短板。

  我们再来看看Go语言的垃圾回收机制,这里也不会讨论Go垃圾回收的所有细节,仅仅针对内存布局来聊聊,首先我们要明确Go语言的内存管理是基于span的,简单而言一个span就是一组大小固定的空槽,每有一个size小于等于这个空槽的值申请内存,就分配一个空槽给这个值。

img

  我小心地避免了使用对象这个词,因为Go语言不是面向对象的,Go是面向值的语言。这种基于span和空槽(slot)的管理方式,显然会浪费内存,但是能够显著简化垃圾回收器的设计,同时也能够简化辅助内存的使用,事实上我们提到过,Go只需要使用2bits就能够跟踪和管理每个值在垃圾回收器中的状态。

  综合而言,在垃圾回收领域,Java目前在某种程度上还是领先于Go的,至少在垃圾回收器的内存利用率上,Java目前是优于Go的,Go的span管理方式简化了内存管理,增加了内存浪费率。

(四)栈的利用效率

  这一点可能是很少有人提及的一点,但是我却认为非常重要的一个因素,就是Go语言倾向于优先把可能的值分配在栈上,这样随着函数的调用和返回,许多临时的小对象就会被释放,Java虽然也有逃逸分析,但是做得更加保守,远远没有达到栈优先的地步。这和Go是面向值的策略非常相关,面向对象的语言做逃逸分析要相对复杂一些,一些不敢保证正确性的场景下就会选择保守策略。

(五)并发模型的区别

  Java的并发模型是基于线程的,每个线程默认需要消耗1MB左右的内存。Go的并发模型是基于coroutine协程的,每个协程默认消耗2KB(不足时可扩展)的内存。在后端微服务场景下,通常需要创建大量的线程/协程来处理高并发。比如我们设定系统处理能力为100个并发(并发和并行需要区分看待),那么Java程序需要100个线程,消耗100MB左右,而相同并发情况下Go程序需要100个协程,消耗200KB左右。

(六)反射机制和框架实现策略

  最后一个不得不提的点就是框架的实现策略,其中最为重要的就是反射和hashmap的使用,Java的框架实现中大量使用反射,并使用hashmap缓存信息,这2个都是极度消耗内存的行为。要想佐证这个观点,只需要启动一个最流行的spring boot应用,写一个hello world接口,然后通过对象分析,看看hashmap对象有多少个,以及reflect相关对象的数量就可以映证了。

  反射是某种意义上的元编程,几乎是框架实现的必经之路,Go的框架就不使用反射来编写框架吗?就不使用map缓存相关信息吗?答案是Go的框架中也使用reflect,当然也使用map。但是Go又占了面向interface和值的便宜,Go的反射模型要比Java的反射模型简单非常多,反射过程要产生的对象数量也少非常多。

  全篇总结而言,在内存利用效率上,Go语言确实比Java做得更好,在6个不同的角度来总结:

(1)Java的JIT策略比Go的AOT策略,在运行时上多占用了一些内存

(2)Java的面向对象抽象策略比Go的面向值的抽象策略在每个对象/值上多消耗了内存

(3)内存分配和垃圾回收器上,Java目前在内存利用率上领先

(4)在栈的利用方面,Go语言做得比Java更加激进

(5)并发模型上,协程模型比线程模型更加节省内存。

(5)Go的反射更加简单,导致框架的内存消耗上Go程序比Java程序优秀。

  协程这种用户态的调度模型在并发上的优越性已经被各种语言证明,当然更多语言选择的是async/await语法来支持协程。Java自己也意识到了这个问题,开启了loom project来添加相关支持,只是这个项目的进度远远慢于人们的预期。相信将来的某一天Java也能够用上协程,但是短期内在这个领域还是Go更为领先。

  多说几句题外话,我们发现Go在内存消耗上的领先,一大部分源自其砍掉了面向对象。原来的软件思潮里面,面向对象几乎就是正统的皇皇大道,但是后来我们慢慢发现继承也许不是一个好的抽象,很多时候是组合优于继承。如果把继承砍掉,那么面向对象的概念几乎也就不再那么必要了。

  如果面向对象没有带来本质上的概念优越性,又带来了许多不必要的负担和麻烦,那么可能面向对象就不是编程的必须思想。例如现在在内存利用率上看到的情况,基本都是由于Go的值模型更加简单才占到了便宜。

  我们发现在Go中利用struct和interface也能很好地抽象客观世界。另外一个编程语言界新星Rust也抛弃了面向对象的概念,在Rust中利用struct和trait抽象客观世界,也没有发现明显的短板和无法逾越的逻辑难题。Go之于Java,Rust之于C++,新生力量都不约而同的选择了砍掉面向对象,也许有时候适当做减法也是一种更大的智慧。

  那么面向对象所声称的“对象 = 数据 + 方法”是否是正确的呢?我们是否只需要孤立的值和方法?就像C语言那样,只要值和方法就足够了?我个人的观点是,数据和方法的紧密结合仍然是必要的,面向对象中多余的概念可能就是继承,没有继承的面向对象可能是一个更加优秀的选择。就像Rust中一样,struct上依然可以有方法,但是没有继承的概念。也许将来的某一天,我们像本文一样审视C++和Rust,我们说不定会发现Rust的大部分优势都是源自于砍掉了复杂的面向对象继承机制。

JVM的各种GC其实都比Go更消耗内存, 因为支持更多的特性和更灵活多样的GC策略, 比如分代,对象可移动,各种参数调节等等. 而Go只做了一种GC方案,不分代,不可移动,没什么参数能调节,而且更注重暂停时间的优化,执行GC的时机更频繁, 所以Go通常更占更少的内存,但代价就是GC性能比JVM差了不少.

go确实只有标记清理, go对小内存有内存池设计, 碎片化问题还好. 但大块内存的频繁分配释放仍有可能陷入碎片问题, 但C/C++/Rust也都不可避免, 能移动并整理内存的Runtime/VM很少见.

18以后java的gc内存占用会降到一个极限值
再往后就只能靠aot了
这片文章对比的是上古的java,感觉对java新的技术并不熟悉,一堆问题
比如他说反射,这个18马上要改反射实现,也是大幅提升性能的

map类型

声明

1
var myMap map[string]int // 一个map[string]int类型的变量

这个声明的myMap没有进行初始化,所以他的值是nil。对nil的map直接进行操作是报错的。

1
2
var myMap map[string]int
myMap["a"] = 1 // panic: assignment to entry in nil map

类似于java的npe

但是,上一章讲切片时,有这样的代码

1
2
var s []int
s= append(s, 100)

切片定义之后没有初始化,值也是nil。但是可以append。这种在 Go 语言中被称为“零值可用”。算是go对切片类型的一个语法糖。

但是map类型没有这种“零值可用”。

初始化

1
m := map[int]string{}

这样虽然该map没有任何键值对数据,但是m已经不是nil了。可以插值了。

1
2
3
4
m := map[int]string{
1: "aaa",
2: "bbb"
}

使用make函数

1
2
3
// 使用make函数可以指定初始化的map容量
m2 := make(map[string]string, 10)
t.Log(m2, len(m2))

map 类型的容量不会受限于它的初始容量值,当其中的键值对数量超过初始容量 后,Go 运行时会自动增加 map 类型的容量,保证后续键值对的正常插入。

map的基本操作

1
2
3
4
5
6
7
8
9
10
11
12
m := map[int]string{}
// 插入
m[1] = "hello"
//获取
t.Log(m[1])
//长度
t.Log(len(m))
// 不能用cap查容量,和切片不一样
//t.Log(cap(m))
// 删除
delete(m, 1)

判断一个元素是否存在,举个例子

1
2
3
4
5
6
func TestExist(t *testing.T)  {
m2 := map[string]int{}
m2["aaa"] = 0
t.Log(m2["aaa"])
t.Log(m2["bbb"])
}

打印的结果都是0

也就是说对于不存在的key,返回的value是其类型的零值。所以说通过查询key的value是不是0,是无法判断key是否存在的。

这种情况下go有其标准写法

1
2
3
4
5
6
7
8
9
10
11
func TestExist(t *testing.T)  {
m2 := map[string]int{}
m2["aaa"] = 0
v, ok := m2["bbb"]
if ok {
// 存在
t.Log(v)
} else {
t.Log("不存在")
}
}

遍历的坑

1
2
3
4
5
6
7
8
9
10
func TestMapFor(t *testing.T) {
m1 := map[int]string{1: "a", 2: "b"}
m1[3] = "c"
for i := 0; i < 5; i++ {
for k, v := range m1 {
fmt.Printf("【%d, %s】", k, v)
}
fmt.Println()
}
}

打印的结果

1
2
3
4
5
6
=== RUN   TestMapFor
【1, a】【2, b】【3, c】
【3, c】【1, a】【2, b】
【2, b】【3, c】【1, a】
【1, a】【2, b】【3, c】
【2, b】【3, c】【1, a】

也就是对map进行循环,每次的顺序都不一样。

程序逻辑千万不要依赖遍历 map 所得到的的元素顺序。

引用传递

map在函数调用时,传递的是引用。也就是函数内部的改变,会改变原始的数据。

底层实现

map的底层实例对应的是runtime.hmap实例。hmap也称为map类型的头部结构header,是一个map的描述符,保存了一个map类型的所有信息。

  • count,map的长度,len()返回的就是这个值

  • flags,当前map做出的状态标识。

  • B,B的值是bucket数量的以2为底的对数,也就是说2^B=bucket数量

  • noverflow,overflow的bucket的大约数量

  • hash0,哈希函数的种子值

  • buckets,指向bucket数组的指针

  • oldbuckets,在map扩容阶段指向旧的bucket数组的指针

  • nevacuate,扩容进度

map用来存储数据的是bucket数组。每个bucket默认的容量是8。如果8个slot全满了,又没有达到扩容的条件,就暂时创建overflow的bucket存储数据。

可以看到一个bucket分成三部分。tophash,key,value。

当像map中插入一个数据,或者获取数据时,对key进行哈希计算。对得到的hashcode一分为二。地位区用来选bucket。高位去用来确认在bucket中的位置。tophash存的就是这个位置。用来快速定位key,不需要对bucket中的key进行逐个比较。

接着下面就是key和value的存储区域,go将key和value分开存储,不是一个key一个value的方式存储。这样做的好处是减少了因为内存对齐带来的内存浪费。坏处是算法上更复杂。

还有一点要强调一下,如果 key 或 value 的数据长度大于一定数值,那么运 行时不会在 bucket 中直接存储数据,而是会存储 key 或 value 数据的指针。

扩容

map 在什么情况下会进行扩容呢?Go 运行时的 map 实现中引入了一个 LoadFactor(负载因子),

当 count > LoadFactor * bucket的数量 或 overflow bucket 过多 时,运行时会自动对 map 进行扩容。

目前 Go 最新 1.17 版本 LoadFactor 设置为 6.5。

这两种原因导致的扩容,在运行时的操作其实是不一样的。

如果是因为 overflow bucket 过多导致的“扩容”,实际上运行时会新建一个和现有规模一样的 bucket 数组, 然后在map的插入和删除时时做排空和迁移。

如果是因为当前数据数量超出 LoadFactor 指定水位而进行的扩容,那么运行时会建立一 个两倍于现有规模的 bucket 数组。数据的迁移并不会立即执行,而是等到map进行插入删除操作时,才会执行数据的迁移工作。

原 bucket 数组会挂在 hmap 的 oldbuckets 指针下面,直到原 buckets 数 组中所有数据都迁移到新数组后,原 buckets 数组才会被释放。

另外,因为动态迁移,value的地址会发生变化,所以go不需要获取value的地址

1
&m[key]  //不需要

并发安全

map 实例不是并发写安全的,也不支持并发读写

不过,如果我们仅仅是进行并发读,map 是没有问题的。而且,Go 1.9 版本中引入了支持 并发写安全的 sync.Map 类型,可以用来在并发读写的场景下替换掉 map

2007年,三位大佬,是图灵奖获得者、C 语法联合发明人、Unix 之父肯·汤普森 (Ken Thompson)、Plan 9 操作系统领导者、UTF-8 编码的最初设计者罗伯·派克(Rob Pike),以及 Java 的 HotSpot 虚拟机和 Chrome 浏览器的 JavaScript V8 引擎的设计者 之一罗伯特·格瑞史莫(Robert Griesemer)。在一次偶然的讨论中,决定开发一门新的语言。因为当时在google内部使用C++构建各种系统,但是C++的巨大复杂性,以即编译构建速度慢和并发支持不足的问题令三位大佬十分不爽。

2009 年 11 月 10 日,谷歌官方宣布 Go 语言项目开源,这一天也被 Go 官方确 定为 Go 语言的诞生日

go_history

go目前已经成为云原生领域的基础语言,现代云计算基础设施软件的大部分流行和可靠的软件,都是用 Go 编写的,比如:Docker、Kubernetes、Prometheus、 Ethereum(以太坊)、Istio、CockroachDB、InfluxDB、Terraform、Etcd、Consul 等 等。

go的设计哲学

一门语言的设计哲学相当于一个人的价值观。

简单、显式、组合、并发和面向工程

简单

知名 Go 开发者戴维·切尼(Dave Cheney)曾说过:“大多数编程语言创建伊始都致力于 成为一门简单的语言,但最终都只是满足于做一个强大的编程语言”。

java就是这样,对其他语言的特性进行吸收。比如java8的lambda

go仅有25个关键字,是主流语言最少的

内置垃圾回收,降低程序员进行内存管理的心智负担

内置并发编程支持

首字母大小写决定可见性、变量初始为类型零值

显式

go不支持隐式的类型转换。需要手动进行显式的类型转换

隐含的设计哲学是:程序员应该明确知道自己在做什么。

组合

go中没有继承。go不是面向对象的语言。

通过组合,将已经实现的功能嵌入到新类型中,以快速开发功能强大的新类型。

这种机制在某种层面上类似与继承的机制,但是在原理上截然不同。被嵌入的类型和新类型之间完全没有关系,完全没有父类子类的继承关系,当然也不存在向上或向下的类型转换。

通过多个小接口,组合成一个大接口。

并发

go中多线程编程写起来非常舒服。

“并发”这个设计哲学的出现有它的背景,CPU 都是靠提高主频来改进性能的, 但是现在这个做法已经遇到了瓶颈。主频提高导致 CPU 的功耗和发热量剧增,反过来制约 了 CPU 性能的进一步提高。2007 年开始,处理器厂商的竞争焦点从主频转向了多核

在这种大背景下,Go 的设计者在决定去创建一门新语言的时候,果断将面向多核、原生支 持并发作为了新语言的设计原则之一。并且,Go 放弃了传统的基于操作系统线程的并发模 型,而采用了用户层轻量级线程,Go 将之称为 goroutine。

goroutine 占用的资源非常小,Go 运行时默认为每个 goroutine 分配的栈空间仅 2KB。 goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个 Go 程序中可以创建成千上万个并发的 goroutine。而且,所有的 Go 代码都在 goroutine 中执行,哪怕是 go 运行时的代码也不例外。

java的线程是内核态的,线程的切换需要进行用户态和内核态的切换?

面向工程

go语言被开发出来的目的是为了解决当时在google内部遇到的大规模软件开发流程中遇到的问题,比如大型项目代码构建慢,依赖管理混乱,代码复杂难以理解等。

所以go天生就是为了解决工程化项目而生的。

这一点完全区别与很多偏学院派或者说研究型语言。

重构方便:别名

构建快读:重新设计编译单元和目标文件格式,实现 Go 源码快速构建,让大工程的构建时间缩短 到类似动态语言的交互式解释的编译速度;

标准库丰富

工具链完备

Go项目的典型结构布局

可执行程序项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$tree -F exe-layout
exe-layout
├── cmd/
│ ├── app1/
│ │ └── main.go
│ └── app2/
│ └── main.go
├── go.mod
├── go.sum
├── internal/
│ ├── pkga/
│ │ └── pkg_a.go
│ └── pkgb/
│ └── pkg_b.go
├── pkg1/
│ └── pkg1.go
├── pkg2/
│ └── pkg2.go
└── vendor

cmd 目录就是存放项目要编译构建的可执行 文件对应的 main 包的源文件。如果有多个,再分包。

internal目录下是程序内部可以引用的

pka目录是存放外部项目可以引用的包

vendor目录 用于在项目本地缓存特定 版本依赖包

库项目

仅对外暴露go包,Go 库项目的初衷是为了对外部(开源或组织内部公开)暴露 API

1
2
3
4
5
6
7
8
9
10
11
12
13
$tree -F lib-layout
lib-layout
├── go.mod
├── internal/
│ ├── pkga/
│ │ └── pkg_a.go
│ └── pkgb/
│ └── pkg_b.go
|── pkg1/
│ └── pkg1.go
└── pkg2/
└── pkg2.go

介绍

首先从go语言本身的介绍开始。
有很多可以讲的,但是我们必须从某个地方开始说,带我们一步步学习go语言
同时会讲到几个重要的库

首先,我们要知道的go是google内部一个小团队的创造。这个小团队的三个创始人是Robert griesmer(hotsopt虚拟机设计者,javascript v8引擎设计者),Ken Thompson(c语言之父,unix之父),Rob
Pike(plan9操作系统领导者)。
这几个人都是当今软件工程领域响当当的大牛。,Ken Thompson设计和实现了unix操作系统,并是Unicode设计的关键人物。
所以当这几个人坐到一起,并决定开发一门新的语言时。
第一个问题时,为什么需要一个新的语言?
要理解这个问题,首先要了解当时谷歌内部广泛使用的语言,最主要的语言是三个,第一个是Python,然后是Java,最后是C和C++。这几种语言都很棒,
但是考虑到这几种语言的设计限制和历史遗留问题,go的设计者们认为google目前需要一门新的语言。
比如,Pyhton,python很容易使用。但它是一门解释型语言。
如果使用python编写非常大型的应用,那么在google的体量下,会遇到很多挑战。
java很快,但是随着时间推移,他的类型系统越来越复杂。(这也是很多语言的问题)。他们刚推出时很简单,然后不断增加新的特性,使语言本身越来越复杂。
c和c++也很快,但是他们也有复杂的类型系统,而且编译很慢。最近类型系统的问题越来越被社区关注,但是必须承担历史遗留代码的负担。java也同样面临这个问题。这些语言必须兼容以前的代码,很多十年前编写的代码,现在还需要编译。
编译慢的问题,也是一个历史遗留问题。当初在c和c++设计之时,计算机的内存很小,和现在不可同日而语。所以c和c++的编译被设计为使用尽量小的内存,编译速度可以慢一点。
以上三种语言设计的时代,多线程应用非常少。绝大多数应用都是单线程运行。所以对并发的支持只是一种锦上添花。
所以google目前的高并发环境下,继续使用这三种语言遇到了越来越多的问题。

所以,说回go。go带来了什么呢?

  1. go是强类型的静态类型语言,这和java,c++是一样的。那强类型是什么意思?强类型就是变量不能改变其类型。当你定义int类型的变量a,那么a总是只能持有一个整数。你不能给a赋值一个Boolean,或者赋值一个string。
    静态类型表示所有的变量必须在编译期就被定义。
    go提供了特性允许你绕过这个强类型系统。但是99%的情况下,你都应该处在go的强类型-静态类型系统下,你会从中收益的。如果你是一个java开发者,你可能会有点担心,因为强静态类型语言通常语法上会有点冗长。等稍后学习到go语法,你会发现,go语言做了很多努力让编译器来自动分析当你定义变量时你究竟是要做什么。比如你不需要每次都写变量的类型,等等类似的事。
    2.go拥有一个出色的社区。go是一门出色的语言,但是并不能保证一定会成功。现在市面上有很多语言,让一个新的开发者完全扑到一门语言上是很困难的。因此,围绕go语言建立了坚固的社区,不断推动go语言向前发展,让新的开发者更容易的入门go。
    3.关键特性
  • 第一,也是最重要的就是简单。简单为什么会成为一种特性?随着你慢慢学习和熟悉go语言,你可能发现go没有其他语言的某种特性,你会问自己,为什么go没有这个特性,为什么我们不需要这个特性?这些问题的答案都是要保持简单。go认为简单性是非常重要的。这就导致了必须舍弃一些看起来很有用的特性,如果增加这些特性会显著的增加复杂性,那我们就不要增加这个特性
  • 第二,go极其关注编译效率。在现代开发环境下,我们要求快速开发,快速构建,快速测试,使开发者能够迅速得到反馈。如果你花了45分钟在编译上,那么这个环就被打破了。所以go针对性的提高编译效率,降低编译时长。尽管让我们放弃全量编译。
  • 垃圾收集。go是有垃圾收集的,意味着你不需要自己去管理内存。你也可以自己去管理内存。但大体上来说,还是go运行时在管理内存。这也是简单性的一个体现。有一个说法是,带垃圾收集的语言在处理某些场景时会有问题。比如股票市场的实时交易系统,当出现垃圾收集时会有问题,但是,对于开发者而言,不需要管理内存带来的优势显然更大。当然这不是说垃圾回收带来的延时问题不被重视了,如果你仔细看go的版本历史,你会发现go花了很多力气去降低垃圾回收期间暂停的时间,以至于现在你基本感觉不到垃圾回收暂停的时间。
  • 原生的并发支持。为了体现对并发编程的关注,go直接将并发原语内置到语言中。我们在以后的视频会学习这部分知识。我们不需要引入其他任何包,只是用语言自身就可以进行并发编程。
  • go编译成一个单独的库。这意味着,当你编译你的go应用,所有和这个应用相关的依赖都会被打进这个包里。go的运行时会打进来,你依赖的库,会编译后会打进来,所以你不需要担心会去外部环境找外部依赖库或者dll文件。这样一来,应用的版本管理变得简单。因为你只有一个二进制文件,你分发这个文件,运行这个文件。所有你需要的依赖全部在这里。注意,我这里说的依赖指的是go的依赖。如果你构建的是一个web应用。有html资源,css资源,这些资源也必须打进这个二进制文件里。

Qmgo

Qmgo 是一款Go语言的MongoDB driver,它基于MongoDB 官方 driver 开发实现,同时使用更易用的接口设计,比如参考mgo (比如mgo的链式调用)。

  • Qmgo让您以更优雅的姿势使用MongoDB的新特性。

  • Qmgo是从mgo迁移到新MongoDB driver的第一选择,对代码的改动影响最小。

要求

  • Go 1.10 及以上。
  • MongoDB 2.6 及以上。

功能

  • 文档的增删改查, 均支持官方driver支持的所有options
  • Sortlimitcountselectdistinct
  • 事务
  • Hooks
  • 自动化更新的默认和定制fields
  • 预定义操作符
  • 聚合Aggregate、索引操作、cursor
  • validation tags 基于tag的字段验证
  • 可自定义插件化编程

安装

推荐方式是使用go mod,通过在源码中import github.com/qiniu/qmgo 来自动安装依赖。

当然,通过下面方式同样可行:

1
go get github.com/qiniu/qmgo

Usage

开始

import并新建连接

1
2
3
4
5
6
7
8
9
10
11
import(
"context"

"github.com/qiniu/qmgo"
)

ctx := context.Background()
client, err := qmgo.NewClient(ctx, &qmgo.Config{Uri: "mongodb://localhost:27017"})
db := client.Database("class")
coll := db.Collection("user")

如果你的连接是指向固定的 database 和 collection,我们推荐使用下面的更方便的方法初始化连接,后续操作都基于cli而不用再关心 database 和 collection

1
cli, err := qmgo.Open(ctx, &qmgo.Config{Uri: "mongodb://localhost:27017", Database: "class", Coll: "user"})

后面都会基于cli来举例,如果你使用第一种传统的方式进行初始化,根据上下文,将cli替换成clientdbcoll即可

在初始化成功后,请defer来关闭连接

1
2
3
4
5
defer func() {
if err = cli.Close(ctx); err != nil {
panic(err)
}
}()

创建索引

做操作前,我们先初始化一些数据:

1
2
3
4
5
6
7
8
9
10
11
12

type UserInfo struct {
Name string `bson:"name"`
Age uint16 `bson:"age"`
Weight uint32 `bson:"weight"`
}

var userInfo = UserInfo{
Name: "xm",
Age: 7,
Weight: 40,
}

创建索引

1
2
cli.CreateOneIndex(context.Background(), options.IndexModel{Key: []string{"name"}})
cli.CreateIndexes(context.Background(), []options.IndexModel{{Key: []string{"id2", "id3"}}})

插入一个文档

1
2
// insert one document
result, err := cli.InsertOne(ctx, userInfo)

查询

查找一个文档

1
2
3
    // find one document
one := UserInfo{}
err = cli.Find(ctx, bson.M{"name": userInfo.Name}).One(&one)

Select只返回需要的字段

只返回age字段

1
err := cli.Find(ctx, bson.M{"age": 10}).Select(bson.M{"age": 1}).One(&one)

排序

使用sort方法。写上字段名即可,默认是升序,前面加上-就是降序了

1
2
3
4
5
6
7
8
9
// 升序
err = coll.Find(ctx, findOptions).Sort("weight").All(&users)

// 降序
err = coll.Find(ctx, findOptions).Sort("-weight").All(&users)

// 组合排序
err = coll.Find(ctx, findOptions).Sort("age","-weight").All(&users)

分页查找

1
2
batch := []UserInfo{}
cli.Find(ctx, bson.M{"age": 6}).Sort("weight").Skip(10).Limit(10).All(&batch)

Count

1
count, err := cli.Find(ctx, bson.M{"age": 6}).Count()

删除文档

1
err = cli.Remove(ctx, bson.M{"age": 7})

插入多条数据

1
2
3
4
5
6
7
8
9
10
// multiple insert
var userInfos = []UserInfo{
UserInfo{Name: "a1", Age: 6, Weight: 20},
UserInfo{Name: "b2", Age: 6, Weight: 25},
UserInfo{Name: "c3", Age: 6, Weight: 30},
UserInfo{Name: "d4", Age: 6, Weight: 35},
UserInfo{Name: "a1", Age: 7, Weight: 40},
UserInfo{Name: "a1", Age: 8, Weight: 45},
}
result, err = cli.Collection.InsertMany(ctx, userInfos)

Update

1
2
3
4
5
// UpdateOne one
err := cli.UpdateOne(ctx, bson.M{"name": "d4"}, bson.M{"$set": bson.M{"age": 7}})

// UpdateAll
result, err := cli.UpdateAll(ctx, bson.M{"age": 6}, bson.M{"$set": bson.M{"age": 10}})

Aggregate

1
2
3
4
matchStage := bson.D{{"$match", []bson.E{{"weight", bson.D{{"$gt", 30}}}}}}
groupStage := bson.D{{"$group", bson.D{{"_id", "$name"}, {"total", bson.D{{"$sum", "$age"}}}}}}
var showsWithInfo []bson.M
err = cli.Aggregate(context.Background(), Pipeline{matchStage, groupStage}).All(&showsWithInfo)

建立连接时支持所有 mongoDB 的Options

1
2
3
4
5
6
7
8
9
10
11
12
13
14
poolMonitor := &event.PoolMonitor{
Event: func(evt *event.PoolEvent) {
switch evt.Type {
case event.GetSucceeded:
fmt.Println("GetSucceeded")
case event.ConnectionReturned:
fmt.Println("ConnectionReturned")
}
},
}

opt := options.Client().SetPoolMonitor(poolMonitor) // more options use the chain options.
cli, err := Open(ctx, &Config{Uri: URI, Database: DATABASE, Coll: COLL}, opt)

事务

有史以来最简单和强大的事务, 同时还有超时和重试等功能:

1
2
3
4
5
6
7
8
9
10
11
callback := func(sessCtx context.Context) (interface{}, error) {
// 重要:确保事务中的每一个操作,都使用传入的sessCtx参数
if _, err := cli.InsertOne(sessCtx, bson.D{{"abc", int32(1)}}); err != nil {
return nil, err
}
if _, err := cli.InsertOne(sessCtx, bson.D{{"xyz", int32(999)}}); err != nil {
return nil, err
}
return nil, nil
}
result, err = cli.DoTransaction(ctx, callback)

关于事务的更多内容

预定义操作符

1
2
3
4
5
// aggregate
matchStage := bson.D{{operator.Match, []bson.E{{"weight", bson.D{{operator.Gt, 30}}}}}}
groupStage := bson.D{{operator.Group, bson.D{{"_id", "$name"}, {"total", bson.D{{operator.Sum, "$age"}}}}}}
var showsWithInfo []bson.M
err = cli.Aggregate(context.Background(), Pipeline{matchStage, groupStage}).All(&showsWithInfo)

Hooks

Qmgo 灵活的 hooks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type User struct {
Name string `bson:"name"`
Age int `bson:"age"`
}
func (u *User) BeforeInsert(ctx context.Context) error {
fmt.Println("before insert called")
return nil
}
func (u *User) AfterInsert(ctx context.Context) error {
fmt.Println("after insert called")
return nil
}

u := &User{Name: "Alice", Age: 7}
_, err := cli.InsertOne(context.Background(), u)

Hooks 详情介绍

自动化更新fields

Qmgo支持2种方式来自动化更新特定的字段

  • 默认 fields

在文档结构体里注入 field.DefaultField, Qmgo 会自动在更新和插入操作时更新 createAtupdateAt and _id field的值.

1
2
3
4
5
6
7
8
9
10
type User struct {
field.DefaultField `bson:",inline"`

Name string `bson:"name"`
Age int `bson:"age"`
}

u := &User{Name: "Lucas", Age: 7}
_, err := cli.InsertOne(context.Background(), u)
// tag为createAt、updateAt 和 _id 的字段会自动更新插入
  • Custom fields

可以自定义field名, Qmgo 会自动在更新和插入操作时更新他们.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type User struct {
Name string `bson:"name"`
Age int `bson:"age"`

MyId string `bson:"myId"`
CreateTimeAt time.Time `bson:"createTimeAt"`
UpdateTimeAt int64 `bson:"updateTimeAt"`
}
// 指定自定义field的field名
func (u *User) CustomFields() field.CustomFieldsBuilder {
return field.NewCustom().SetCreateAt("CreateTimeAt").SetUpdateAt("UpdateTimeAt").SetId("MyId")
}

u := &User{Name: "Lucas", Age: 7}
_, err := cli.InsertOne(context.Background(), u)
// CreateTimeAt、UpdateTimeAt and MyId 会自动更新并插入DB

// 假设Id和ui已经初始化
err = cli.ReplaceOne(context.Background(), bson.M{"_id": Id}, &ui)
// UpdateTimeAt 会被自动更新

例子介绍

自动化 fields 详情介绍

Validation tags 基于tag的字段验证

功能基于go-playground/validator实现。

所以Qmgo支持所有go-playground/validator 的struct验证规则,比如:

1
2
3
4
5
6
7
8
type User struct {
FirstName string `bson:"fname"`
LastName string `bson:"lname"`
Age uint8 `bson:"age" validate:"gte=0,lte=130" ` // Age must in [0,130]
Email string `bson:"e-mail" validate:"required,email"` // Email can't be empty string, and must has email format
CreateAt time.Time `bson:"createAt" validate:"lte"` // CreateAt must lte than current time
Relations map[string]string `bson:"relations" validate:"max=2"` // Relations can't has more than 2 elements
}

本功能只对以下API有效:
InsertOne、InsertyMany、Upsert、UpsertId、ReplaceOne

插件化编程

  • 实现以下方法
1
2
3
func Do(ctx context.Context, doc interface{}, opType operator.OpType, opts ...interface{}) error{
// do anything
}
  • 调用middleware包的Register方法,注入Do
    Qmgo会在支持的操作执行前后调用Do
1
middleware.Register(Do)

Example

Qmgo的hook、自动更新field和validation tags都基于plugin的方式实现

qmgo vs go.mongodb.org/mongo-driver

下面我们举一个多文件查找、sortlimit的例子, 说明qmgomgo的相似,以及对go.mongodb.org/mongo-driver的改进

官方Driver需要这样实现

1
2
3
4
5
6
7
8
9
10
11
// go.mongodb.org/mongo-driver
// find all 、sort and limit
findOptions := options.Find()
findOptions.SetLimit(7) // set limit
var sorts D
sorts = append(sorts, E{Key: "weight", Value: 1})
findOptions.SetSort(sorts) // set sort

batch := []UserInfo{}
cur, err := coll.Find(ctx, bson.M{"age": 6}, findOptions)
cur.All(ctx, &batch)

Qmgomgo更简单,而且实现相似:

1
2
3
4
5
6
7
8
// qmgo
// find all 、sort and limit
batch := []UserInfo{}
cli.Find(ctx, bson.M{"age": 6}).Sort("weight").Limit(7).All(&batch)

// mgo
// find all 、sort and limit
coll.Find(bson.M{"age": 6}).Sort("weight").Limit(7).All(&batch)

Qmgo vs mgo

Qmgo 和 Mgo 的差异

gin

swag

1.path路径参数

在gin中添加路由,路径参数使用冒号

1
emailRouterPublic.POST(":groupId/addEmailAddr", emailToolApi.AddEmailAddr)

在swag的router注释中使用{}

1
2
// @Param     groupId  path     string            true  "邮件组ID"
// @Router /emailTool/{groupId}/addEmailAddr [post]

两者不一样,需要注意

mongo

根据id查询,需要对id生成mongo的ObjId

1
2
3
objID, _ := primitive.ObjectIDFromHex(id)
var group tool.EmailAddrGroup
e := collxx.Find(context.Background(), bson.M{"_id": objID}).One(&group)

Go依赖管理

Go 程序由 Go 包组合而成的,Go 程序的构建过程就是确定包版本、编译包以及将编译后 得到的目标文件链接在一起的过程。

Go Module

go 1.11版本引入。

一个 Go Module 的顶 层目录下会放置一个 go.mod 文件,每个 go.mod 文件会定义唯一一个 module,也就是 说 Go Module 与 go.mod 是一一对应的。

go.mod 文件所在的顶层目录也被称为 module 的根目录,module 根目录以及它子目录 下的所有 Go 包均归属于这个 Go Module,这个 module 也被称为 main module。

由 go mod tidy 下载的依赖 module 会被放置在本地的 module 缓存路径下,默认值为 $GOPATH[0]/pkg/mod,Go 1.15 及以后版本可以通过 GOMODCACHE 环境变量,自 定义本地 module 的缓存路径。

包依赖管理

go为了解决包依赖管理问题,采取了语义导入版本 (Semantic Import Versioning),以及和其他主流 语言不同的最小版本选择 (Minimal Version Selection) 等机制

语义导入版本机制

我们看 go.mod 的 require 段中依赖的版本号,都符合 vX.Y.Z 的格 式。在 Go Module 构建模式下,一个符合 Go Module 要求的版本号,就是这样由前缀 v 和一个 满足规范的版本号组成。

x:主版本。不同主版本是互相不兼容的

y:次版本。同一主版本。大的次版本号向后兼容

z:补丁版本。不影响兼容性

而且,Go Module 规定:如果同一个包的新旧版本是兼容的,那么它们的包导入路径应该 是相同的。

那么有一个问题,如果遇到主版本升级了,怎么办?

Go Module 创新性地给出了一个方法:将包主版本号引入到包导入路径中。

比如,以前是1.x.y版本:

1
import "github.com/sirupsen/logrus"

现在升级到2.x版本

1
import "github.com/sirupsen/logrus/v2"

这就是 Go 的“语义导入版本”机制,也就是说通过在包导入路径中引入主版本号的方 式,来区别同一个包的不兼容版本,这样一来我们甚至可以同时依赖一个包的两个不兼容 版本

另外,为什么v1不需要写呢?

因为,这是约定,当依赖的主版 本号为 0 或 1 的时候,我们在 Go 源码中导入依赖包,不需要在包的导入路径上增加版本 号

最小版本选择原则

myproject 有两个直接依赖 A 和 B,A 和 B 有一个共同的依赖包 C,但 A 依 赖 C 的 v1.1.0 版本,而 B 依赖的是 C 的 v1.3.0 版本,并且此时 C 包的最新发布版为 C v1.7.0。这个时候,Go 命令是如何为 myproject 选出间接依赖包 C 的版本呢?选出的究 竟是 v1.7.0、v1.1.0 还是 v1.3.0 呢?

按照上面提到的,版本兼容性,选择1.7.0版本应该是可以保障向后兼容的。

但是。go选择了最小版本匹配原则。会使用1.3.0的版本。

指定版本

如果go自动为我们确认的版本有问题。我们可以指定版本。

go list列出所有版本

1
go list -m -versions github.com/sirupsen/logrus

指定版本

1
2
go mod edit -require=github.com/sirupsen/logrus@v1.7.0
go mod tidy