青训营Lesson-1 | 青训营笔记
这是我参与「第三届青训营 -后端场」笔记创作活动的的第1篇笔记
「Go 语言上手-基础语言」
1.Go语言受众公司
2.基本语法
-
Golang只有for循环一种循环.
-
可以在 switch 后面不加任何的变量,然后在 case 里面写条件分支。这样代码相比用多个 if else 代码逻辑会更为清晰。
-
在真实业务代码里面,我们很少直接使用数组,因为它长度是固定的,我们用的更多的是切片。
-
slice 拥有像 python 一样的切片操作,比如s[2:5]代表取出第二个到第五个位置的元素,不包括第五个元素。不过不同于python,这里不支持负数索引.
-
golang的map是完全无序的,遍历的时候不会按照字母顺序,也不会按照插入顺序输出,而是随机顺序。
-
对于一个 slice 或者一个 map 的话,我们可以用 range 来快速遍历,这样代码能够更加简洁。 range 遍历的时候,对于数组会返回两个值,第一个是索引,第二个是对应位置的值。如果我们不需要索引的话,我们可以用下划线来忽略.
-
Golang 里面的函数原生支持返回多个值。在实际的业务逻辑代码里面几乎所有的函数都返回两个值,第一个是真正的返回结果,第二个值是一个错误信息。
-
go里面也支持指针。当然,相比 C 和 C++ 里面的指针,支持的操作很有限。指针的一个主要用途就是对于传入参数进行修改。
-
同样的结构体我们也能支持指针,这样能够实现对于结构体的修改,也可以在某些情况下避免一些大结构体的拷贝开销。
-
在实现结构体的方法的时候有两种写法,一种是带指针,一种是不带指针。这个它们的区别的话是说如果你带指针的话,那你那么你就可以对这个结构体去做修改。如果你不带指针的话,那你实际上操作的是一个拷贝,你就无法对结构体进行修改。
-
在函数里面,我们可以在那个函数的返回值类型里面,后面加一个 error, 就代表这个函数可能会返回错误。自定义的error可以用New方法来定义。
errors.New("not found") -
string字符串方法
contains 判断一个字符串里面是否有包含另一个字符串 , count 字符串计数, index 查找某个字符串的位置。 join 连接多个字符串 repeat 重复多个字符串 replace 替换字符串。
func main() { a := "hello" fmt.Println(strings.Contains(a, "ll")) // true fmt.Println(strings.Count(a, "l")) // 2 fmt.Println(strings.HasPrefix(a, "he")) // true fmt.Println(strings.HasSuffix(a, "llo")) // true fmt.Println(strings.Index(a, "ll")) // 2 fmt.Println(strings.Join([]string{"he", "llo"}, "-")) // he-llo fmt.Println(strings.Repeat(a, 2)) // hellohello fmt.Println(strings.Replace(a, "e", "E", -1)) // hEllo fmt.Println(strings.Split("a-b-c", "-")) // [a b c] fmt.Println(strings.ToLower(a)) // hello fmt.Println(strings.ToUpper(a)) // HELLO fmt.Println(len(a)) // 5 b := "你好" fmt.Println(len(b)) // 6 } -
fmt.Println用 %v 来打印任意类型的变量,而不需要区分数字字符串。你也可以用 %+v 打印详细结果,%#v 则更详细。%.2f打印包含两个小数的浮点数。
-
对于一个已有的结构体,只要保证每个字段的第一个字母是大写,也就是是公开字段。那么这个结构体就能用 JSON.marshaler 去序列化,变成一个 JSON 的字符串。
序列化之后的字符串也能够用 JSON.unmarshaler 去反序列化到一个空的变量里面。 这样默认序列化出来的字符串的话,它的风格是大写字母开头,而不是下划线。我们可以在后面用 json tag 等语法来去修改输出 JSON 结果里面的字段名。
type userInfo struct { Name string Age int `json:"age"` //去序列化之后Age就不是大写 而是age Hobby []string } -
time包的基本使用
time.now() 来获取当前时间,time.date 去构造一个带时区的时间,
通过构造完的时间来获取这个时间点的年月日小时分钟秒,然后也能用点 sub 去对两个时间进行减法,得到一个时间段。
时间段又可以去得到它有多少小时,多少分钟、多少秒。 在和某些系统交互的时候,我们经常会用到时间戳,可以用 .UNIX 来获取时间戳。 time.format time.parse ["2006-01-02 15:04:05"]规范时间格式
func main() { now := time.Now() fmt.Println(now) // 2022-03-27 18:04:59.433297 +0800 CST m=+0.000087933 t := time.Date(2022, 3, 27, 1, 25, 36, 0, time.UTC) t2 := time.Date(2022, 3, 27, 2, 30, 36, 0, time.UTC) fmt.Println(t) // 2022-03-27 01:25:36 +0000 UTC fmt.Println(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute()) // 2022 March 27 1 25 fmt.Println(t.Format("2006-01-02 15:04:05")) // 2022-03-27 01:25:36 diff := t2.Sub(t) fmt.Println(diff) // 1h5m0s fmt.Println(diff.Minutes(), diff.Seconds()) // 65 3900 t3, err := time.Parse("2006-01-02 15:04:05", "2022-03-27 01:25:36") if err != nil { panic(err) } fmt.Println(t3 == t) // true fmt.Println(now.Unix()) // 1648738080 } -
字符串和数字之间的转换都在 strconv 这个包下 我们可以用 parseInt 或者 parseFloat 来解析一个字符串。 parseint 参数 我们可以用 Atoi 把一个十进制字符串转成数字。可以用 itoA 把数字转成字符串。 如果输入不合法,那么这些函数都会返回error
func main() { f, _ := strconv.ParseFloat("1.234", 64) fmt.Println(f) // 1.234 n, _ := strconv.ParseInt("111", 10, 64) // 10进制,返回64进制 fmt.Println(n) // 111 n, _ = strconv.ParseInt("0x1000", 0, 64) // 0表示自动推测进制,返回64进制 fmt.Println(n) // 4096 n2, _ := strconv.Atoi("123") fmt.Println(n2) // 123 n2, err := strconv.Atoi("AAA") fmt.Println(n2, err) // 0 strconv.Atoi: parsing "AAA": invalid syntax } -
在 go 里面,我们能够用 os.argv 来得到程序执行的时候的指定的命令行参数。 比如我们编译的一个 二进制文件,command。 后面接 abcd 来启动,输出就是 os.argv 会是一个长度为 5 的 slice ,第一个成员代表二进制自身的名字。 我们可以用 so.getenv来读取环境变量。 exec.Command来快速启动子进程然后获取输入输出。
func main() { // go run example/20-env/main.go a b c d fmt.Println(os.Args) // [/var/folders/8p/n34xxfnx38dg8bv_x8l62t_m0000gn/T/go-build3406981276/b001/exe/main a b c d] fmt.Println(os.Getenv("PATH")) // /usr/local/go/bin... fmt.Println(os.Setenv("AA", "BB")) buf, err := exec.Command("grep", "127.0.0.1", "/etc/hosts").CombinedOutput() if err != nil { panic(err) } fmt.Println(string(buf)) // 127.0.0.1 localhost }
3.实战部分
3.1猜谜游戏
-
使用rand需要先布置seed
func main() { maxNum := 100 rand.Seed(time.Now().UnixNano()) secretNumber := rand.Intn(maxNum) fmt.Println("The secret number is ", secretNumber) } -
读取用户输入
把数据读到一个bufio中,strings.TrimSuffix()去除换行符,atoi转换为数字
可用scanf实现读取输入
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) input, err := reader.ReadString('\n') if err != nil { fmt.Println("An error occured while reading input. Please try again", err) return } input = strings.TrimSuffix(input, "\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) }
3.2在线词典
- 调用彩云app翻译api,找到HTTP 的 post 的请求 fanyi.caiyunapp.com/#/
-
dict Copy as curl 之后采用curlconverter.com/#go 在线转化为go码(有几个header比较复杂,生成代码有转义导致的编译错误,删掉这几行即可。)
打印出json格式的结果
func main() { client := &http.Client{} // timeout参数可设定超时,此处不限制 var data = strings.NewReader(`{"trans_type":"en2zh","source":"good"}`) //data是由string转为流 req, err := http.NewRequest("POST", "<https://api.interpreter.caiyunai.com/v1/dict>", data) // method url data(是个bufio,避免body很大会占用太大内存) if err != nil { log.Fatal(err) } //请求头 req.Header.Set("Connection", "keep-alive") req.Header.Set("DNT", "1") req.Header.Set("os-version", "") req.Header.Set("sec-ch-ua-mobile", "?0") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36") req.Header.Set("app-name", "xy") req.Header.Set("Content-Type", "application/json;charset=UTF-8") req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("device-id", "") req.Header.Set("os-type", "web") req.Header.Set("X-Authorization", "token:qgemv4jr1y38jyq6vhvi") req.Header.Set("Origin", "<https://fanyi.caiyunapp.com>") req.Header.Set("Sec-Fetch-Site", "cross-site") req.Header.Set("Sec-Fetch-Mode", "cors") req.Header.Set("Sec-Fetch-Dest", "empty") req.Header.Set("Referer", "<https://fanyi.caiyunapp.com/>") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") req.Header.Set("Cookie", "_ym_uid=16456948721020430059; _ym_d=1645694872") resp, err := client.Do(req) //发送请求 if err != nil { log.Fatal(err) } defer resp.Body.Close() // body也是一个流,防止资源泄露需要手动关闭 bodyText, err := ioutil.ReadAll(resp.Body) //读到内存里作为byte数组 if err != nil { log.Fatal(err) } fmt.Printf("%s\n", bodyText) } -
构造结构体字段(输出一样是json格式的结果)
type DictRequest struct { TransType string `json:"trans_type"` Source string `json:"source"` UserID string `json:"user_id"` } client := &http.Client{} request := DictRequest{TransType: "en2zh", Source: "good"} buf, err := json.Marshal(request) //序列化变为byte数组 if err != nil { log.Fatal(err) } var data = bytes.NewReader(buf) // byte数组转化为bytes.NewReader -
解析response body,常用方式是写一个结构体对应response,然后反序列化。由于返回的结果很复杂,运用oktools.net/json2go来解析(转化-嵌套)
结果作为一个DictResponse结构体,然后反序列化
```
var dictResponse DictResponse
err = json.Unmarshal(bodyText, &dictResponse) //&写入
if err != nil {
log.Fatal(err)
}
fmt.Printf("%#v\n", dictResponse) //%#输出具体结果
```
-
完善代码,读取输入
if resp.StatusCode != 200 { // 防御性编程,打印错误代码(403/404)状态码为200才是正确的 log.Fatal("bad StatusCode:", resp.StatusCode, "body", string(bodyText)) } var dictResponse DictResponse err = json.Unmarshal(bodyText, &dictResponse) if err != nil { log.Fatal(err) } // 选择所需要的信息 fmt.Println(word, "UK:", dictResponse.Dictionary.Prons.En, "US:", dictResponse.Dictionary.Prons.EnUs) for _, item := range dictResponse.Dictionary.Explanations { fmt.Println(item) } } func main() { if len(os.Args) != 2 { //判断是否为单词 fmt.Fprintf(os.Stderr, `usage: simpleDict WORD example: simpleDict hello `) os.Exit(1) } word := os.Args[1] query(word) }
3.3 socks5代理服务器
-
不经过代理服务器的步骤:先和网站进行tcp连接,三次握手之后正常发起http请求,服务器返回http响应
-
经过代理服务器的步骤:
首先是浏览器和 socks5 代理建立 TCP 连接,代理再和真正的服务器建立 TCP 连接。
这里可以分成四个阶段,握手阶段、认证阶段、请求阶段、 relay 阶段。 第一个握手阶段,浏览器会向 socks5 代理发送请求(报文),内容包括一个协议的版本号,还有支持的认证的种类(密码或者不需要认证),socks5 代理服务器会选中一个认证方式,返回给浏览器。如果返回的是 00 的话就代表不需要认证,返回其他类型的话会开始认证流程,这里我们就不对认证流程进行概述了,因为此处实现不加密的代理。 第三个阶段是请求阶段,认证通过之后浏览器会 socks5 服务器发起请求。主要信息包括 版本号,请求的类型,一般主要是 connection 请求,就代表代理服务器要和某个域名或者某个 IP 地址某个端口建立 TCP 连接。代理服务器收到响应之后,会真正和后端服务器建立TCP连接,然后返回一个报文响应。 第四个阶段是 relay 阶段。此时浏览器会发送正常发送请求,然后代理服务器接收到请求之后,会直接把请求转换到真正的服务器上。然后如果真正的服务器以后返回响应的话,那么也会把请求转发到浏览器这边。然后实际上代理服务器并不关心流量的细节,可以是 HTTP流量,也可以是其它 TCP 流量。
-
第一步,我们先在 go 里面写一个简单的 TCP echo server。为了方便测试, server 的工作逻辑很简单,你给他发送啥,他就回复啥。
bufio.NewReader 来创建一个 带缓冲的只读流,这个在前面的猜谜游戏里面也有用到, 带缓冲的流的作用是,可以减少底层系统调用的次数,比如这里为了方便是一个字节一个字节的读取,但是底层可能合并成几次大的读取操作。并且带缓冲的流会有更多的一些工具函数用来读取数据。 我们可以简单地调用那个 readbyte 函数来读取单个字节。再把这一个字节写进去连接。
func main() { server, err := net.Listen("tcp", "127.0.0.1:1080") //监听一个端口返回server if err != nil { panic(err) } for { client, err := server.Accept() // 接受一个请求,成功则返回连接client,之后在process中处理 if err != nil { log.Printf("Accept failed %v", err) continue } go process(client) //启动goroutinue实现并发 } } 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}) // 把这个字节写入,[]byte类型转化 if err != nil { break } } } -
接下来我们是要开始实现协议的第一步,认证阶段
我们实现一个空的 auth 函数,在 process 函数里面调用,再来编写 auth 函数的代码。 我们回忆一下认证阶段的逻辑,首先第一步的话,浏览器会给代理服务器发送一个报文,然后这个报文有三个字段, 第一个字段version也就是协议版本号,固定是 5。
第二个字段methods,认证的方法数目。
第三个字段每个method的编码,0代表不需要认证,2代表用户名密码认证。 我们先用 Readbyte来把版本号读出来,然后如果版本号不是 socket 5 的话直接返回报错,接下来我们再读取 methodsize ,也是一个字节。然后我们需要我们去 make 一个相应长度的一个 slice缓冲区 ,用 io.ReadFull 把它填充进去。 写到这里,我们把获取到的版本号和认证方式打印一下。 此时,代理服务器还需要返回一个response, 返回包包括 两个字段,一个是 version 一个是 method,也就是我们选中的鉴传方式,我们当前只准备实现不需要鉴传的方式,也就是00。
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() //读methodSize也是单个字节 if err != nil { return fmt.Errorf("read methodSize failed:%w", err) } method := make([]byte, methodSize) //创建一个method缓冲区 _, err = io.ReadFull(reader, method) //用io.ReadFull填充满 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}) // 返回一个报文告诉浏览器使用哪种鉴传方式 00表示不需要认证 if err != nil { return fmt.Errorf("write failed:%w", err) } return nil }此时curl 命令肯定是不成功的,因为我们的协议还没实现完成。 但是我们看日志会发现, version和method 可以正常打印,
-
接下来我们开始做第三步,实现请求阶段,我们试图读取到携带 URL 或者 IP 地址+端口的包,然后把它打印出来。我们实现一个和 auth 函数类似的 connect 函数,同样在 process 里面去调用。
再来实现 connect 函数的代码。我们来回忆一下请求阶段的逻辑。浏览器会发送一个报文,报文里面包含如下6个字段:
- version 版本号(还是 5);
- command ,代表请求的类型,我们只支持 connection 请求,也就是让代理服务建立新的TCP连接;
- RSV 保留字段 0,不理会;
- atype 就是目标地址类型,可能是 IPV4、IPV6或者域名;
- addr, 这个地址的长度是根据 atype 的类型而不同的
- port 端口号,两个字节
我们需要逐个去读取这些字段。前面这四个字段总共四个字节,我们可以一次性把它读出来。我们定义一个长度为 4 的 buffer 然后把它读满。读满之后,然后第0 个、 第1个、第3个、分别是 version、cmd 和 type。
version 需要判断是 socket 5, cmd 需要判断是1。
下面的 atype,可能是 ipv4 ,ipv6,或者是 host。
- 如果 IPV 4 的话,我们再次读满这个buffer, 因为这个buffer长度刚好也是4个字节,然后逐个字节打印成 IP 地址的格式保存到 addr变量。
- 如果是个 host 的话,需要先读它的长度,再 make 一个相应长度的buf 填充它。 再转换成字符串保存到 addr 变量。
- IPV 6 用得比较少, 我们就暂时先不支持。
最后还有两个字节那个是 port ,我们读取它,然后按协议规定的大端字节序转换成数字。由于上面的 buffer 已经不会被其他变量使用了,我们可以直接复用之前的内存,建立一个临时的 slice ,长度是2用于读取,这样的话最多会只读两个字节回来。 接下来我们把这个地址和端口打印出来用于调试。
收到浏览器的这个请求包之后,我们需要返回一个包,这个包有很多字段,但其实大部分都不会使用。
- 第一个是版本号还是 socket 5。
- 第二个是返回的类型,这里是成功就返回0。
- 第三个是保留字段 填 0 。
- 第四个 atype 地址类型 填 1(Ipv4)
- 第五个,第六个暂时用不到,都填成 0。
一共 4 + 4 + 2 个字节,后面6个字节都是 0 填充。
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 }此时请求还是会失败,我们现在已经能看到正常打印出来访问的 IP 地址和端口,这说明我们当前的实现正常,这样我们就可以做最后一步,我们真正和这个端口建立连接,双向转发数据。
-
直接用 net.dial 建立一个 TCP 连接 建立完连接之后,我们同样要加一个 defer 来关闭连接。 接下来需要建立 浏览器 和 下游服务器的双向数据转发。 标准库的 io.copy 可以实现一个单向数据转发,双向转发的话,需要启动两个 goroutinue。
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 连 with cancel 来创建一个context。 在最后等待 ctx.Done() , 只要 cancel 被调用, ctx.Done就会立刻返回。 然后在上面的两个 goroutinue 里面 调用一次 cancel 即可。
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