1 背景和综述
- 作者作为校招新人入职,公司用Golang, 自己有Java、PHP的 Web 开经验,熟悉前端的Vue技术栈,在此之前没写过Golang项目,只是几个月前按照菜鸟教程看了一遍Golang的语法 ,了解其天生高并发的特性,知道其有协程、管道这两个重要的特性。
- 入职当天就要求自学做一个Golang的新手村任务,做一个分布式的在2000并发访问下能够做到1000QPS的登录注册,期限一周,要求细节不便透露,这里就不详细展开了,本文所分享的干货主要来自于这个新手村任务的积累。
- Golang 语法入门很简单,但是根据我的入门经验,最容易对初学者造成困扰的是代码的组织形式,稍有语法基础的读者建议直接看 2.2 代码组织部分。
2 Golang 的常用语法以及代码组织
2.1 语法
- 如果你不曾系统看过Golang语法,那么没关系,认真看完下面这个代码,就足够了
// 单行注释
/* 多行
注释 */
// go的IDE我推荐Vscode,Goland也不错,前者我自用,后者同同事都在用
// 用 go 开发时注释中也会经常见到文档注解,如 @Version 1.0.0
// 导入包的子句在每个源文件的开头。
// Main比较特殊,它用来声明可执行文件,而不是一个库。
package main
// Import语句声明了当前文件引用的包。
import (
"fmt" // Go语言标准库中的包
"net/http" // 一个web服务器包
"strconv" // 字符串转换
"strings" // 字符串标准库
)
// 等你写习惯了go不带分好,写Java会很变扭
// 函数声明:Main是程序执行的入口。
// 不管你喜欢还是不喜欢,反正Go就用了花括号来包住函数体,花括号配合严格的缩进,代码非常易读。
func main() {
// 往标准输出打印一行。
// 用包名fmt限制打印函数。
fmt.Println("Hello world!")
// 写项目时用 log.Println("Hello world!") 会更多
// 调用当前包的另一个函数。
beyondHello()
}
// 函数可以在括号里加参数。
// 如果没有参数的话,也需要一个空括号。
func beyondHello() {
var x int // 变量声明,变量必须在使用之前声明。
x = 3 // 变量赋值。
// 可以用:=来偷懒,它自动把变量类型、声明和赋值都搞定了,这个在项目中最常用。
y := 4
sum, prod := learnMultiple(x, y) // 返回多个变量的函数
fmt.Println("sum:", sum, "prod:", prod) // 简单输出
learnTypes() // 少于y分钟,学的更多!
}
// 多变量和多返回值的函数
func learnMultiple(x, y int) (sum, prod int) {
return x + y, x * y // 返回两个值
/*
// 因为这里返回值不仅有类型,还写了名称,因此函数体其实还可以这么写
sum = x + y // 注意这里不是 :=
prod = x * y
return
*/
}
// 内置变量类型和关键词
func learnTypes() {
// 短声明给你所想。
s := "Learn Go!" // String类型
s2 := `A "raw" string literal
can include line breaks.` // 同样是String类型
// 非ascii字符。Go使用UTF-8编码。
g := 'Σ' // rune类型,int32的别名,使用UTF-8编码
f := 3.14195 // float64类型,IEEE-754 64位浮点数
c := 3 + 4i // complex128类型,内部使用两个float64表示
// Var变量可以直接初始化。
var u uint = 7 // unsigned 无符号变量,但是实现依赖int型变量的长度
var pi float32 = 22. / 7
// 字符转换
n := byte('\n') // byte是uint8的别名
// 数组类型编译的时候大小固定。
var a4 [4] int // 有4个int变量的数组,初始为0
a3 := [...]int{3, 1, 5} // 有3个int变量的数组,同时进行了初始化
// Slice 可以动态的增删。Array和Slice各有千秋,但是使用slice的地方更多些。
s3 := []int{4, 5, 9} // 和a3相比,这里没有省略号
s4 := make([]int, 4) // 分配一个有4个int型变量的slice,全部被初始化为0
// 常见操作
str := "带加豪,偶稀渣渣辉"
bs := []byte(str) // 字符串转化为byte切片
str2 := string(bs) // byte切片转字符串
println(str, len(str), bs, len(bs), str2, strings.Count(str, "")-1) // println函数更像 Java的 sysout
fmt.Println(str, len(str), bs, len(bs), str2, strings.Count(str2, "渣")) // 这个效果更好
var d2 [][]float64 // 声明而已,什么都没有分配
bs := []byte("a slice") // 类型转换的语法
p, q := learnMemory() // 声明p,q为int型变量的指针
fmt.Println(*p, *q) // * 取值
// Map是动态可增长关联数组,和其他语言中的hash或者字典相似。
m := map[string]int{"three": 3, "four": 4}
m["one"] = 1
// 在Go语言中未使用的变量在编译的时候会报错,而不是warning。
// 下划线 _ 可以使你“使用”一个变量,但是丢弃它的值。
_,_,_,_,_,_,_,_,_ = s2, g, f, u, pi, n, a3, s4, bs
// 输出变量, 从结果可知上面这种操作并不会清除变量
fmt.Println(s, c, a4, s3, d2, m)
learnFlowControl() // 回到流程控制
}
// Go全面支持垃圾回收。Go有指针,但是不支持指针运算。
// 你会因为空指针而犯错,但是不会因为增加指针而犯错。
func learnMemory() (p, q *int) {
// 返回int型变量指针p和q
p = new(int) // 内置函数new分配内存
// 自动将分配的int赋值0,p不再是空的了。
s := make([]int, 20) // 给20个int变量分配一块内存
s[3] = 7 // 赋值
r := -2 // 声明另一个局部变量
return &s[3], &r // & 取地址
}
func expensiveComputation() int {
return 1e6 // 1 * 10^6
}
func learnFlowControl() {
// If需要花括号,括号就免了
if true {
fmt.Println("told ya")
}
// 用go fmt 命令可以帮你格式化代码,所以不用怕被人吐槽代码风格了,
// 也不用容忍被人的代码风格。
if false {
// pout
} else {
// gloat
}
// 如果太多嵌套的if语句,推荐使用switch
x := 1
switch x {
case 0:
case 1:
// 隐式调用break语句,匹配上一个即停止
case 2:
// 不会运行
case 3:
// 如果非要匹配上一个不停止,也不是不可
fallthrough // 但是不建议使用
default:
// 都没匹配上执行
}
// 和if一样,for也不用括号
for x := 0; x < 3; x++ { // ++ 自增
fmt.Println("iteration", x)
}
// x在这里还是1。为什么?
// for 是go里唯一的循环关键字,不过它有很多变种
for { // 死循环
break // 骗你的
continue // 不会运行的
}
// 和for一样,if中的:=先给y赋值,然后再和x作比较。
if y := expensiveComputation(); y > x {
x = y
}
// 闭包函数
xBig := func() bool {
return x > 100 // x是上面声明的变量引用
}
fmt.Println("xBig:", xBig()) // true (上面把y赋给x了)
x /= 1e5 // x变成10
fmt.Println("xBig:", xBig()) // 现在是false
// 当你需要goto的时候,你会爱死它的!
goto love
love:
learnInterfaces() // 好东西来了!
}
// 定义Stringer为一个接口类型,有一个方法String
type Stringer interface {
String() string // 这里可以有多个方法声明
}
// 定义pair为一个结构体,有x和y两个int型变量。
// 这个pair相当于Java中的一个类
type pair struct {
x, y int
}
// 定义pair类型的成员方法,实现Stringer接口,这里不需要显示指定 Stringer
func (p pair) String() string { // p被叫做“接收器”
// Sprintf是fmt包中的另一个公有函数。
// 用 . 调用p中的元素。
return fmt.Sprintf("(%d, %d)", p.x, p.y)
}
func learnInterfaces() {
// 花括号用来定义结构体变量,:=在这里将一个结构体变量赋值给p。
p := pair{3, 4}
fmt.Println(p.String()) // 调用pair类型p的String方法
var i Stringer // 声明i为Stringer接口类型
i = p // 有效!因为p实现了Stringer接口(类似java中的塑型)
// 调用i的String方法,输出和上面一样
fmt.Println(i.String())
// fmt包中的Println函数向对象要它们的string输出,实现了String方法就可以这样使用了。
// (类似java中的序列化)
fmt.Println(p) // 输出和上面一样,自动调用String函数。
fmt.Println(i) // 输出和上面一样。
learnErrorHandling()
}
// 你可以定义别的结构体作为接收器来实现 String() string 方法,实现多态,
// 在实例化时以Stringer作为其类型 (var obj Stringer), 赋值赋不同的结构体
// 可详细参考 https://www.jianshu.com/p/1227c5145cd8
// 我认为 Golang勉强可以面向对象,但是没必要对Java那套太挂念
func learnErrorHandling() {
// 一开始不适应没 try catch,习惯了你会爱上她
// ", ok"用来判断有没有正常工作
m := map[int]string{3: "three", 4: "four"}
if x, ok := m[1]; !ok { // ok 为false,因为m中没有1
fmt.Println("no one there")
} else {
fmt.Print(x) // 如果x在map中的话,x就是那个值喽。
}
// 错误可不只是ok,它还可以给出关于问题的更多细节。
if _, err := strconv.Atoi("non-int"); err != nil { // _ discards value
// 输出"strconv.ParseInt: parsing "non-int": invalid syntax"
fmt.Println(err)
}
// 待会再说接口吧。同时,
learnConcurrency()
}
// c是channel类型,一个并发安全的通信对象。
func inc(i int, c chan int) {
c <- i + 1 // <-把右边的发送到左边的channel。
}
// 我们将用inc函数来并发地增加一些数字。
func learnConcurrency() {
// 用make来声明一个slice,make会分配和初始化slice,map和channel。
c := make(chan int)
// 用go关键字开始三个并发的goroutine,如果机器支持的话,还可能是并行执行。
// 三个都被发送到同一个channel。
go inc(0, c) // go is a statement that starts a new goroutine.
go inc(10, c)
go inc(-805, c)
// 从channel中独处结果并打印。
// 打印出什么东西是不可预知的。
fmt.Println(<-c, <-c, <-c) // channel在右边的时候,<-是读操作。
cs := make(chan string) // 操作string的channel
cc := make(chan chan string) // 操作channel的channel
go func() { c <- 84 }() // 开始一个goroutine来异步发送一个新的数字
go func() { cs <- "wordy" }() // 发送给cs
// Select类似于switch,但是每个case包括一个channel操作。
// 它随机选择一个准备好通讯的case。
select {
case i := <-c: // 从channel接收的值可以赋给其他变量
fmt.Println("it's a", i)
case <-cs: // 或者直接丢弃
fmt.Println("it's a string")
case <-cc: // 空的,还没作好通讯的准备
fmt.Println("didn't happen.")
}
// 上面c或者cs的值被取到,其中一个goroutine结束,另外一个一直阻塞。
learnWebProgramming() // Go很适合web编程,我知道你也想学!
}
// 上述函数可是涉及到了 多线程,多线程通信,但是却简单明了
// http包中的一个简单的函数就可以开启web服务器。
func learnWebProgramming() {
// ListenAndServe第一个参数指定了监听端口,第二个参数是一个接口,特定是http.Handler。
err := http.ListenAndServe(":8080", pair{})
fmt.Println(err) // 不要无视错误。
// 如何优雅的关闭 http server:
}
// 使pair实现http.Handler接口的ServeHTTP方法。
func (p pair) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 使用http.ResponseWriter返回数据
w.Write([]byte("You learned Go in Y minutes!"))
defer fmt.Println("最后一个执行")
// defer 语句常用于处理 io的关闭,在函数内代码执行完之后依次执行
// 执行顺序遵循压栈出栈顺序
defer func(){
fmt.Println("倒数第二个执行")
}()
}
至此你已经掌握了多数 Go语言教程中的重点内容,但是下面的Golang代码组织篇却更为重要,是笔者早期驯服Go语言时的珍贵经验。
2.2 代码组织
- 对于Java或者别的很多编程语言,都有
global、var、public、private
之类的作用域限定关键词,但是Golang不是这样的,Go 语言中的 大写字母开头的方法是Public的,小写字母开头的是private的,这就是为什么fmt.Println()
的方法名是大写字母开头的原因
明白了这一点,作为 Javaer 的你再去看Go 的代码,是不是觉得眉目清秀起来了?
- 这一点非常重要,作者早期在用
json.Marshal
序列化一个struct
时,序列化得到的字符串是没有成员变量的空对象{}
,直到我给成员变量名改为大写字母开头,问题才解决! - Java 中的类和方法会有static 关键字修饰,在Golang中包变量和包函数就是static的,也就是说在多处import的同一个包,其中直接定义的变量是全局的。
- 关于
package
和import
, 跟Java
中的有着较大区别,Java 中的package是一个完整的路径字符串,和目录结构是一致的,Go中路径是路径,包名是包名,import
导入时导入的不是包名,而是包的路径,包名是在导入包的代码中使用时使用的,如fmt.Println()
。 - 这一点也非常重要,作者早期试图像导入Java的类一样导入Golang 的package下的一个文件,怎么都不能成功,反复试错,直到发现 Golang 的一个package下的若干文件的作用域是互通的,也就是一个目录下(不包含子目录)的若干文件是同一个包,引入这个包所在的目录路径即可。同一个包下的不同文件中小写字母开头的函数和变量作用域是互通的,就像在同一个文件中一般。
- 关于
GOPATH
与go mod
,GOPATH
是 Go语言中使用的一个环境变量,它使用绝对路径提供项目的工作目录,当我们的第三方包不是官方库时都要放在GOPATH/src
下才可以使用。该方案导致第三方库和自己的项目都在GOPATH/src
目录下,鱼龙混杂,如果GOPATH/src
下面不同的包依赖的同一第三方库的不同版本,那么就会出现版本冲突,总之就是对多项目支持不友好。于是在go1.11版本之后出现了go mod
,其于Java 的Maven
比较类似,项目根目录的go.mod
依赖配置文件类比于Maven项目根目录的pom.xml
。
解决了代码组织问题,你已经掌握了使用 使用Golang 生存的基本技能
3 Go 语言常用组件的使用
众所周知,go语言早期使用C和汇编实现,在1.5版本,其实现自举( 使用Go实现Go编译器 ),这意味着弄懂了Golang语法,然后要看懂Golang源码是不困难的,看看源码,总会有一些意料之外的收货。
3.1 rpc
- 从Golang的源码中可以看到两个关于rpc的包,如下图,一个是
net/rpc
,另一个是net/rpc/jsonrpc
- 对应的包有测试代码,如
server.go
的测试代码为server_test.go
, 可以看做非常高质量的官方Demo
3.2 ginweb
- 跑起一个接口demo:
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run() // 监听并在 0.0.0.0:8080 上启动服务
}
- 这些年开发流行前后端分离,前端纯静态文件服务:
func main() {
router := gin.Default()
router.Static("/assets", "./assets")
router.StaticFS("/more_static", http.Dir("my_file_system"))
router.StaticFile("/favicon.ico", "./resources/favicon.ico")
// 监听并在 0.0.0.0:8080 上启动服务
router.Run(":8080")
}
3.3 redis
- go-redis 还可以
- redis 单点失效在我的开发经验中的确发生过,因此redis高可用是必要的,面试中也是常问的,关于 redis 集群方案,推荐阅读此文章,文章最后总结 官方Redis-Cluster 是更靠谱的选择,但是我觉得 Codis 对开发者更为友好,解决了一些关键痛点,更为推荐。
3.4 mysql
- gorm 是很不错的选择:gorm 文档
4 Go 语言压测工具 wrk的基本使用
- Golang 性能较高,单点使用 Jmeter 不一定能压到性能上限,因此我使用 wrk
- wrk 的 lua 脚本的执行过程和生命周期比较坑多, 在这里进行一些总结
1. 生命周期:
首先总体上分为三个阶段 :setup, ruuning, done
然后每个阶段可细分:
setup(thread) 每个线程执行一次
runing:
init(args) 每个线程执行一次
delay() 每个请求执行一次
request() 每个请求执行一次
response(status, headers, body) 每个请求执行一次
done(summary, latency, requests)
这里值得注意的是,周期函数外的语句会被执行 thread + 1 次,因此想要初始化一次的东西,应该使用计数器,判断为第一次时执行
2. 变量作用域
a. lua 脚本内, 生命周期函数外变量
b. setup 阶段函数内
c. runing 阶段的函数内
d. done 阶段的函数内
e. 其他自定义函数内
这里面
- abd作用域互通
- c的所有函数中作用域互通,但是每个线程单独持有
- d可以通过 thread 获取到每个线程的局部变量
issue: 如何在每个thread中获取全局变量,如测试数据集?
- 方法一: 在生命周期函数setup中给传入的thread传入数据,缺点,不适合大量数据
- 方法二: 引用传递(其可行性还需调查wrk内存调用模型以及lua如何跨进程引用传递)从报错情况来看,猜测init阶段使用堆内存,可以加载较多数据
- 一个复杂的 example:
samples = {} -- lua 中函数传递table时引用传递
local counter = 1 -- 全局的变量
local threads = {}
function setup(thread)
-- 执行 t 次
if counter == 1 then
loadSamples()
end
thread:set("id", counter)
math.randomseed(os.time())
-- 线程初始化时就完成用例设定,因此是固定用户
thread:set("sample", samples[math.random(0, #samples)])
counter = counter + 1
end
function init(args)
faild = 0
responses = 0
end
-- 加载数据
function loadSamples()
local j = 0
for line in io.lines("../pressure/loginbody4lua.txt")
do
samples[j] = line
end
-- print("总共加载了"..j.."个用例")
end
-- 单元测试,验证正确性,正式压测时不注释的话脚本性能上不来
-- function response(status, headers, body)
-- -- 属于线程,每个请求完成执行一次, 统计失败
-- if status == 200 then
-- if(not string.find(body,'"code":0')) then
-- faild = faild + 1
-- end
-- wrk.thread:stop()
-- else
-- faild = faild + 1
-- end
-- responses = responses + 1
-- end
wrk.method = "POST"
wrk.host = "127.0.0.1"
wrk.port = 8000
wrk.path = "/api/v1/login"
wrk.headers["Content-Type"] = "application/json"
wrk.headers["Connection"] = "keep-alive"
function request(args)
wrk.body = sample
return wrk.format()
end
-- 200并发请求
-- redis-cli flushall
-- redis-cli flushall & wrk -t200 -c200 -d60s -s login_fixuser.lua http://127.0.0.1:8000 --latency
-- wrk -t200 -c200 -d120s -s login_fixuser.lua http://127.0.0.1:8000 --latency
-- 2000并发请求
-- redis-cli flushall
-- wrk -t2000 -c2000 -d30s -s login_fixuser.lua http://127.0.0.1:8000 --latency
-- wrk -t2000 -c2000 -d60s -s login_fixuser.lua http://127.0.0.1:8000 --latency
-- wrk -t2000 -c2000 -d120s -s login_fixuser.lua http://127.0.0.1:8000 --latency
5 Go 技术栈下的单元测试