这是我参与「第五届青训营 」伴学笔记创作活动的第1天
GO - 基础语法
概述
- 语言简介
- 开发入门(语言及环境配置,基础语法,标准库)
- 基本语法
- 实战项目
语言简介
特点
- 高性能、高并发
- 语法简单、学习曲线平缓
- 标准库丰富
- 工具链完善
- 静态链接
- 快速编译
- 跨平台
- GC
优势
- 几乎所有的云原生组件都使用Go开发
- 多个领域拥有较高的市场占有率 (云计算、微服务、大数据、区块链、物联网、etc.)
开发入门
安装Go Runtime
按照 go.dev 的提示进行安装即可
配置Go
注意配置环境变量GOPROXY
,将值设置为https://goproxy.cn
具体配置方法可参考七牛云 提供的教程
选择开发环境
编辑器方面,推荐使用VS Code (插件生态丰富);
IDE方面,推荐使用Goland (在重构、代码生成等方面比较好)
基础语法
可参考的学习材料
Hello World
package main
import (
"fmt"
)
func main() {
fmt.Println("hello world")
}
代码解析
- 表达式后无需添加分号;若需要在一行内执行 多个 表达式,则需要使用分号
package main
表示文件属于main
包的一部分,也就是程序的入口包import "fmt"
表示导入fmt
包,用于输入输出格式化字符串。当一次性导入多个包时,可以使用括号,一个包占一行,使用 逗号 进行分隔func main()
为程序入口函数
代码运行/编译
- 运行:执行命令
go run [yourFileName].go
- 编译二进制文件:执行命令
go build [yourFileName].go
,完成编译后运行可执行二进制文件即可
可以进入
pkf.go.dev/[packageName]
查看对应包的在线文档
变量类型
Go为强类型语言,运算符使用和优先级与大多数编程语言类似。
基本数据类型
- 整型
- 浮点
- 复数
- 布尔
- 字符串 (可使用
+
进行字符串拼接,也可使用=
进行比较) - 常量 (常量没有确定类型,会根据上下文自动推导)
复合数据类型
- 数组
- Slice
- Map
- 结构体
- JSON
- 文本和HTML模板
复合数据类型的0值(zero value)均为
nil
,并非其他编程语言常用的null
变量声明方式
var [name] [type] = [value]
[name] := [value]
(自动推导数据类型)const [name] = [value]
(声明常量)
Go支持同时声明或定义多个变量
var a, b = int
c, d := 3, "hello"
逻辑控制
条件分支 (if-else)
语法类似于C/C++,区别为:
if
的判断条件无需括号if
判断后的执行代码块不允许省略大括号- 实际开发中,
if
的判断条件中会执行多条语句
条件分支 (switch)
swich
中可以使用任何的变量类型,甚至可以取代任意的 if-else
。可在switch手不添加任何的变量,然后在case内写条件分支,会比多个 if-else
更为清晰。其他需要注意的事项为:
- 判断条件无需括号
- case中无需break,若需要单次执行后面的case,可以使用
fallthrough
(此情况下,不会判断下一个case的表达式的值)
循环 (for)
Go中仅提供 for
循环,同样的:
- 判断条件无需括号,若无判断条件则为死循环
- 类似于
if-else
中的第三点,for
中可以实现经典的C循环:for i := 0; i < 4; i++
- 可以使用
break
或continue
跳出循环
数组
因为其长度固定,所以在实际开发中更常使用 Slice
- 创建空数组:
var a [5]int
- 创建数组同时初始化值:
b := [5]int{1, 2, 3, 4, 5}
Slice
Slice
的长度不固定,因为其存储了长度,容量以及指向数组的指针 (所以在创建时可以指定初始长度);当容量不足时,Slice会自动扩容,并返回新的Slice。而且,Slice
可使用的方法更多:
- 使用
make
创建Slice:s := make([]int, 5)
- 使用
append
追加新元素:s = append(s, 6)
- 可进行切片操作 (类似于Python,但是不支持 步长 和 负数范围 )
Map
使用键值对进行数据存储,无序,并且在遍历时为随机顺序
- 创建方式:
var a = make(map[string]int)
- 定义方式:
b := map[string]int{"one":1, "two":2}
- 删除键值对:
delete(b, "two")
Range
对于Slice或Map,可以使用 Range
快速遍历。对于数组和Map,Ragne会返回两个值:Index
和 Value
;若不需要其中某个值,可以使用 _
表示忽略该值
nums := []int{1, 2, 3}
for _, num := range nums {
...
}
函数
Go的函数返回值类型是后置的,而且原生支持返回多个值 (参考变量声明时可以声明多个变量)。在实际的开发中,几乎所有的业务逻辑的函数代码都返回两个值:第一个为结果,第二个为错误信息
func exists(m map[string]string, k string) (v string, ok bool) {
v, ok = m[k]
return v, ok
}
func main() {
v, ok := exists(map[string]string{"a":"A"}, "a")
}
指针
指针的主要用途之一:对于传入参数进行修改
func add2(n int) {
n += 2
}
func add2ptr(n *int) {
*n += 2
}
func main() {
n := 1
add2(n)
print(n) // 1
add2ptr(&n)
print(n) // 3
}
结构体
带类型的字段的集合:首字母为大写的字段为公开字段,小写的为私有字段。
结构体同样支持指针 (避免较大结构体的拷贝开销)。构造时需要传入初始值 (全部初始化或部分初始化,未初始化的字段均为“0值” - zero value)。访问结构体内字段使用 .
运算符。
type user struct {
name string // private field
password string
Id int64 // public field
}
func main() {
a := user{name:"zhao", password:"hello"}
b := user{password: "hello"}
c := user{"zhao", "hello"}
d := user{"zhao"}
print(a.name) // "zhao"
print(b.name) // ""
print(c.password) // "hello"
print(d.password) // ""
}
结构体方法
结构体中可以定义方法,但需要在结构体外部进行定义。其中:
- 定义结构体方法的结构体变量为指针时,实际操作的是结构体本身
- 定义结构体方法的结构体变量不为指针时,实际操作的为结构体的拷贝
type user struct {
name string
password string
}
func (u user) checkkPsw(password string) bool {
return u.password == password
}
func (u *user) resetPsw(password string) {
u.password = password
}
func main() {
a := user{"zhao", "hello"}
a.resetPsw("world")
print(a.checkPsw("world")) // true
}
错误处理
在Go中,error
作为一个接口类型来处理程序中的错误情况。不同于Java中的 try-catch
机制,在Go中只需 if-else
即可。
在函数中,如果出现了不符合流程的情况,可以使用 errors.New()
创建 error
并返回;当正常完成流程后,可以返回 nil
func findUser(users []user, name string) (v *user, err error) {
for _, u := range users {
if u.name == name {
return &u, nil
}
}
return nil, errors.New("not found")
}
字符串操作
Go的标准库 strings
中包含了很多常用的字符串工具函数
- strings.Contains()
- strings.Count()
- strings.HasPrefix()
- strings.HasSuffix()
- strings.Index()
- strings.Join()
- strings.Repeat()
- strings.Replace()
- strings.Split()
- strings.ToLower()
- strings.ToUpper()
- etc.
字符串格式化
在 fmt
包中,有很多的字符串格式相关的函数;例如类似于C中的 printf
,但在Go中,可以直接使用 %v
打印任意类型的变量,或 %+v
打印详细结果,而 #%v
会更加的详细
type point struct {
x, y int
}
func main() {
p := point{1, 2}
fmt.Printf("p=%v", p) // p={1 2}
fmt.Printf("p=%+v", p) // p={x:1, y:2}
fmt.Printf("p=#%v", p) // p=main.point{x:1, y:2}
}
JSON处理
Go标准库中提供了对JSON的解析处理工具。对于JSON数据,我们只需要创建对应的结构体即可 (快速生成结构体的工具)
结构体(公开字段)和JSON数据的转换,需要使用 json.Marshal()
和 json.Unmarshal()
等方法。而 json.MarshalIndent()
等方法则是用于转换时进行字符串格式化
在转换过程中,如果JSON对象中变量名命名规则不符合Go中的变量命名规范,可以使用 JSONTAG
等语法进行处理 (语法讲解)
import (
"encoding/json"
"fmt"
)
type userInfo struct {
Name string
Age int `json:"age"`
Hobby []string
}
func main() {
a := userInfo{Name:"zhao", Age:18, Hobby: []string{"sing", "dance", "basketball"}}
buf, err := json.Marshal(a)
if err != nil {
panic(err)
}
fmt.Println(buf) // {123 34 78 97...}
fmt.Println(string(buf)) // {"Name":"zhao", "age":18, ...}
buf, err = json.MarshalIndent(a, "", "\t")
if err != nil {
panic(err)
}
fmt.Println(string(buf))
var b userInfo
err = json.Unmarshal(buf, &b)
if err != nil {
panic(err)
}
fmt.Printf("%#v\n", b) // main.userInfo{Name:"zhao", Age:18, ...}
}
时间处理
- 最常用的就是
time.now()
获取当前时间 time.date()
构造带时区的时间- 有很多方法可以获取时间点的年月日小时分钟秒
- 可以使用
[time].sub
对两个时间进行剑法,得到时间段(同样可以获得时分秒) - 使用
[time].Unix()
获得时间戳 time.format()
和time.parse()
数字解析
字符串和数字转换的方法存在于 strconv
包中
strconv.ParseInt()
和strconv.ParseFloat()
strconv.Atoi()
和strconv.itoA
- 输入不合法则返回
error
进程信息
- 使用
os.Args
获取程序执行时指定的命令行参数 (第一个参数为程序地址) - 使用
os.Getenv()
读取环境变量,os.Setenv()
设置环境变量 exec
用于执行命令行指令
实战项目
猜谜游戏
复习的内容:变量循环、流程控制和错误处理
其实就是猜数字,类似于聚会游戏“数字炸弹”。将程序实现进行拆分:
- 生成随机数
- 读取用户输入
- 实现判断逻辑
- 实现游戏循环
随机数
Version 1:使用 math/rand
包的 rand.Intn()
生成随机数 (但是每次生成的数字都是相同的)
Version 2:为生成随机数设置种子(比如程序启动的时间戳 - rand.Seed(time.Now().UnixNano())
)
func main() {
maxNum := 100
rand.Seed(time.Now().UnixNano())
secretNumber := rand.Intn(maxNum)
fmt.Println("the secret number is ", secretNumber)
}
输入
程序执行时会打开多个输入输出流文件(stdin
, stdout
, stderr
等)。其中 stdin
可通过 os.Stdin
获取,但直接操作并不方便,可以使用 bufio.NewReader
把文件转换成 reader
变量。reader
变量中有很多用来操作流的方法;例如使用ReadString
方法读取一行数据(返回结果包含结尾的换行符)
Unix系统里,每行结尾只有“<换行>”,即“\n”
Windows系统里面,每行结尾是“<换行><回车>”,即“\n\r”
Mac系统里,每行结尾是“<回车>”,即“\r”
func main() {
...
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReaderString('\n')
if err != nil {
fmt.Println("err occured!", err)
return
}
input = strings.TrimSuffix(input, "\n")
guess, err := strconv.Atoi(input)
if err != nil {
fmt.Println("invalid input!")
return
}
fmt.Println("you guess: ", guess)
}
判断
func main() {
...
if guess > secretNumber {
fmt.Println("your guess is greater.")
} else if guess < secretNumber {
fmt.Println("your guess is smaller.")
} else {
fmt.Println("correct!")
}
}
实现循环
现在只实现了单次的猜测(无论结果如何,只接收一次用户输入)。我们可以把刚刚获取输入的逻辑放入一个死循环中,在用户猜测错误的时候,continue
继续获取输入;用户猜测正确的时候,break
退出程序
func main() {
...
for {
input, err := reader.ReaderString('\n')
if err != nil {
fmt.Println("err occured!", err)
continue
}
input = strings.TrimSuffix(input, "\n")
guess, err := strconv.Atoi(input)
if err != nil {
fmt.Println("invalid input!")
continue
}
fmt.Println("you guess: ", guess)
if guess > secretNumber {
fmt.Println("your guess is greater.")
} else if guess < secretNumber {
fmt.Println("your guess is smaller.")
} else {
fmt.Println("correct!")
break
}
}
}
命令行词典
学习的内容:发送HTTP请求,解析JSON,使用代码生成提高开发效率
使用命令行指令和参数运行程序,通过调用第三方的API查询单词的翻译,并打印出来。大致的开发流程:
- 抓包
- 代码生成
- 生成
request body
- 解析
request body
- 打印结果
抓包
以 彩云翻译 为例。首先进入浏览器开发者工具 - 网络,对网络请求进行录制;然后手动发送一次翻译请求,获取其用于翻译的HTTP协议报文。
HTTP请求的header相对复杂,键值对有十多个。而请求头是一个JSON对象,里面有两个字段:一个是代表语言的转换类型,另一个source是要查询的单词。
API的返回结果里面,会有WIKI和Dictionary两个字段。我们需要用的结果主要在Dictionary.Explanation字段里面(其他字段中还包含了音标等信息)
代码生成
因为手动生成和浏览器中一样的请求比较麻烦,可以在浏览器的开发者工具中的该网络请求列表项目上右键,选择copy as curl
,然后在terminal中粘贴刚刚复制好的命令,执行后即可获取到结果的JSON数据
之后可以在这个网站中粘贴刚才的curl请求,由工具自动生成对应的Go HTTP请求代码即可。
func main() {
// create HTTP client
client := &http.Client{}
// set string to stream(data maybe huge)
var data = strings.NewReader(`{"trans_type":"en2zh","source":"hello"}`)
// create POST request
// use read-only stream data in order to support streaming transport
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
log.Fatal(err)
}
// set headers
req.Header.Set("authority", "api.interpreter.caiyunai.com")
...
// send request
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
// resp.Body is stream, it need to be closed manually
// defer means it will be excute after function finished
defer resp.Body.Close()
// read response
// use io.Readall() to read resp.Body(stream data)
bodyText, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s\n", bodyText)
}
生成request body
在Go中生成JSON的常用方法是利用结构体的Marshal
方法,而这个结构体和需要生成的JSON结构是一一对应的。
根据抓包中的结果,request body
中需要三个字段:trans_type
, source
, user_id
不同于工具生成的代码中的字符串,使用 json.Marshal()
生成的是字节数组,所以data需要从string.NewReader()
生成改为bytes.NewReader()
type DictRequest struct {
TransType string `json:"trans_type"`
Source string `json:"source"`
UserID string `json:"user_id"`
}
func main() {
...
request := DictRequest{TransType:"en2zh", "source":"hello"}
buf, err := json.Marshal(request)
if err != nil {
log.Fatal(err)
}
var data = bytes.NewReader(buf)
...
}
解析response body (JSON数据)
使用前面提到的快速生成JSON对应结构体工具,将获取到的数据生成对应的结构体;然后使用json.Unmarshal()
将body反序列化到结构体中。
打印结果
最后根据我们需要的数据(Dictionary.Explanations
),在控制台中打印出来即可。
程序优化
把代码主体改为 query()
函数,查询的单词作为参数传入。在 main()
函数中判断命令和参数的个数,如果不符合要求就打印错误信息并退出程序。
SOCKS5 代理
socks5为明文传输的代理协议,让授权的用户通过单个端口访问内部的所有资源
许多爬虫的代理IP池的很多代理的协议就是socks5
具体的实现过程:
- TCP echo server
- auth
- 请求阶段
- relay
测试代理服务器:curl --sock5 服务器IP -v 访问地址
协议原理
浏览器和代理建立TCP连接,代理再和真正的服务器建立TCP连接。整个过程分为四个阶段:
- 握手:浏览器向代理发送请求,包含协议版本号和支持的认证类型,代理会选择其中一种认证方式返回给浏览器。如果返回值为00,则无需认证;返回其他类型,就开始认证流程
- 认证:浏览器给代理发送数据(version,固定为5;methods,认证的方法数目;每个method的编码,0代表无需认证,2代表用户名密码认证)
- 请求:认证通过后,浏览器再次向代理发起请求,包含版本号和请求的类型(一般主要是connection请求,代表代理要和某个域名/IP建立TCP连接)。代理收到响应之后,会和服务器建立连接,然后返回一个响应
- relay:浏览器发送“正常发送”请求,代理收到后,会直接把请求转换到服务器上。如果服务器返回响应,就会把请求转发到浏览器。实际上,代理并不关心流量的细节(可以是HTTP,也可以是其他任何的TCP请求)
TCP echo server
server的逻辑:发送什么就回复什么
- 使用net.Listen()监听某个端口,将返回的server放在死循环中,每次去accepct一个请求,成功就返回一个连接。之后就在process函数中处理这个连接。
- process函数中,使用bufio.NewReader()创建带缓冲的只读流(可以减少system call的次数,而且很多工具函数也是使用带缓冲的流读取数据),再使用ReadByte()函数读取单个字节,把这个字节写进连接中。
func main() {
server, err := net.Listen("tcp", "127.0.0.1:1080")
if err != nil {
panic(err)
}
for {
client, err := server.Accept()
if err != nil {
log.Printf("accept failed %v", err)
continue
}
go process(client)
}
}
func process(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
for {
b, err := reader.ReadByte()
if err != nil {
break
}
_, err = conn.Write([]byte{b})
if err != nil {
break
}
}
}
测试:运行命令
nc 127.0.0.1 1080
,输入任意字符串,观察服务器是否返回数据
auth
实现一个空的auth
函数,在process
函数内部调用
- 使用
ReadBytes()
读取版本号,再读取method size(一个字节),创建一个长度相同的slice,使用io.ReadFull()
把它填充进去。 - 返回response,包含两个字段:version和method,也就是选中的鉴传方式。当前并不实现鉴传,所以返回00
const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04
func process(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
err := auth(reader, conn)
if err != nil {
log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
return
}
log.Println("auth success")
}
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
ver, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read ver failed:%w", err)
}
if ver != socks5Ver {
return fmt.Errorf("not supported ver:%v", ver)
}
methodSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read methodSize failed:%w", err)
}
method := make([]byte, methodSize)
_, err = io.ReadFull(reader, method)
if err != nil {
return fmt.Errorf("read method failed:%w", err)
}
log.Println("ver", ver, "method", method)
_, err = conn.Write([]byte{socks5Ver, 0x00})
if err != nil {
return fmt.Errorf("write failed:%w", err)
}
return nil
}
测试:使用
curl
进行连接,打印出version和method即可(失败是正常的,因为并未完成协议实现)
请求
读取携带URL/IP并打印,实现connect()函数,同样在process调用
浏览器发送请求的具体内容:
- version (版本号,固定为5)
- command (代表请求的类型,项目中只实现connection请求,也就是代理建立新的TCP连接。值为1)
- RSV (保留字段)
- atype (目标地址类型,可能为IPv4/IPv6/域名)
- addr (根据atype类型而不同)
- port (端口号,两个字节)
根据上面的内容,connect()
函数的实现:
- 前四个字段长度为四个字节,使用长度为4的buffer读取,再判断具体的值。
- atyp如果为IPv4,再次复用上面的buffer读取IP地址(IPv4地址长度为4个字节),最后保存至addr变量中;如果为host(域名),则重新创建一个长度相同的buffer并填充进去,最后转成字符串,保存在addr变量中;IPv6因为目前使用较少,项目中暂时不做支持。
- 最后用两个字节的buffer读取端口,按协议规定进行转换(大端字节序)。此处可以再次复用之前的buffer,将端口填充进去,并使用临时slice读取前两字节。
- 返回数据(共10字节):version(值为5);返回类型(请求成功则返回0);保留字段(0);atype(此处填1);剩下字段暂时不使用,直接返回0
func connect(reader *bufio.Reader, conn net.Conn) (err error) {
buf := make([]byte, 4)
_, err = io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read header failed:%w", err)
}
ver, cmd, atyp := buf[0], buf[1], buf[3]
if ver != socks5Ver {
return fmt.Errorf("not supported ver: %v", ver)
}
if cmd != cmdBind {
return fmt.Errorf("not supported cmd: %v", ver)
}
addr := ""
switch atyp {
case atypIPV4:
_, err := io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read atyp failed:%w", err)
}
addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
case atypeHOST:
hostSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read hostSize failed:%w", err)
}
host := make([]byte, hostSize)
_, err = io.ReadFull(reader, host)
if err != nil {
return fmt.Errorf("read host failed:%w", err)
}
addr = string(host)
case atypeIPV6:
return errors.New("IPv6: no supported yet")
default:
return errors.New("invaild atyp")
}
_, err = io.ReadFull(reader, buf[:2])
if err != nil {
return fmt.Errorf("read port failed:%w", err)
}
port := binary.BigEndian.Uint16(buf[:2])
log.Println("dial", addr, port)
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
return fmt.Errorf("write failed: %w", err)
}
return nil
}
测试:运行
curl
,打印出访问的地址和端口即可
relay
使用net.dial()建立TCP连接,最后使用defer关闭连接。之后建立浏览器和服务器的双向数据转发。
io.copy()
可以实现一个单向数据转发;要实现双向转发,则需要两个goroutinue
func connection(reader *bufio.Reader, conn net.Conn) (err error) {
...
port := binary.BigEndian.Uint16(buf[:2])
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
if err != nil {
return fmt.Errorf("dial dst failed:%w", err)
}
defer dest.Close()
log.Println("dial", addr, port)
...
}
现在有一个问题,connect()
函数会立刻返回,返回的时候连接就被关闭了。需要等待任意一个方向copy出错的时候,再返回connect()
函数。
这里可以使用到标准库里面的一个context
机制,用context.WithCancel()
创建一个context
在最后等待ctx.Done()
,只要cancel
被调用,ctx.Done()
就会立刻返回。然后在上面的两个goroutinue
里面调用一次cancel
即可。
func connection(reader *bufio.Reader, conn net.Conn) (err error) {
...
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
return fmt.Errorf("write failed: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_, _ = io.Copy(dest, reader)
cancel()
}()
go func() {
_, _ = io.Copy(conn, dest)
cancel()
}()
<-ctx.Done()
return nil
...
}
测试1:运行
curl
,此时应能成功响应
测试2:在浏览器内安装插件SwitchOmega
,新建情景模式,代理服务器选择socks5
,端口1080。此时应该还能正常的访问网站,代理这边可以显示出浏览器版本的域名和端口
课后作业
- 修改猜谜游戏的最终代码,使用fmt.Scanf简化代码实现
- 修改命令行词典的最终代码,增加另一种翻译引擎支持
- 在2的基础上,实现并行请求两个翻译引擎以提高响应速度