这是我参与「第五届青训营 」伴学笔记创作活动的第 1 天,今天学习的内容是Go的环境配置,基础的语法学习以及三个实战项目,整理学习笔记如下。
1 安装及环境配置
1.1 安装Go语言
在go官网下载页进行下载安装
一路next即可
1.2 配置Go代理
根据Goproxy配置Go代理,以下两个方法都可
1.2.1 终端配置
go env -w GOPROXY=https://goproxy.cn,direct
1.2.2 环境变量直接配置
1.3 VsCode配置Go
打开Vscode,安装go插件
打开任意go文件,安装go调试工具
配置完成
1.3 克隆课程代码
使用git克隆课程代码
git clone https://github.com/wangkechun/go-by-example
2 Go语言上手 - 基础语法
参考学习资料:Go语言圣经(中文版),青训营课程1-走进 Go 语言基础语言
2.1 走进Go语言基础语言
2.1.1 Go语言简介
Go特点: 高性能(类C++)、高并发,语法简单(C简化)、学习曲线平缓,丰富的标准库,完善的工具链(编译、代码格式化、包管理等),静态链接,快速编译,跨平台(多种常见平台,无需交叉编译环境),垃圾回收(类java)
2.1.2 Go语言开发环境配置
见课前准备(VSCode)
2.1.3 基础语法 - Hello World
直接运行(使用 run )
go run example/01-hello/main.go
直接输出运行结果
编译运行(使用 build + ./ )
go build example/01-hello/main.go
./main
静态编译成 .exe 文件,可直接运行,输出运行结果
2.1.4 基础语法 - 变量
常见变量类型包括字符串、整数、布尔型、浮点型
声明方法1: var 变量名 类型(可选) = 初始值
Tips: Go中的字符串类型 string 为内置类型,类C++可使用 + 等方法。
声明方法2: 变量名 := 初始值
常量声明: const 常量名 = 初始值
Tips: Go中常量没有确定类型,根据上下文确定
运行样例
go run example/02-var/main.go
输出字符串 initial ,整数 1,2 ,布尔值直接输出 true ,使用 + 进行的字符串拼接 foo ,科学计数法表示输出, math 库中的函数 Sin 使用
2.1.5 基础语法 - if else
Tips: 条件不需要括号 () ,执行语句必须带括号 {}
运行样例
go run example/04-if/main.go
分别进行的判断是:奇数偶数判断 %2 ;8整除4;数字正负与位数判断(分别与0,10相比较)
2.1.6 基础语法 - 循环
Tips: Go中只有 for 循环(没有 while / do-while 语句)
运行样例
go run example/03-for/main.go
运行的样例分别为死循环(用 break 跳出);常见的赋值递增循环;continue语句跳过后面部分;可任意省略的参数
2.1.7 基础语法 - switch
switch不需要括号,除了与C/C++中类似的结果分支,也可以直接在分支case中进行条件判断等
Tips: Go中switch的分支默认不需要加 break ,不会像C++中继续执行。
运行样例
go run example/05-switch/main.go
2.1.8 基础语法 - 数组
长度固定,声明格式为 var 变量名 [数组长度]变量类型 {初始值1,2,...}(可选) 或 变量名 := [数组长度]变量类型 {初始值1,2,...}(可选)
Tips: Go中更常用切片slice进行数据存储(可变长度)
运行样例
go run example/06-array/main.go
2.1.9 基础语法 - 切片
可变长度数组,一般使用 meke 进行声明,声明格式为 切片名 := make([]变量类型,初始长度) 或 切片名 := []变量类型{初始值1,2,...}
Tips:
- Go中可使用
append向切片中添加元素,格式为切片名 = append(切片名,元素),注意需要将其赋值回变量(其底层实现为记录当前的长度、容量与数组指针,容量不够时类似vector发生扩容) - 使用
cpoy(复制后切片名, 被复制切片名)进行切片的复制 - 切片操作类似py
运行样例
go run example/07-slice/main.go
2.1.10 基础语法 - map
类似于py中的字典,一般使用 make 进行创建,创建格式为 名称 := make(map[索引变量类型]存放变量类型) ,或 名称 := map[索引变量类型]存放变量类型{索引1:初始值1, 索引2:初始值2, ...}
删除匹配关系使用 delete ,格式为 delete(名称, 索引)
Tips:
- map中存放数据无序;
- 对于没有定义的索引,map会返回0,可以使用第二个变量获取该索引是否有相应的匹配,返回布尔值
运行样例
go run example/08-map/main.go
在获取 unknow 索引时,返回值0,且匹配状态为 false
2.1.11 基础语法 - range
类似于py,一般用在循环遍历中,遍历数组/切片返回值为索引以及该索引对应的值;遍历map时返回值为key以及对应的value。
运行样例
go run example/09-range/main.go
2.1.12 基础语法 - 函数
Go中的函数定义格式为 func 函数名 (传入参数...) (可选:返回值...)
返回结果包括函数结果以及错误信息。
运行样例
go run example/10-func/main.go
2.1.13 基础语法 - 指针
Go的指针支持的操作相比于C/C++较有限,常用的如支持参数根据地址修改参数值。
运行样例
go run example/11-point/main.go
2.1.14 基础语法 - 结构体
定义格式 type 结构体名称 struct {参数...} ,声明可类似于map或变量,支持缺省赋值,使用 . 进行结构体参数访问。
运行样例
go run example/12-struct/main.go
两个 false 结果表示的是传入拷贝与传入指针与结构体中值比较的结果(使用指针传入可减少开销)
2.1.15 基础语法 - 结构体方法
实现结果类似于C++类中的类成员函数,具体方法是将结构体参数放在 func 定义后。这样可以使用 . 进行方法的调用。
Tips: 传入结构体带指针可修改结构体参数值。
运行样例
go run example/13-struct-method/main.go
2.1.16 基础语法 - 错误处理
Go中的错误处理需要引入 errors 库,在需要处理的函数定义 error 类型的返回值,如果正常则返回 nil 值(空值,类似NULL),否则可以使用 error.new(错误消息) 返回错误的消息。
运行样例
go run example/14-error/main.go
2.1.17 基础语法 - 字符串操作
引入 strings 库可使用相应的字符串方法(使用 string 变量及简单的拼接操作等不需要该库),使用 len(字符串) 方法获取字符串长度。其余常用函数如下:
Tips: 中文字符在字符串中占3位
运行样例
go run example/15-string/main.go
2.1.18 基础语法 - 字符串格式化/格式化输出
使用 fmt 库进行字符串格式化输出。
-
fmt.Println():打印多个变量且换行(类似于py中的print) -
fmt.Printf():打印变量,格式类似于C,但不需要进行变量类型的区分,统一使用%v代替(可使用%+v以及%#v更进一步详细输出变量)但打印浮点数时与C类似(
%整体长度(可加0补0,可选).小数点后保留位数f),但Go中变量与py类似,只有浮点数不需区分单双精度浮点
运行样例
go run example/16-fmt/main.go
2.1.19 基础语法 - JSON处理
导入 encoding/json 包进行相关JSON处理函数调用
json.Marshal(v any):将结构体编码成JSON字符串(要求结构体变量名首字母大写,第二个参数返回错误信息)json.MarshalIndent(v any, prefix string, indent string):将结构体格式化编码成JSON字符串(第二个参数为前缀,第三个参数为缩进)json.Unmarshal(data []byte, v any):将JSON字符串解码到相应的数据结构
运行样例
go run example/17-json/main.go
2.1.20 基础语法 - 时间处理
导入 time 包进行时间处理。常用方法如下:
time.Now():获取当前时间(yyyy-mm-dd hh:mm:ss + nsec nanoseconds,带时区);time.Date(year int, month time.Month, day int, hour int, min int, sec int, nsec int, loc *time.Location):构造一个时间戳;.Year()/.Month()/.Day()/.Hour()/.Minute()/.Second():获取时间戳的各个部分(返回整数);.Format("2006-01-02 15:04:05"):格式化时间戳;.Sub(time.Time)/...:对两个时间戳进行加减等操作;time.Parse("2006-01-02 15:04:05", string):解析时间;.Unix(time.Time):获取一个int64的时间戳
运行样例
go run example/18-time/main.go
2.1.21 基础语法 - 数字解析
导入 strconv 包进行字符串与数字的转换。常用方法如下:
strconv.ParseFloat(s string, bitSize int):将传入的字符串转为bitSize位浮点数(32/64);strconv.ParseInt(s string, base int, bitSize int):将传入的字符串以base进制转为bitSize位精度整数(当字符串前由0x/0o等标志位时base也可传入0自动推测);strconv.Atoi(s string):将传入的字符串转为整数(十进制)strconv.Itoa(i int):将传入的整数转为字符串(十进制)
Tips: 输入不合法时返回错误
运行样例
go run example/19-strconv/main.go
2.1.22 基础语法 - 进程信息
导入 os , os/exec 包获取进程信息。
2.2 Go语言的实战案例
2.2.1 猜谜游戏
目标及程序逻辑: 使用Golang构建一个猜数字游戏。在这个游戏里面,程序先会生成一个介于1到100之间的随机整数,然后提示玩家进行猜测。玩家每次输入一个数字,程序会告诉玩家这个猜测的值是高于还是低于那个秘密的随机数,并且让玩家再次猜测。如果猜对了,就告诉玩家胜利并且退出程序。
编写获取随机数的程序
运行代码
go run guessing-game/v1/main.go
发现此时的随机数并不随机,因为没有设置随机数种子,随机数种子相同输出的随机数也相同。
用当前时间作为随机数种子,修改代码:
运行如下:
go run guessing-game/v2/main.go
此时可获得随机数。
使用 bufio 进行数字输入的读取与字符转换
import (
...
"bufio"
"os"
"strconv"
"strings"
)
...
func main() {
...
fmt.Println("Please input your guess")
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println("An error occured while reading input. Please try again", err)
return
}
input = strings.Trim(input, "\r\n")
guess, err := strconv.Atoi(input)
if err != nil {
fmt.Println("Invalid input. Please enter an integer value")
return
}
fmt.Println("You guess is", guess)
}
运行如下:
go run guessing-game/v3/main.go
实现判断逻辑
使用if else等进行逻辑判断
func main() {
...
if guess > secretNumber {
fmt.Println("Your guess is bigger than the secret number. Please try again")
} else if guess < secretNumber {
fmt.Println("Your guess is smaller than the secret number. Please try again")
} else {
fmt.Println("Correct, you Legend!")
}
}
运行如下:
go run guessing-game/v4/main.go
修改实现游戏循环
使用一个 for 循环重复进行判断,直到正确退出。最终代码如下:
package main
import (
"bufio"
"fmt"
"math/rand"
"os"
"strconv"
"strings"
"time"
)
func main() {
maxNum := 100
rand.Seed(time.Now().UnixNano())
secretNumber := rand.Intn(maxNum)
// fmt.Println("The secret number is ", secretNumber)
fmt.Println("Please input your guess")
reader := bufio.NewReader(os.Stdin)
for {
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println("An error occured while reading input. Please try again", err)
continue
}
input = strings.Trim(input, "\r\n")
guess, err := strconv.Atoi(input)
if err != nil {
fmt.Println("Invalid input. Please enter an integer value")
continue
}
fmt.Println("You guess is", guess)
if guess > secretNumber {
fmt.Println("Your guess is bigger than the secret number. Please try again")
} else if guess < secretNumber {
fmt.Println("Your guess is smaller than the secret number. Please try again")
} else {
fmt.Println("Correct, you Legend!")
break
}
}
}
运行如下:
go run guessing-game/v5/main.go
2.2.2 命令行词典
目标及程序逻辑: 使用Golang构建一个简单的命令行词典。用户可以在命令行里查询一个单词,我们通过调用第三方API查询到单词的翻译并打印单词的音标以及释义。在过程中我们会使用Go语言来发送HTTP请求、解析json,以及使用高效的代码生成工具。
检查测试API接口
打开在线翻译 - 彩云小译,右键-检查,查看翻译接口
复制cURL
打开代码转换网站curlconverter,选择go,粘贴cURL
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
"strings"
)
func main() {
client := &http.Client{}
var data = strings.NewReader(`{"trans_type":"en2zh","source":"translate"}`)
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
log.Fatal(err)
}
req.Header.Set("authority", "api.interpreter.caiyunai.com")
req.Header.Set("accept", "application/json, text/plain, */*")
req.Header.Set("accept-language", "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,id;q=0.6,ko;q=0.5")
req.Header.Set("app-name", "xy")
req.Header.Set("content-type", "application/json;charset=UTF-8")
req.Header.Set("origin", "https://fanyi.caiyunapp.com")
req.Header.Set("os-type", "web")
req.Header.Set("referer", "https://fanyi.caiyunapp.com/")
req.Header.Set("sec-ch-ua", `" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
req.Header.Set("sec-fetch-dest", "empty")
req.Header.Set("sec-fetch-mode", "cors")
req.Header.Set("sec-fetch-site", "cross-site")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36")
req.Header.Set("x-authorization", "token:qgemv4jr1y38jyq6vhvi")
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
bodyText, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s\n", bodyText)
}
将其保存为 my.go ,运行代码
go run simpledict/my.go
结果如下:
运行示例代码
go run simpledict/v1/main.go
结果如下:
生成与解析request body
我们将翻译的格式 TransType 以及需要翻译的文本 Source 和用户ID UserID 存储为结构体并转换为Json输入。
...
type DictRequest struct {
TransType string `json:"trans_type"`
Source string `json:"source"`
UserID string `json:"user_id"`
}
func main() {
client := &http.Client{}
request := DictRequest{TransType: "en2zh", Source: "good"}
buf, err := json.Marshal(request)
if err != nil {
log.Fatal(err)
}
var data = bytes.NewReader(buf)
...
}
使用JSON转Golang Struct网站将复杂的body转换成嵌套结构体
type DictResponse struct {
Rc int `json:"rc"`
Wiki struct {
KnownInLaguages int `json:"known_in_laguages"`
Description struct {
Source string `json:"source"`
Target interface{} `json:"target"`
} `json:"description"`
ID string `json:"id"`
Item struct {
Source string `json:"source"`
Target string `json:"target"`
} `json:"item"`
ImageURL string `json:"image_url"`
IsSubject string `json:"is_subject"`
Sitelink string `json:"sitelink"`
} `json:"wiki"`
Dictionary struct {
Prons struct {
EnUs string `json:"en-us"`
En string `json:"en"`
} `json:"prons"`
Explanations []string `json:"explanations"`
Synonym []string `json:"synonym"`
Antonym []string `json:"antonym"`
WqxExample [][]string `json:"wqx_example"`
Entry string `json:"entry"`
Type string `json:"type"`
Related []interface{} `json:"related"`
Source string `json:"source"`
} `json:"dictionary"`
}
转换并打印结构体
运行示例代码
go run simpledict/v3/main.go
结果如下:
加入状态检测以及结构体筛选
检测200的ok状态,并选取结构体中我们所需要的音标与解释进行打印
完善输入
修改输入为自定义单词
运行示例代码
go run simpledict/v4/main.go hello
结果如下:
2.2.3 Socks5代理
Socks5工作原理: 正常浏览器访一个网站,如果不经过代理服务器的话,就是先和对方的网站建立TCP连接,然后三次握手,握手完之后发起HTTP请求,然后服务返回HTTP响应。
如果设置代理服务器之后,流程会变得复杂一些。首先是浏览器和 socks5代理建立TCP连接,代理再和真正的服务器建立TCP连接。这里可以分成四个阶段,握手阶段、认证阶段、请求阶段、 relay阶段。
第一个阶段握手阶段,浏览器会向 socks5代理发送请求,包的内容包括一个协议的版本号,还有支持的认证的种类, socks5服务器会选中一个其支持的认证方式,返回给浏览器。如果返回的是00的话就代表不需要认证,返回其他类型的话会开始认证流程,由于我们要实现不加密的传输,因此暂时跳过第二阶段的认证流程。
第三个阶段是请求阶段,认证通过之后浏览器会 socks5服务器发起请求。主要信息包括版本号,请求的类型,一般主要是 connection请求,就代表代理服务器要和某个域名或者某个IP地址某个端口建立TCP连接。代理服务器收到响应之后,会真正和后端服务器建立连接,然后返回一个响应。
第四个阶段是relay阶段,此时浏览器会发送正常发送请求,然后代理服务器接收到请求之后,会直接把请求转换到真正的服务器上。然后如果真正的服务器以后返回响应的话,那么也会把请求转发到浏览器这边。然后实际上代理服务器并不关心流量的细节,可以是HTTP流量,也可以是其它TCP流量。
TCP echo server实现
运行示例代码
go run proxy/v1/main.go
打开另一终端,用 telnet 命令连接端口
telnet 127.0.0.1 1080 #Win
nc 127.0.0.1 1080 #MacOS
此时输入都会被echo打印回显
Tips: 如果未安装Telnet服务则会报错:
只需要安装telnet客户端。打开“打开或关闭Windows功能”,勾选Telnet客户端,下载安装即可。
实现认证阶段auth函数
将之前的回显死循环修改为auth函数,进行认证
auth函数实现如下
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
// +----+----------+----------+
// |VER | NMETHODS | METHODS |
// +----+----------+----------+
// | 1 | 1 | 1 to 255 |
// +----+----------+----------+
// VER: 协议版本,socks5为0x05
// NMETHODS: 支持认证的方法数量
// METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
// X’00’ NO AUTHENTICATION REQUIRED
// X’02’ USERNAME/PASSWORD
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)
// +----+--------+
// |VER | METHOD |
// +----+--------+
// | 1 | 1 |
// +----+--------+
_, err = conn.Write([]byte{socks5Ver, 0x00})
if err != nil {
return fmt.Errorf("write failed:%w", err)
}
return nil
}
运行示例代码
go run proxy/v2/main.go
打开另一终端,执行以下命令:
curl --socks5 127.0.0.1:1080 -v http://www.qq.com
可以看到当前auth是成功运行了的
实现请求阶段connect函数
类似于 auth 函数,我们编写一个 connect 函数,在执行完 auth 函数后调用进行连接。接受解析包格式:
返回包格式:
其中我们定义一个4个字节的缓冲区,使用 io.ReadFull 将其填满来方便我们对字段进行读取。
connect 函数完整实现如下:
func connect(reader *bufio.Reader, conn net.Conn) (err error) {
// +----+-----+-------+------+----------+----------+
// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
// +----+-----+-------+------+----------+----------+
// | 1 | 1 | X'00' | 1 | Variable | 2 |
// +----+-----+-------+------+----------+----------+
// VER 版本号,socks5的值为0x05
// CMD 0x01表示CONNECT请求
// RSV 保留字段,值为0x00
// ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。
// 0x01表示IPv4地址,DST.ADDR为4个字节
// 0x03表示域名,DST.ADDR是一个可变长度的域名
// DST.ADDR 一个可变长度的值
// DST.PORT 目标端口,固定2个字节
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("invalid 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)
// +----+-----+-------+------+----------+----------+
// |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
// +----+-----+-------+------+----------+----------+
// | 1 | 1 | X'00' | 1 | Variable | 2 |
// +----+-----+-------+------+----------+----------+
// VER socks版本,这里为0x05
// REP Relay field,内容取值如下 X’00’ succeeded
// RSV 保留字段
// ATYPE 地址类型
// BND.ADDR 服务绑定的地址
// BND.PORT 服务绑定的端口DST.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
}
运行示例代码
go run proxy/v3/main.go
打开另一终端,执行以下命令:
curl --socks5 127.0.0.1:1080 -v http://www.qq.com
可以看到我们正确打印出需要访问的IP与端口,实现正确
实现relay阶段
接着我们需要实现代理与服务器建立连接。使用 net.Dial 函数建立连接,并创建两个Goroutine(类似于子线程,但开销较小)执行 io.copy 函数双向转发数据。同时我们使用 context.WithCancel 函数避免其直接执行到返回 nil ,当两边任意一方出现异常退出时, Copy 函数退出并继续往下执行到 cancel() 函数,此时才会继续执行返回 nil 。
运行示例代码
go run proxy/v4/main.go
打开另一终端,执行以下命令:
curl --socks5 127.0.0.1:1080 -v http://www.qq.com
可以看到此时成功建立连接。
配置浏览器代理
使用SwitchyOmega进行浏览器代理配置
按照以下配置
配置完成后使用配置,同时在本地运行我们实现的Socks5
可以看到,我们的课程地址与端口都经过该端口进行了转发。