【超万字详细笔记】Go 语言上手-基础语言(1) | 青训营笔记

522 阅读25分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第1篇笔记

1. 简介

1.1 什么是Go语言

  1. 高性能、高并发

    Go语言有和C++,Java媲美的性能,并且原生支持高并发

  2. 语法简单、学习曲线平缓

    语法风格基于C语言进行大幅度的简化, 上手简单,学习期短

    下面是一个简单的高并发Http服务器:

    package main
    ​
    import {
        "net/http"
    }
    ​
    func main() {
        http.Handle("/", http.FileServer(http.Dir(".")))
        http.ListenAndServe(":8080", nil)
    }
    
  3. 丰富的标准库

    有大量功能丰富,质量完善的标准库,降低学习成本。标准库有很高的稳定性和兼容性。

  4. 完善的工具链

    Go 语言自带 编译、代码格式化、错误检查、帮助文档、包管理、代码补充提示、单元测试框架等工具链

  5. 静态链接

    Go 语言的编译结果默认都是静态链接的,编译出的可执行文件不需要附件任何东西即可部署运行,部署方便快捷,镜像体积小。

  6. 快速编译

    编译速度远超C++

  7. 跨平台

    能在Linux、Windows、MacOS等系统运行,能用来开发Android、iOS的软件,还能在路由器、树莓派等设备运行。有方便的交叉编译特性,在笔记本上编写的 Go 语言可执行文件可以直接在路由器上运行而无需配置交叉编译环境。

  8. 垃圾回收

    自带垃圾回收功能,与Java一样。写代码时无需考虑内存的分配和释放。

1.2 哪些公司在使用 Go 语言

  • 字节跳动
  • 腾讯
  • 美团
  • 滴滴
  • 百度
  • 深信服
  • 平安
  • OPPO
  • 知乎
  • 360
  • 微博
  • 金山
  • Google
  • Facebook
  • 七牛云
  • bilibili
  • PingCAP

2. 入门

2.1 开发环境

2.1.1 安装 Golang

Golang 的官网:go.dev/

image-20220507185902430

Golang 中国的镜像:studygolang.com/dl

image-20220507185739590

如果访问速度非常慢,可以配置go mod proxy,打开goproxy.cn/按提示操作即可,配置完成后下载第三方包的速度会大大加快。

image.png

2.1.2 配置集成开发环境

VsCode

image.png

Goland

image-20220507190229509

2.1.3 基于云的开发环境

可以使用 gitpod.io 的在线编程环境来试用 golang。

在 GitHub 的仓库链接前加上 gitpod.io/ ,登录 GitHub 账号即可使用。

例如本课程的示例项目:gitpod.io/#/github.co…

2.2 基础语法

2.2.1 Hello World

package main
​
import {
    "fmt"
}
​
func main() {
    fmt.Println("hello world")
}

第一行 package main 代表这个文件属于 main 包的一部分,main 包也就是程序的入口包。

第三行导入了标准库里面的 FMT 包。这个包主要是用来往屏幕输入输出字符串、格式化字符串。

import 下面是 main 函数,main 函数的话里面调用了 fmt.Println 输出 helloword

要运行这个程序的话,我们就直接 go run helloword.go。如果我们想编译成二进制的话,可以在 go build 来编译。编译完成之后直接 ./helloword 就可以运行。

在 FMT 包里面还有很多的函数来做不同的输入输出格式化工作。大家可以在编辑器里面把鼠标悬浮在你的代码上,就可以看到每一个函数的文档。

你也可以进入 pkg.go.dev ,后面加你的包名比如 FMT 然后就能看到这个包的在线文档,可以从里面去挑选你需要的函数来使用。

2.2.2 变量

package main
​
import {
    "fmt"
    "math"
}
​
func main() {
    
    var a = "initial"
    
    var b, c int = 1, 2
    
    var d = true
    
    var e float64
    
    f := float32(e)
    
    g := a + "foo"
    fmt.Println(a, b, c, d, e, f) // initial 1 2 true 0 0
    fmt.Println(g)                // initialapple
    
    const s string = "constant"
    const h = 500000000
    const i = 3e20 / h
    fmt.Println(s, h, i, math.Sin(h), math.Sin(i))
}

go语言是一门强类型语言,每一个变量都有它自己的变量类型。

常见的变量类型包括 字符串 整数 浮点型、布尔型等。

go 语言的字符串是内置类型,可以直接通过加号拼接,也能够直接用等于号去比较两个字符串。

在go语言里面,大部分运算符的使用和优先级都和 C 或者 C++ 类似,这里就不再概述。

下面讲述go语言里面的变量的声明,在go语言里面变量的声明有两种方式,一种是通过 var name string = "" 这种方式来声明变量,声明变量的时候,一般会自动去推导变量的类型。如果有需要,你也可以显示写出变量类型。另一种声明变量的方式是: 使用变量 冒号 := 等于值。

常量的是把 var 改成const.

go语言里面的常量没有确定的类型,会根据使用的上下文来自动确定类型。

2.2.3 if else

package main
​
import "fmt"func main() {
    
    if 7%2 == 0 {
        fmt.Println("7 is even")
    } else {
        fmt.Println("7 is odd")
    }
    
    if 8%4 == 0 {
        fmt.Println("8 is divisible by 4")
    }
    
    if num := 9; num < 0 {
        fmt.Println(num, "is negative")
    } else if num < 10 {
        fmt.Println(num, "has 1 digit")
    } else {
        fmt.Println(num, "has multiple digits")
    }
}

go语言 里面的 if else 写法和 C 或者 C++ 类似。不同点有两个。

第一个是 if 后面没有括号。如果你写括号的话,那么在保存的时候你的编辑器会自动把你去掉。

第二个是 Golang 里面的if ,它必须后面接大括号,就是你不能像 C 或者 C++ 一样,直接把 if 里面的语句同一行。

2.2.4 循环

package main
​
import "fmt"func main() {
    
    i := 1
    for {
        fmt.Println("loop")
        break
    }
    for j := 7; j < 9; j++ {
        fmt.Println(j)
    }
    
    for n := 0; n < 5; n++ {
        if n%2 == 0 {
            continue
        }
        fmt.Println(n)
    }
    for i <= 3 {
        fmt.Println(i)
        i = i + 1
    }
}

在go里面没有 while 循环、do while 循环,只有唯一的一种 for 循环。

最简单的 for 循环就是在 for 后面什么都不写,代表一个死循环。循环途中你可以用 break 跳出。也可以使用经典的 C 循环,就是 for I 等于0, I 小于 N,I 加加。这中间三段,任何一段都可以省略。

在循环里面,你可以用 break 或者 continue 来跳出或者继续循环。

2.2.5 switch

package main
​
import {
    "fmt"
    "time"
}
​
func main() {
    
    a := 2
    switch a {
    case 1:
        fmt.Println("one")
    case 2:
        fmt.Println("two")
    case 3:
        fmt.Println("three")
    case 4, 5:
        fmt.Println("four or five")
    default:
        fmt.Println("other")
    }
    
    t := time.Now()
    switch {
    case t.Hour() < 12:
        fmt.Println("It's before noon")
    default:
        fmt.Println("It's after noon")
    }
}

go语言里面的 switch 分支结构看起来也 C 或者 C++ 比较类似。

同样的,在 switch 后面的那个变量名,并不需要括号。

有个很大的一点不同的是,在C++里面, 在case中如果不加 break ,会继续往下跑完所有的 case;在go语言里面的话是不需要加 break 的。

相比 C 或者 C++ , go语言里面的 switch 功能更强大。可以使用任意的变量类型,比如字符串和结构体,甚至可以用来取代任意的 if else 语句。你可以在 switch 后面不加任何的变量,然后在 case 里面写条件分支。这样代码相比你用多个 if else 代码逻辑会更为清晰。

2.2.6 数组

package main
​
import "fmt"func main() {
​
    var a [5]int
    a[4] = 100
    fmt.Println("get:", a[2])
    fmt.Println("len:", len(a))
​
    b := [5]int{1, 2, 3, 4, 5}
    fmt.Println(b)
​
    var twoD [2][3]int
    for i := 0; i < 2; i++ {
        for j := 0; j < 3; j++ {
            twoD[i][j] = i + j
        }
    }
    fmt.Println("2d: ", twoD)
}

数组就是一个具有编号且长度固定的元素序列。比如这里的话是一个可以存放 5 个int元素的数组 A 。

对于一个数组,可以很方便地取特定索引的值或者往特定索引取存储值,然后也能够直接去打印一个数组。不过,在真实业务代码里面,我们很少直接使用数组,因为它长度是固定的,我们用的更多的是切片。

2.2.7 切片

package main
​
import "fmt"func main() {
​
    s := make([]string, 3)
    s[0] = "a"
    s[1] = "b"
    s[2] = "c"
    fmt.Println("get:", s[2])   // c
    fmt.Println("len:", len(s)) // 3
​
    s = append(s, "d")
    s = append(s, "e", "f")
    fmt.Println(s) // [a b c d e f]
​
    c := make([]string, len(s))
    copy(c, s)
    fmt.Println(c) // [a b c d e f]
​
    fmt.Println(s[2:5]) // [c d e]
    fmt.Println(s[:5])  // [a b c d e]
    fmt.Println(s[2:])  // [c d e f]
​
    good := []string{"g", "o", "o", "d"}
    fmt.Println(good) // [g o o d]
}

2.2.8 map

package main
​
import "fmt"func main() {
    m := make(map[string]int)
    m["one"] = 1
    m["two"] = 2
    fmt.Println(m)           // map[one:1 two:2]
    fmt.Println(len(m))      // 2
    fmt.Println(m["one"])    // 1
    fmt.Println(m["unknow"]) // 0
​
    r, ok := m["unknow"]
    fmt.Println(r, ok) // 0 falsedelete(m, "one")
​
    m2 := map[string]int{"one": 1, "two": 2}
    var m3 = map[string]int{"one": 1, "two": 2}
    fmt.Println(m2, m3)
}

map在其他编程语言里面,被叫做哈希或者字典。 map 是实际使用过程中最频繁用到的数据结构。

我们可以用 make 来创建一个空 map ,这里会需要两个类型,第一个是 key 的类型,这里是 string;另一个是 value 的类型,这里是 int 。 我们可以从里面去存储或者取出键值对。可以用 delete 从里面删除键值对。

golang的map是完全无序的,遍历的时候不会按照字母顺序,也不会按照插入顺序输出,而是随机顺序。

2.2.9 range

package main
​
import "fmt"func main() {
    nums := []int{2, 3, 4}
    sum := 0
    for i, num := range nums {
        sum += num
        if num == 2 {
            fmt.Println("index:", i, "num:", num) // index: 0 num: 2
        }
    }
    fmt.Println(sum) // 9
​
    m := map[string]string{"a": "A", "b": "B"}
    for k, v := range m {
        fmt.Println(k, v) // b 8; a A
    }
    for k := range m {
        fmt.Println("key", k) // key a; key b
    }
}

对于一个 slice 或者一个 map 的话,我们可以用 range 来快速遍历,这样代码能够更加简洁。

range 遍历的时候,对于数组会返回两个值,第一个是索引,第二个是对应位置的值。如果我们不需要索引的话,我们可以用下划线来忽略。

2.2.10 函数

package main
​
import "fmt"func add(a int, b int) int {
    return a + b
}
​
func add2(a, b int) int {
    return a + b
}
​
func exists(m map[string]string, k string) (v string, ok bool) {
    v, ok = m[k]
    return v, ok
}
​
func main() {
    res := add(1, 2)
    fmt.Println(res) // 3
​
    v, ok := exists(map[string]string{"a": "A"}, "a")
    fmt.Println(v, ok) // A True
}

这个是 Golang 里面一个简单的实现两个变量相加的函数。 Golang 和其他很多语言不一样的是,变量类型是后置的。

Golang 里面的函数原生支持返回多个值。在实际的业务逻辑代码里面几乎所有的函数都返回两个值,第一个是真正的返回结果,第二个值是一个错误信息。

2.2.11 指针

package main
​
import "fmt"func add2(n int) {
    n += 2
}
​
func add2ptr(n *int) {
    *n += 2
}
​
func main() {
    n := 5
    add2(n)
    fmt.Println(n) // 5
    add2ptr(&n)
    fmt.Println(n) // 7
}

go里面也支持指针。当然,相比 C 和 C++ 里面的指针,支持的操作很有限。指针的一个主要用途就是对于传入参数进行修改。

我们来看这个函数。这个函数试图把一个变量+2。但是单纯像上面这种写法其实是无效的。因为传入函数的参数实际上是一个拷贝,也就是说这个+2,是对拷贝进行了+2,并不能改变变量。如果需要改变变量,我们需要把那个类型写成指针类型,那么为了类型匹配,调用的时候会加一个 & 符号。

2.2.12 结构体

package main
​
import "fmt"type user struct {
    name     string
    password string
}
​
func main() {
    a := user{name: "wang", password: "1024"}
    b := user{"wang", "1024"}
    c := user{name: "wang"}
    c.password = "1024"
    var d user
    d.name = "wang"
    d.password = "1024"
​
    fmt.Println(a, b, c, d)                 // {wang 1024} {wang 1024} {wang 1024} {wang 1024}
    fmt.Println(checkPassword(a, "haha"))   // false
    fmt.Println(checkPassword2(&a, "haha")) // false
}
​
func checkPassword(u user, password string) bool {
    return u.password == password
}
​
func checkPassword2(u *user, password string) bool {
    return u.password == password
}

结构体的带类型的字段的集合。

比如这里 user 结构包含了两个字段,name 和 password。我们可以用结构体的名称去初始化一个结构体变量,构造的时候需要传入每个字段的初始值。也可以用这种键值对的方式去指定初始值,这样可以只对一部分字段进行初始化。

结构体支持指针,这样能够实现对于结构体的修改,也可以在某些情况下避免一些大结构体的拷贝开销。

2.2.13 结构体方法

package main
​
import "fmt"type user struct {
    name     string
    password string
}
​
func (u user) checkPassword(password string) bool {
    return u.password == password
}
​
func (u *user) resetPassword(password string) {
    u.password = password
}
​
func main() {
    a := user{name: "wang", password: "1024"}
    a.resetPassword("2048")
    fmt.Println(a.checkPassword("2048")) // true
}

在 Golang 里面可以为结构体去定义一些方法。类似其他语言里面的类成员函数。

比如这里,我们把上面一个例子的 checkPassword 的实现,从一个普通函数,改成了 结构体方法。 这样用户可以像 a.checkPassword(“xx”) 这样去调用。

具体的代码修改,就是把第一个参数,加上括号,写到函数名称前面。

在实现结构体的方法的时候也有两种写法,一种是带指针,一种是不带指针。它们的区别是,如果带指针,就可以对这个结构体去做修改。如果不带指针,实际上操作的是一个拷贝,就无法对结构体进行修改。

2.2.14 错误处理

package main
​
import (
    "errors"
    "fmt"
)
​
type user struct {
    name     string
    password string
}
​
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")
}
​
func main() {
    u, err := findUser([]user{{"wang", "1024"}}, "wang")
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(u.name) // wangif u, err := findUser([]user{{"wang", "1024"}}, "li"); err != nil {
        fmt.Println(err) // not found
        return
    } else {
        fmt.Println(u.name)
    }
}

错误处理在 go 语言里面符合语言习惯的做法就是使用一个单独的返回值来传递错误信息。

不同于 Java 自家家使用的异常,go语言的处理方式能够很清晰地知道哪个函数返回了错误,并且能用简单的 if else 来处理错误。

在函数里面,我们可以在那个函数的返回值类型里面,后面加一个 error, 就代表这个函数可能会返回错误。

那么在函数实现的时候, return 需要同时 return 两个值,要么就是如果出现错误的话,那么可以 return nil 和一个 error。如果没有的话,那么返回原本的结果和 nil。

2.2.15 字符串操作

package main
​
import (
    "fmt"
    "strings"
)
​
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
}

Go 语言在标准库 strings 包里面有很多常用的字符串工具函数,比如 contains 判断一个字符串里面是否有包含另一个字符串, count 字符串计数, index 查找某个字符串的位置, join 连接多个字符串, repeat 重复多个字符串, replace 替换字符串。

2.2.16 字符串格式化

package main
​
import "fmt"type point struct {
    x, y int
}
​
func main() {
    s := "hello"
    n := 123
    p := point{1, 2}
    fmt.Println(s, n) // hello 123
    fmt.Println(p)    // {1 2}
​
    fmt.Printf("s=%v\n", s)  // s=hello
    fmt.Printf("n=%v\n", n)  // n=123
    fmt.Printf("p=%v\n", p)  // p={1 2}
    fmt.Printf("p=%+v\n", p) // p={x:1 y:2}
    fmt.Printf("p=%#v\n", p) // p=main.point{x:1, y:2}
​
    f := 3.141592653
    fmt.Println(f)          // 3.141592653
    fmt.Printf("%.2f\n", f) // 3.14
}

在标准库的 FMT 包里面有很多的字符串格式相关的方法,比如 printf 这个类似于 C 语言里面的 printf 函数。

不同的是,在go语言里面的话,你可以很轻松地用 %v 来打印任意类型的变量,而不需要区分数字字符串。

你也可以用 %+v 打印详细结果,%#v 则更详细。

2.2.17 JSON处理

package main
​
import (
    "encoding/json"
    "fmt"
)
​
type userInfo struct {
    Name  string
    Age   int `json:"age"`
    Hobby []string
}
​
func main() {
    a := userInfo{Name: "wang", Age: 18, Hobby: []string{"Golang", "TypeScript"}}
    buf, err := json.Marshal(a)
    if err != nil {
        panic(err)
    }
    fmt.Println(buf)         // [123 34 78 97...]
    fmt.Println(string(buf)) // {"Name":"wang","age":18,"Hobby":["Golang","TypeScript"]}
​
    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:"wang", Age:18, Hobby:[]string{"Golang", "TypeScript"}}
}

go 语言里面的 JSON 操作非常简单,对于一个已有的结构体,我们可以什么都不做,只要保证每个字段的第一个字母是大写,也就是是公开字段。那么这个结构体就能用 JSON.marshaler 去序列化,变成一个 JSON 的字符串。

序列化之后的字符串也能够用 JSON.unmarshaler 去反序列化到一个空的变量里面。

这样默认序列化出来的字符串的话,它的风格是大写字母开头,而不是下划线。我们可以在后面用 json tag 等语法来去修改输出 JSON 结果里面的字段名。

2.2.18 时间处理

package main
​
import (
    "fmt"
    "time"
)
​
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
}

在 go 语言里面最常用的就是 time.now() 来获取当前时间,你也可以用 time.date 去构造一个带时区的时间,构造完的时间可以用 .Year() 等方法获取这个时间点的 年、月、日、时、分、秒等信息。可以用 .sub() 去对两个时间进行减法,得到一个时间段,时间段又可以去得到它有多少小时,多少分钟、多少秒。

在和某些系统交互的时候,我们经常会用到时间戳。可以用 .UNIX 来获取时间戳。

可以用time.Format或者time.Parse去格式化一个时间。

2.2.19 数字解析

package main
​
import (
    "fmt"
    "strconv"
)
​
func main() {
    f, _ := strconv.ParseFloat("1.234", 64)
    fmt.Println(f) // 1.234
​
    n, _ := strconv.ParseInt("111", 10, 64)
    fmt.Println(n) // 111
​
    n, _ = strconv.ParseInt("0x1000", 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 语言当中,关于字符串和数字类型之间的转换都在 STR conv 这个包下,这个包是 string convert 这两个单词的缩写。

可以用 parseInt 或者 parseFloat 来解析一个字符串。 parseint 参数我们可以用 Atoi 把一个十进制字符串转成数字。

可以用 itoA 把数字转成字符串。

如果输入不合法,那么这些函数都会返回error

2.2.20 进程信息

package main
​
import (
    "fmt"
    "os"
    "os/exec"
)
​
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
}

在 go 里面,我们能够用 os.Argv 来得到程序执行的时候的指定的命令行参数。

比如我们编译的一个 二进制文件,command。 后面接 abcd 来启动,输出的 os.Argv 会是一个长度为 5 的 slice ,第一个成员代表二进制自身的名字。

可以用 os.Getenv来读取环境变量。

3. 实战

3.1 猜谜游戏

3.1.1 生成随机数

package main
​
import (
    "fmt"
    "math/rand"
)
​
func main() {
    maxNum := 100
    secretNumber := rand.Intn(maxNum)
    fmt.Println("The secret number is ", secretNumber)
}

使用rand.Intn生成随机数前需要用rand.Seed设置随机数种子,否则每次都会生成相同的随机数序列。下面用 time.now.unix 来初始化随机种子。

3.1.2 优化生成随机数

package main
​
import (
    "fmt"
    "math/rand"
    "time"
)
​
func main() {
    maxNum := 100
    rand.Seed(time.Now().UnixNano())
    secretNumber := rand.Intn(maxNum)
    fmt.Println("The secret number is ", secretNumber)
}

正常输出0-100之间的随机数。

3.1.3 读取用户输入

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)
    // 读取一行输入,后面会多一个换行符。读取后转换为数字
    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, "\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)
}

3.1.4 实现判断逻辑

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)
    // 读取一行输入,后面会多一个换行符。读取后转换为数字
    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, "\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)
    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!")
    }
}

3.1.5 实现游戏循环

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.TrimSuffix(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
        }
    }
}

3.2 在线词典

3.2.1 抓包

以彩云科技提供的在线翻译为例。

打开彩云翻译的网页,然后右键检查打开浏览器的开发者工具。

image-20220508020110081

输入任意文字,点击翻译,在控制台点击Network,找到dict,查看对比Payload,Preview

image-20220508022128944

我们需要在 Golang 里面去发送这个请求。因为这个请求比较复杂,用代码构造很麻烦,实际上我们有一种非常简单的方式来生成代码。

右键选择dict,点击Copy,点击Copy as cURL(bash)

image-20220508022441648

curl "https://api.interpreter.caiyunai.com/v1/dict" ^
  -H "Accept: application/json, text/plain, */*" ^
  -H "Accept-Language: zh-CN,zh;q=0.9" ^
  -H "Connection: keep-alive" ^
  -H "Content-Type: application/json;charset=UTF-8" ^
  -H "Origin: https://fanyi.caiyunapp.com" ^
  -H "Referer: https://fanyi.caiyunapp.com/" ^
  -H "Sec-Fetch-Dest: empty" ^
  -H "Sec-Fetch-Mode: cors" ^
  -H "Sec-Fetch-Site: cross-site" ^
  -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36" ^
  -H "X-Authorization: token:qgemv4jr1y38jyq6vhvi" ^
  -H "app-name: xy" ^
  -H "device-id: " ^
  -H "os-type: web" ^
  -H "os-version: " ^
  -H "sec-ch-ua: ^^" Not A;Brand^^";v=^^"99^^", ^^"Chromium^^";v=^^"101^^", ^^"Google Chrome^^";v=^^"101^^"" ^
  -H "sec-ch-ua-mobile: ?0" ^
  -H "sec-ch-ua-platform: ^^"Windows^^"" ^
  --data-raw "^{^^"trans_type^^":^^"en2zh^^",^^"source^^":^^"good^^"^}" ^
  --compressed

3.2.2 代码生成

curlconverter.com/#go

在这个网站中粘贴 curl 请求,在下方的语言里面选 Golang ,会自动生成一串很长的代码,我们直接把它 copy 到我们的编辑器里面。 有几个header比较复杂,生成代码有转义导致的编译错误,删掉这几行即可。

image-20220508091854046

package main
​
import (
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "strings"
)
​
func main() {
    client := &http.Client{}
    var data = strings.NewReader(`{"trans_type":"en2zh","source":"good"}`)
    // 创建请求
    req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
    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)
    }
    // 关闭Body这个流,避免资源泄露
    defer resp.Body.Close()
    // 读取响应
    bodyText, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%s\n", bodyText)
}

第 12 行创建了一个 HTTP client,创建的时候可以指定很多参数,包括比如请求的超时是否使用 cookie 等。

接下来是构造一个 HTTP 请求,这是一个 post 请求,然后会用到 HTTP .NewRequest ,第一个参数是 http 方法 POST, 第二个参数是 URL, 最后一个参数是 body ,body因为可能很大,为了支持流式发送,是一个只读流。我们用 strings.NewReader 来把字符串转换成一个流。这样我们就成功构造了一个 HTTP request ,接下来我们需要对这个 HTTP request 来设置一堆 header。

接下来我们把我们调用 client.do request ,就能得到 response 如果请求失败的话,那么这个 error 会返回非 nil,会打印错误并且退出进程。response 有它的 HTTP 状态码, response header和body。

body同样是一个流,在golang里面,为了避免资源泄露,你需要加一个 defer 来手动关闭这个流,这个 defer 会在这个函数运行结束之后去执行。接下来我们是用 ioutil.ReadAll 来读取这个流,能得到整个body。我们再用 print 打印出来。

image-20220508090520696

运行会返回一串JSON。但现在的输入是固定的,我们需要从一个变量来输入,需要用到JSON序列化。

3.2.3 生成 request body

package main
​
import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
)
​
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)
    // 创建请求
    req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
    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)
    }
    // 关闭Body这个流,避免资源泄露
    defer resp.Body.Close()
    // 读取响应
    bodyText, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%s\n", bodyText)
}

在 Golang 里面。我们需要生成一段 JSON ,常用的方式是我们先构造出来一个结构体,这个结构体和我们需要生成的 JSON 的结构是一一对应的,然后直接调用 json.Marshal 即可。

执行结果和上一步相同。

3.2.4 解析 request body

接下来我们要做的是把这个 response body 来解析出来。

在 js/Python 这些脚本语言里面,body 是一个字典或者 map 的结构, 可以直接从里面取值。 但是golang是个强类型语言,这种做法并不是最佳实践。

更常用的方式是和 request 的一样,写一个结构体,把返回的 JSON 反序列化到结构体里面。但是我们在浏览器里面可以看到这个 API 返回的结构非常复杂,如果要一一定义结构体字段,非常繁琐并且容易出错。

此时有一个小技巧的是,网上有对应的代码生成工具,我们可以打开这个网站 ,把json字符串粘贴进去,这样我们就能够生成对应结构体。

回到彩云翻译,将 Preview 中的 json 复制。

image-20220508092442429

打开oktools.net/json2go,在左边输入刚刚复制的 json ,点击转换嵌套。

image-20220508092540809

在某些时刻,我们如果不需要对这个返回结果,做很多精细的操作,我们可以选择转换嵌套,能让生成的代码更加紧凑。

type AutoGenerated struct {
    Rc int `json:"rc"`
    Wiki Wiki `json:"wiki"`
    Dictionary Dictionary `json:"dictionary"`
}
type Description struct {
    Source string `json:"source"`
    Target interface{} `json:"target"`
}
type Item struct {
    Source string `json:"source"`
    Target string `json:"target"`
}
type Wiki struct {
    KnownInLaguages int `json:"known_in_laguages"`
    Description Description `json:"description"`
    ID string `json:"id"`
    Item Item `json:"item"`
    ImageURL string `json:"image_url"`
    IsSubject string `json:"is_subject"`
    Sitelink string `json:"sitelink"`
}
type Prons struct {
    EnUs string `json:"en-us"`
    En string `json:"en"`
}
type Dictionary struct {
    Prons Prons `json:"prons"`
    Explanations []string `json:"explanations"`
    Synonym []string `json:"synonym"`
    Antonym []string `json:"antonym"`
    WqxExample []WqxExample[]string `json:"wqx_example"`
    Entry string `json:"entry"`
    Type string `json:"type"`
    Related []interface{} `json:"related"`
    Source string `json:"source"`
}

这样我们就得到了一个 response 结构体。接下来我们修改代码,我们先定一个 response 结构体的对象,然后我们用 JSON.unmarshal 把 body 反序列化到 这个结构体里面,再尝试打印出来。

image-20220508093037971

最后打印的时候使用了 %#v, 这样可以让打印出来的结果比较容易读。现在离最终版本已经很近了,接下来我们需要修改代码为打印 response 里面的特定字段。

3.2.5 打印结果

    bodyText, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        log.Fatal(err)
    }
    if resp.StatusCode != 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)
    }

观察返回的 json 可以看出我们需要的结果是在 Dictionary.explanations. 我们用 for range 循环来迭代它,然后直接打印结构,参照一些词典的显示方式,我们可以在那个前面打印出这个单词和它的音标。这里有英式音标和美式音标。

3.2.6 完善代码

package main
​
import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "os"
)
​
type DictRequest struct {
    TransType string `json:"trans_type"`
    Source    string `json:"source"`
    UserID    string `json:"user_id"`
}
​
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"`
}
​
func query(word string) {
    client := &http.Client{}
    request := DictRequest{TransType: "en2zh", Source: word}
    // 序列化
    buf, err := json.Marshal(request)
    if err != nil {
        log.Fatal(err)
    }
    var data = bytes.NewReader(buf)
    // 创建请求
    req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
    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)
    }
    // 关闭Body这个流,避免资源泄露
    defer resp.Body.Close()
    // 读取响应
    bodyText, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        log.Fatal(err)
    }
    if resp.StatusCode != 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)
}

现在我们的程序的输入还是写死的。我们把代码的主体改成一个 query 函数,查询的单词作为参数传递进来。然后我们写一个简单的 main 函数,这个 main 函数首先判断一下命令和参数的个数,如果它不是两个,那么我们就打印出错误信息,退出程序。 否则就获取到用户输入的单词,然后执行 query 函数。

image-20220508093936569

3.3 SOCKS5 代理

现在来写一个 socks5 代理服务器。

3.3.1 SOCKS5 代理介绍

image-20220509000140293

对于大家来说,一提到代理服务器,第一想到的是翻墙。不过很遗憾的是, socks5 协议它虽然是代理协议,但它并不能用来翻墙,它的协议都是明文传输。

这个协议历史比较久远,诞生于互联网早期。它的用途是, 比如某些企业的内网为了确保安全性,有很严格的防火墙策略,但是带来的副作用就是访问某些资源会很麻烦。socks5 相当于在防火墙开了个口子,让授权的用户可以通过单个端口去访问内部的所有资源。

实际上很多翻墙软件,最终暴露的也是一个 socks5 协议的端口。

如果有同学开发过爬虫就知道,在爬取过程中很容易会遇到IP访问频率超过限制。这个时候很多人就会去网上找一些代理 IP 池,这些代理 IP 池里面的很多代理的协议就是 socks5。

image.png

这是最终写完的代理服务器的效果。

启动这个程序,然后在浏览器里面配置使用这个代理。此时我们打开网页,代理服务器的日志会打印出你访问的网站的域名或者 IP ,这说明我们的网络流量是通过这个代理服务器的。

我们也能在命令行去测试我们的代理服务器。我们可以用 curl -socks5 + 代理服务器地址,后面加一个可访问的 URL, 如果代理服务器工作正常的话,那么 curl 命令就会正常返回。

3.3.2 SOCKS5 的原理

image.png

接下来了解一下 socks5 协议的工作原理。正常浏览器访问一个网站,如果不经过代理服务器的话,就是先和对方的网站建立 TCP 连接,然后三次握手,握手完之后发起 HTTP 请求,然后服务返回 HTTP 响应。如果设置代理服务器之后,流程会变得复杂一些。

首先是浏览器和 socks5 代理建立 TCP 连接,代理再和真正的服务器建立 TCP 连接。这里可以分成四个阶段,握手阶段、认证阶段、请求阶段、 relay 阶段。

第一个握手阶段,浏览器会向 socks5 代理发送请求,包的内容包括一个协议的版本号,还有支持的认证的种类,socks5 服务器会选中一个认证方式,返回给浏览器。如果返回的是 00 的话就代表不需要认证,返回其他类型的话会开始认证流程,这里我们就不对认证流程进行概述了。

第三个阶段是请求阶段,认证通过之后浏览器会 socks5 服务器发起请求。主要信息包括 版本号,请求的类型,一般主要是 connection 请求,就代表代理服务器要和某个域名或者某个 IP 地址某个端口建立 TCP 连接。代理服务器收到响应之后,会真正和后端服务器建立连接,然后返回一个响应。

第四个阶段是 relay 阶段。此时浏览器会发送 正常发送请求,然后代理服务器接收到请求之后,会直接把请求转换到真正的服务器上。然后如果真正的服务器以后返回响应的话,那么也会把请求转发到浏览器这边。然后实际上 代理服务器并不关心流量的细节,可以是 HTTP流量,也可以是其它 TCP 流量。

这个就是 socks5 协议的工作原理,接下来我们就会试图去简单地实现它。

3.3.3 TCP echo server

package main
​
import (
    "bufio"
    "log"
    "net"
)
​
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
        }
    }
}

第一步,我们先在 go 里面写一个简单的 TCP echo server。为了方便测试, server 的工作逻辑很简单,你给他发送啥,他就回复啥,大概代码会长这样子:

首先我们在 main 函数里面先用 net.listen 去监听一个端口,会返回一个 server, 然后在一个死循环里面,每次去 accept 一个请求,成功就会返回一个连接。接下来的话我们在一个 process 函数里面去处理这个连接。

注意这前面会有个 go 关键字,这个代表启动一个 goroutinue, 可以暂时类比为其他语言里面的启动一个子线程。只是这里的 goroutinue 的开销会比子线程要小很多,可以很轻松地处理上万的并发。

接下来是这个 process 函数的实现。首先第一步的话会先加一个 defer connection.close(), defer 是 Golang 里面的一个语法,这一行的含义就是代表在这个函数退出的时候要把这个连接关掉,否则会有资源的泄露。

接下来的话我们会用 bufio.NewReader 来创建一个 带缓冲的只读流,这个在前面的猜谜游戏里面也有用到, 带缓冲的流的作用是,可以减少底层系统调用的次数,比如这里为了方便是一个字节一个字节的读取,但是底层可能合并成几次大的读取操作。并且带缓冲的流会有更多的一些工具函数用来读取数据。

我们可以简单地调用那个 readbyte 函数来读取单个字节。再把这一个字节写进去连接。

3.3.4 安装 netcat

接下来测试一下这个TCP服务器。

测试需要用到 netcat。下面先安装 netcat。

netcat 下载地址:eternallybored.org/misc/netcat…

打开后点击 netcat 1.12下载。

image-20220509144059050

下载完成后解压文件夹,放到安装软件的目录。

image-20220509144701098

然后配置环境变量。把上图的路径加入到环境变量 Path 中。

image-20220509145036556

接下来测试一下 netcat。打开两个命令行窗口,第一个输入 nc -l -p 9000 ,第二个输入 nc localhost 9000

image-20220509145549926

然后在第二个命令行窗口输入任意内容,在第一个窗口如果能自动显示相同内容,即安装成功。

image-20220509145822860

3.3.5 测试3.3.3中的 TCP 服务器

打开两个命令行窗口,第一个 运行3.3.3中的 go 程序,第二个输入 nc 127.0.0.1 1080 ,然后在第二个窗口中输入任意内容,会自动返回相同内容。

image-20220509151919544

3.3.6 认证函数auth

上面已经完成了一个能够返回你输入信息的一个 TCP server ,接下来我们是要开始实现协议的第一步:认证阶段, 从这一部分开始会变得比较复杂。

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
}

先实现一个空的 auth 函数,在 process 函数里面调用,再来编写 auth 函数的代码。

我们回忆一下认证阶段的逻辑,首先第一步的话,浏览器会给代理服务器发送一个包,然后这个包有三个字段。

第一个字段, version 也就是 协议版本号 ,固定是 5 ,第二个字段 methods, 认证的方法数目 第三个字段 每个 method的编码, 0代表 不需要认证, 2 代表用户名密码认证

我们先用 read bytes 来把版本号读出来,然后如果版本号不是 socket 5 的话直接返回报错,接下来我们再读取 method size ,也是一个字节。然后我们需要我们去 make 一个相应长度的一个 slice ,用 io.ReadFull 把它去填充进去。写到这里,我们把获取到的版本号和认证方式打印一下。

此时,代理服务器还需要返回一个response, 返回包包括 两个字段,一个是 version 一个是 method,也就是我们选中的鉴传方式,我们当前只准备实现不需要鉴传的方式,也就是00。

下面我们用 curl 命令测试一下当前版本的效果。

打开两个命令行窗口,第一个 运行这一步的 go 程序,第二个输入 curl --socks5 127.0.0.1:1080 -v http://www.qq.com

image-20220509215836078

此时curl 命令是不成功的,因为我们的协议还没实现完成。但是我们看日志会发现, version 和 method 可以正常打印,说明当前我们的实现是正确的。

3.3.7 请求阶段

接下来实现请求阶段,我们试图读取到 携带 URL 或者 IP 地址+端口的包,然后把它打印出来。我们实现一个和 auth 函数类似的 connect 函数,同样在 process 里面去调用。再来实现 connect 函数的代码。我们来回忆一下请求阶段的逻辑。浏览器会发送一个包,包里面包含如下6个字段:

字段长度内容
VER1byte协议版本,0x05为socks5
CMD1byte请求类型,0x01表示connect请求
RSV1byte保留字段(不理会)
ATYP1byte目标地址类型(IPV4/IPV6/域名)
DST.ADDRVariable地址值,长度由ATYP类型决定
DST.PORT2byte目标端口号

我们需要逐个去读取这些字段。

前面这四个字段总共四个字节,我们可以一次性把它读出来。我们定义一个长度为 4 的 buffer 并把它读满。其中,version 需要判断是 socket 5, cmd 需要判断是 1。然后 atype,可能是 ipv4 ,ipv6,或者是 host。如果 IPV4 的话,我们再次读满这个buffer, 因为这个buffer长度刚好也是4个字节,然后逐个字节打印成 IP 地址的格式保存到 addr变量;如果是个 host 的话,需要先读它的长度,再 make 一个相应长度的buf 填充它。 再转换成字符串保存到 addr 变量。 IPV6 用得比较少, 就暂时先不支持。

最后还有两个字节那个是 port ,我们读取它,然后按协议规定的大端字节序转换成数字。由于上面的 buffer 已经不会被其他变量使用了,我们可以直接复用之前的内存,建立一个临时的 slice ,长度是2用于读取,这样的话最多会只读两个字节回来。

服务器收到浏览器的这个请求包之后需要返回一个包,内容如下:

字段长度内容
VER1byte协议版本,0x05为socks5
REP1byte返回的类型,这里是成功,返回0
RSV1byte保留字段,填0
ATYP1byte地址类型(IPV4/IPV6/域名),填1
BND.ADDRVariable地址值,暂时不需要,填0
BND.PORT2byte端口号,暂时不需要,填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
}

下面再用curl测试一下结果。

打开两个命令行窗口,第一个 运行这一步的 go 程序,第二个输入 curl --socks5 127.0.0.1:1080 -v http://www.qq.com

image-20220509222442370

此时请求还是会失败,我们现在已经能看到正常打印出来访问的 IP 地址和端口,这说明当前的实现正常。

这样我们就可以做最后一步,真正和这个端口建立连接,双向转发数据。

3.3.8 relay 阶段

最后的转发过程,由于不需要对流量进行任何的处理,所以没有上层协议,直接再Write操作完后把流量进行转发即可。

  1. 用 net.dial 建立一个 TCP 连接,在后面加一个 defer 来关闭连接。

    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()
    
  2. 建立 浏览器 和 下游服务器 的双向数据转发。

    标准库的 io.copy 可以实现一个单向数据转发,双向转发的话,需要启动两个 goroutinue。

    go func() {
            _, _ = io.Copy(dest, reader)
            cancel()
        }()
        go func() {
            _, _ = io.Copy(conn, dest)
            cancel()
        }()
    }()
    
  3. 现在有一个问题,connect 函数会立刻返回,返回的时候连接就被关闭了。

    我们需要等待任意一个方向copy出错的时候,再返回 connect 函数。

    这里可以使用到标准库里面的一个 context 机制,用 context.WithCancel 来创建一个context。在最后等待 ctx.Done() , 只要 cancel 被调用, ctx.Done就会立刻返回。 然后在上面的两个 goroutinue 里面 调用一次 cancel 即可。

    _, 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
    

现在我们的 socks5 代理服务器就完成了。完整代码:

package main
​
import (
    "bufio"
    "context"
    "encoding/binary"
    "errors"
    "fmt"
    "io"
    "log"
    "net"
)
​
const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04func 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)
    err := auth(reader, conn)
    if err != nil {
        log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
        return
    }
    err = connect(reader, conn)
    if err != nil {
        log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
        return
    }
}
​
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)
    }
​
    // +----+--------+
    // |VER | METHOD |
    // +----+--------+
    // | 1  |   1    |
    // +----+--------+
    _, err = conn.Write([]byte{socks5Ver, 0x00})
    if err != nil {
        return fmt.Errorf("write failed:%w", err)
    }
    return nil
}
​
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])
​
    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)
​
    // +----+-----+-------+------+----------+----------+
    // |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)
    }
    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
}

此时再用curl测试。

打开两个命令行窗口,第一个 运行这一步的 go 程序,第二个输入 curl --socks5 127.0.0.1:1080 -v http://www.qq.com

image-20220510103437472

返回了成功!

我们也可以在浏览器里面测试。首先要安装这个 switchomega 插件, 然后里面新建一个情景模式, 代理服务器选 socks5,端口 1080 ,保存并启用。

image-20220510104246346

此时在浏览器能够正常地访问网站,同时代理服务器会显示出浏览器版本的域名和端口。