【字节跳动青训营】后端笔记整理-2 | Go实践记录:猜谜游戏,在线词典,Socks5代理服务器

简介: 猜数字游戏也算是入门一门编程语言必写的程序了。通过这个程序,我们可以熟悉Go语言中的输入输出、流程控制与随机函数的调用。

**本人是第六届字节跳动青训营(后端组)的成员。本文由博主本人整理自该营的日常学习实践,首发于稀土掘金:🔗Go实践记录:猜谜游戏,在线词典,Socks5代理服务器 | 青训营


我的go开发环境:


*本地IDE:GoLand 2023.1.2


*go:1.20.6


一、猜谜游戏


猜数字游戏也算是入门一门编程语言必写的程序了。通过这个程序,我们可以熟悉Go语言中的输入输出、流程控制与随机函数的调用。


1、生成随机数

在Go语言中,标准库math/rand下的一系列方法可以用来生成随机数。


math/rand库的官方文档:https://pkg.go.dev/math/rand


通过调用库中的rand.Intn(n)函数,可以生成一个 [0,n) 的随机整数:


//Go 1.20
package main

import (
    "fmt"
    "math/rand"
)

func main() {
    maxNum := 100
    for i := 0; i < 10; i++ {    //生成10个随机整数
       secretNum := rand.Intn(maxNum)        //每次生成的随机数在 [0,100)
       fmt.Println("随机数是", secretNum)
    }
}


注意:生成的这10个随机整数是否相同呢?

在我所使用的 Go 1.20.6 下,生成的10个随机数是各不相同的。也就是说,从 Go 1.20 开始,rand.Intn()生成的是真随机数,不需要设定Seed()。



依照官方的说法,math/rand 已弃用 rand.Seed(…) 全局函数,现在自动为全局随机数生成器Int生成一个随机值,并且顶级Seed函数已被弃用。因此,每次运行程序时会生成不同的随机数。




但如果在 Go 1.20 版本之前,如培训中王克纯老师演示的(Go 1.18),在调用rand.Intn()之前必须先通过 rand.Seed()设置随机数种子,这样才能让每次运行生成的随机数值不同,否则,由于随机数种子固定,每次运行生成的随机数也是固定的一个值,并不“随机”。


通常,用时间戳来作为随机数的种子。(这一点特性和C语言的rand()是类似的。)






//Go 1.20 之前

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)
}


2、读取用户输入

这里直接用 fmt.Scan() 来读取用户的输入。

fmt.Scan() 函数用于从标准输入中读取数据,并根据提供的格式字符串将输入解析为相应的变量。这个函数会一直等待用户输入,直到按下回车键,并尝试将输入解析为指定的变量类型:


package main

import (
        "fmt"
)

func main() {
        var num int
        fmt.Print("请输入一个整数: ")
        _, err := fmt.Scan(&num)
        if err != nil {
                fmt.Println("输入错误:", err)
                return
        }
        fmt.Println("您输入的整数是:", num)
}


在这个示例中,fmt.Scan(&num) 会等待用户输入,并将输入解析为整数,并将其存储到 num 变量中。如果输入无法解析为整数,将会返回错误。


注意,fmt.Scan() 函数需要提供变量的地址作为参数,以便将解析后的值存储到变量中。也可以在 fmt.Scan() 中使用格式字符串来匹配特定的输入格式,以及处理多个变量的读取。


通过 fmt.Scan() 完善猜数字的代码:


maxNum := 100
secretNum := rand.Intn(maxNum)
fmt.Println("随机数是", secretNum)

fmt.Println("请输入你猜测的数字:>")
var guess int
_, err := fmt.Scan(&guess)
if err != nil {
    fmt.Println("输入出错!", err)
    return
}


3、实现逻辑判断与循环游戏

加上 if-else 对 输入的 guess 与 随机数 secretNum 进行值的校验,来得出用户是否猜中数字;加上循环,实现多次游戏。这样,猜数字游戏的程序就完成了。


package main

import (
    "fmt"
    "math/rand"
)

func main() {
    maxNum := 100
    secretNum := rand.Intn(maxNum)
    //fmt.Println("随机数是", secretNum)

    for {
       fmt.Println("请输入你猜测的数字:>")
       var guess int
       _, err := fmt.Scan(&guess)
       if err != nil {
          fmt.Println("输入出错!", err)
          continue    //输入出错不能直接退出程序,而是进入下一次输入
       }

       if guess > secretNum {
          fmt.Println("猜大了!")
       } else if guess < secretNum {
          fmt.Println("猜小了!")
       } else {
          fmt.Println("恭喜你!猜中了!")
          break
       }
    }
}



二、在线词典


第二个案例是一个命令行词典。效果是输入一个单词,命令行中会显示这个单词的发音和注释。



原理是调用第三方的API去查询结果,并且其打印出来。

实现这个程序的关键在于,如何用Go语言发送HTTP请求和解析JSON。

1、抓包

以彩云小译为例:https://fanyi.caiyunapp.com/#/



分析这个请求:



2、代码自动生成

我们要实现在Golang里发送这个请求。

但这个请求很复杂,直接用代码来构造很麻烦,所以我们可以用另一种简单的方式来生成请求。

右键浏览器中的请求:



点击 Copy as cURL(bash) 后,打开这个网址:https://curlconverter.com/go/

把刚才复制的东西粘贴进去,自动会生成响应的请求代码:



将这些代码复制到Goland:


package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "strings"
)

func main() {
    client := &http.Client{}
    var data = strings.NewReader(`{"trans_type":"en2zh","source":"hello"}`)
    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")
    req.Header.Set("app-name", "xy")
    req.Header.Set("cache-control", "no-cache")
    req.Header.Set("content-type", "application/json;charset=UTF-8")
    req.Header.Set("device-id", "b72e9f6cbb97432941b8adf317a17dee")
    req.Header.Set("origin", "https://fanyi.caiyunapp.com")
    req.Header.Set("os-type", "web")
    req.Header.Set("os-version", "")
    req.Header.Set("pragma", "no-cache")
    req.Header.Set("referer", "https://fanyi.caiyunapp.com/")
    req.Header.Set("sec-ch-ua", `"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"`)
    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/115.0.0.0 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 := io.ReadAll(resp.Body)
    if err != nil {
       log.Fatal(err)
    }
    fmt.Printf("%s\n", bodyText)
}

代码解读:



这里特别解释一下上图中第40行的 defer 关键字。


在 Go 语言中,defer 是一个用于延迟函数调用执行的关键字。通过使用 defer,可以在函数返回之前(无论这个函数是正常结束还是发生了异常),推迟某个函数的执行。这在编写代码时可以很有用,特别是在需要确保资源释放或清理操作的情况下。


defer 语句的语法是:


defer functionCall(arguments)

当在一个函数内部使用 defer 时,被推迟执行的函数调用会被添加到一个栈中,而不会立即执行。在函数执行完毕并即将返回之前,栈中的函数调用会按照逆序执行。因此,如果有多个 defer 语句,它们会按照后进先出(LIFO)的顺序执行,即最后一个推迟的函数调用会最先执行,而最早的推迟的函数调用会最后执行。


以下示例演示了有三个 defer 的情况:


package main

import "fmt"

func main() {
    defer fmt.Println("第一个defer")
    defer fmt.Println("第二个defer")
    defer fmt.Println("第三个defer")
    fmt.Println("正常的函数调用")
}

在这个示例中,尽管 fmt.Println("正常的函数调用") 在代码中位于三个 defer 语句之前,但它会最先执行。然后,defer 语句会按照后进先出的顺序执行,所以先执行第三个 defer,然后是第二个 defer,最后是第一个 defer。这就是 defer 的执行顺序。


defer 的设计是为了在函数返回前执行一些清理工作,或者确保一些资源被正确释放。通过使用 defer,我们可以更方便地管理代码,并确保清理操作不会被遗漏。


运行自动生成得到的代码块,成功输出一大串JSON:



说明请求成功。


但是,这片请求代码是固定的,传入的data也是固定的 data = strings.NewReader(`{"trans_type":"en2zh","source":"hello"}`)


我们肯定希望用户能通过一个变量进行输入,想翻译什么单词,就翻译什么单词,而不是固定的只能输入JSON字符串。因此,我们要用到JSON序列化。


3、生成request body

如何进行JSON序列化?在上一篇文章中提到过:https://juejin.cn/post/7265577455208955938#heading-62


只需要构造一个结构体,让这个结构体的字段名称和JSON的结构一一对应,然后直接调用 json.Marshal() 即可。


因此这里,我们也需要构造出一个结构体,然后将结构体序列化,通过这样的方式,让data中的值是可变的:


package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "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: "hello"}
    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("authority", "api.interpreter.caiyunai.com")
    req.Header.Set("accept", "application/json, text/plain, */*")
    req.Header.Set("accept-language", "zh-CN,zh;q=0.9")
    req.Header.Set("app-name", "xy")
    req.Header.Set("cache-control", "no-cache")
    req.Header.Set("content-type", "application/json;charset=UTF-8")
    req.Header.Set("device-id", "b72e9f6cbb97432941b8adf317a17dee")
    req.Header.Set("origin", "https://fanyi.caiyunapp.com")
    req.Header.Set("os-type", "web")
    req.Header.Set("os-version", "")
    req.Header.Set("pragma", "no-cache")
    req.Header.Set("referer", "https://fanyi.caiyunapp.com/")
    req.Header.Set("sec-ch-ua", `"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"`)
    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/115.0.0.0 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 := io.ReadAll(resp.Body)
    if err != nil {
       log.Fatal(err)
    }
    fmt.Printf("%s\n", bodyText)
}

上述代码中,手动将要翻译的类型(“en2zh”)和单词(“hello”)传入了结构体。


此时代码的运行结果应与一开始的代码运行结果完全相同。对请求的序列化的逻辑就完成了。


4、解析 response body

完成了请求的序列化后,我们还要进行响应的反序列化,这样才能得到结果。这我们要解析出这一堆响应,并且获取到其中的一些关键信息如"explanations"等。



如果是python或者js,返回的会是一个字典的结构,可以直接通过 [] 或 . 去取值。但是Go是一种强类型的语言,这种方式并不是最佳实践(虽然也可以做到)。


更常见的方式是和处理request body一样,写一个结构体,这个结构体的字段和返回的response是一一对应的。再把返回的 json 反 序列化 (json.Unmarshal())到结构体里面。


但是,我们看到,浏览器里返回的这个response结构非常复杂,手动一一创建结构体去对应显然不是好的做法。所以我们还是用代码生成的方式来实现。


打开这个工具网站:https://oktools.net/json2go


然后,把彩云小译界面的 response 的json粘贴进去:




点击“转换-嵌套”,就会生成一个结构体。(如果点击“转换-展开”,会生成独立的多个结构体。)

将生成的结构体代码粘贴到Goland中,将结构体改名为 DictResponse,然后修改最后处理响应的部分代码:


package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
)

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 {
    } `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      []interface{} `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 main() {
    client := &http.Client{}
    request := DictRequest{TransType: "en2zh", Source: "hello"}
    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("authority", "api.interpreter.caiyunai.com")
    req.Header.Set("accept", "application/json, text/plain, */*")
    req.Header.Set("accept-language", "zh-CN,zh;q=0.9")
    req.Header.Set("app-name", "xy")
    req.Header.Set("cache-control", "no-cache")
    req.Header.Set("content-type", "application/json;charset=UTF-8")
    req.Header.Set("device-id", "b72e9f6cbb97432941b8adf317a17dee")
    req.Header.Set("origin", "https://fanyi.caiyunapp.com")
    req.Header.Set("os-type", "web")
    req.Header.Set("os-version", "")
    req.Header.Set("pragma", "no-cache")
    req.Header.Set("referer", "https://fanyi.caiyunapp.com/")
    req.Header.Set("sec-ch-ua", `"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"`)
    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/115.0.0.0 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 := io.ReadAll(resp.Body)
    if err != nil {
       log.Fatal(err)
    }
    
    //处理响应
    var dictResponse DictResponse
    err = json.Unmarshal(bodyText, &dictResponse)
    if err != nil {
       log.Fatal(err)
    }
    //用 %#v 会以最详细的方式来打印结构体,包括结构体的名字和字段的名字
    fmt.Println("%#v\n", dictResponse)
}

我们需要从response中筛选出我们需要的信息:如“音标”,“解释”:


//fmt.Println("%#v\n", dictResponse)
fmt.Println(word, "UK:", dictResponse.Dictionary.Prons.En,
    "US:", dictResponse.Dictionary.Prons.EnUs)
for _, item := range dictResponse.Dictionary.Explanations {
    fmt.Println(item)
}

最终经过调整,完整的程序代码如下:


package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "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 {
    } `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 main() {
    //检查命令行传入的参数个数(第一个参数是程序名称本身,第二个参数是传入的单词,因此必须是两个参数)
    if len(os.Args) != 2 {
       fmt.Fprintf(os.Stderr, `usage: simpleDict WORD example: simpleDict hello`)
       os.Exit(1)
    }
    //将第二个命令行参数也就是单词传给变量 word
    word := os.Args[1]
    query(word)
}

// 将程序的主要代码封装为query()函数,传入要翻译的单词
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("authority", "api.interpreter.caiyunai.com")
    req.Header.Set("accept", "application/json, text/plain, */*")
    req.Header.Set("accept-language", "zh-CN,zh;q=0.9")
    req.Header.Set("app-name", "xy")
    req.Header.Set("cache-control", "no-cache")
    req.Header.Set("content-type", "application/json;charset=UTF-8")
    req.Header.Set("device-id", "b72e9f6cbb97432941b8adf317a17dee")
    req.Header.Set("origin", "https://fanyi.caiyunapp.com")
    req.Header.Set("os-type", "web")
    req.Header.Set("os-version", "")
    req.Header.Set("pragma", "no-cache")
    req.Header.Set("referer", "https://fanyi.caiyunapp.com/")
    req.Header.Set("sec-ch-ua", `"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"`)
    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/115.0.0.0 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 := io.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("%#v\n", dictResponse)
    fmt.Println(word, "UK:", dictResponse.Dictionary.Prons.En,
       "US:", dictResponse.Dictionary.Prons.EnUs)
    for _, item := range dictResponse.Dictionary.Explanations {
       fmt.Println(item)
    }
}

在命令行运行:输入要翻译的单词,可以调用接口实现在线翻译




三、SOCKS5代理服务器


对于大家来说,一提到代理服务器,第一想到的是翻墙。不过遗憾的是, socks5 协议它虽然是代理协议,但它并不能用来翻墙,它的协议都是明文传输。这个协议历史比较久远,诞生于互联网早期。


它的用途是,比如某些企业的内网为了确保安全性,有很严格的防火墙策略,但是带来的副作用是访问某些资源会很麻烦。socks5 相当于在防火墙开了个口子,让授权的用户可以通过单个端口去访问内部的所有资源。实际上很多翻墙软件,最终暴露的也是一个 socks5 协议的端口。


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


**socks5协议的原理




正常浏览器访问一个网站,如果不经过代理服务器的话,会先和对方的网站建立 TCP 连接,然后三次握手。握手完之后发起 HTTP 请求,然后服务返回 HTTP 响应。


如果设置代理服务器之后,流程会变得复杂一些:


首先是浏览器和 socks5 代理建立 TCP 连接,代理再和真正的服务器建立 TCP 连接。这里可以分成四个阶段:


握手阶段

认证阶段

请求阶段

relay 阶段

第一个握手阶段,浏览器会向 socks5 代理发送请求,包的内容包括一个协议的版本号和支持的认证的种类。


socks5 服务器会选中一个认证方式,返回给浏览器。如果返回的是 00 的话就代表不需要认证,返回其他类型的话会开始认证流程。


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


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


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


1、TCP echo server

在实现代理服务器之前,我们先实现一个简单的回显服务器,以测试我们的server写的对不对。


当运行时,此代码将创建一个简单的 TCP 服务器,监听在 127.0.0.1 的 1080 端口上,接受客户端连接并将客户端发送的数据原样返回。以下是逐句加注释的代码解释:



package main

import (
        "bufio"
        "log"
        "net"
)

func main() {
        // 在本地的 127.0.0.1:1080 地址上创建一个 TCP 服务器
        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
                }
                // 启动一个新的 goroutine 处理客户端连接
                go process(client)
        }
}

func process(conn net.Conn) {
        // 在函数退出时关闭客户端连接
        defer conn.Close()
        // 创建一个用于读取客户端数据的 bufio.Reader
        reader := bufio.NewReader(conn)
        for {
                // 从客户端读取一个字节
                b, err := reader.ReadByte()
                if err != nil {
                        break
                }
                // 将读取的字节原样发送回客户端
                _, err = conn.Write([]byte{b})
                if err != nil {
                        break
                }
        }
}

这是一个基本的 TCP 服务器,用于接受客户端连接并将接收到的数据返回给客户端。每当客户端发送一个字节,服务器会将相同的字节返回。


我们通过nc命令来进行测试。首先需要下载netcat,地址及教程:https://blog.csdn.net/BoomLee/article/details/102563472


安装配置完毕后,Goland中启动echo-server程序(直接启动或用命令go run echo-server.go)。启动后,另开一个cmd窗口,在其中输入nc命令:nc 127.0.0.1 1080


输入什么就显示什么,一个简单的回显服务器就搞定了。





2、认证阶段:auth



package main

import (
        "bufio"
        "fmt"
        "io"
        "log"
        "net"
)

const socks5Ver = 0x05
const cmdBind = 0x01
const atypeIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

func main() {
        server, err := net.Listen("tcp", "127.0.0.1:1080") // 在 127.0.0.1 的 1080 端口上创建 TCP 服务器
        if err != nil {
                panic(err)
        }
        for {
                client, err := server.Accept() // 接受客户端连接
                if err != nil {
                        log.Printf("Accept failed %v", err)
                        continue
                }
                go process(client) // 启动一个新的 goroutine 处理客户端连接
        }
}

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")
}




// +----+----------+----------+
// |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
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)

        // 发送认证响应给客户端
        // +----+--------+
        // |VER | METHOD |
        // +----+--------+
        // | 1  |   1    |
        // +----+--------+
        _, err = conn.Write([]byte{socks5Ver, 0x00}) // 发送无需认证的响应给客户端
        if err != nil {
                return fmt.Errorf("write failed:%w", err)
        }
        return nil
}

这段代码演示了一个简单的 SOCKS5 服务器,用于处理客户端的认证请求,并向客户端发送响应。现在我们用 curl 命令进行一下测试。首先还是一样,在Goland运行项目程序。然后在另一个终端执行curl命令:


curl --socks5 127.0.0.1:1080 -v http://www.qq.com

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




3、请求阶段

接下来我们开始做第三步:请求阶段。


我们试图读取到携带 URL 或 IP 地址+端口的包,然后把它打印出来。auth 函数和 connect 函数类似,同样在 process 里去调用。


再实现 connect 函数的代码。根据请求阶段的逻辑,浏览器会发送一个包,包里面包含如下6个字段:


VER 版本号,socks5的值为0x05


CMD 0x01表示CONNECT请求


RSV 保留字段,值为0x00


ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。


0x01表示IPv4地址,DST.ADDR为4个字节

0x03表示域名,DST.ADDR是一个可变长度的域名

DST.ADDR 一个可变长度的值


DST.PORT 目标端口,固定2个字节


接下来我们要挨个去把这6个字段读出来。


面这四个字段总共四个字节,我们可以一次性把它读出来。创建一个长度为4的缓冲区,然后用io.ReadFull()把它整个填充满。


//connect()

buf := make([]byte, 4)
_, err = io.ReadFull(reader, buf)
if err != nil {
    return fmt.Errorf("read header failed:%w", err)
}

这样就能一次性读取到前面4个字段,它们是定长的。对于每个字段,都要验证合法性:


//connect()

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", cmd)
}
addr := ""
switch atyp {
case atypeIPV4:
    _, 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")
}

最后还有两个字节是 port ,我们读取它,然后按协议规定的大端字节序转换成数字。


由于上面的 buffer 已经不会被其他变量使用了,我们可以直接复用之前的内存,建立一个临时的 slice ,长度是2,用于读取,这样的话最多会只读两个字节回来。 接下来我们把这个地址和端口打印出来用于调试。


//connect()

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


收到浏览器的这个请求包之后,我们需要返回一个包,这个包有很多字段,但其实大部分都不会使用:




运行测试:虽然还没有完全成功,但是能够打印出IP地址和端口号了,说明实验还是成功的。




4、relay阶段

直接用 net.Dial 建立一个 TCP 连接。建立完连接之后,同样要加一个 defer 来关闭连接。


接下来需要建立浏览器和下游服务器的双向数据转发。标准库的 io.Copy 可以实现一个单向数据转发。完成双向转发还需启动两个 goroutinue。


完整代码如下


package main

import (
    "bufio"
    "context"
    "encoding/binary"
    "errors"
    "fmt"
    "io"
    "log"
    "net"
)

const socks5Ver = 0x05
const cmdBind = 0x01
const atypeIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

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)
    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", cmd)
    }
    addr := ""
    switch atyp {
    case atypeIPV4:
       _, 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
}




到此,socks5代理服务器就实现完成了。


也可以在浏览器中进行测试。Chrome浏览器只需安装SwitchyOmega插件:




可以在浏览器里面再测试一下,在插件中新建一个情景模式, 代理服务器选 socks5,端口 1080 ,保存并启用。此时你正常地访问其它网站,代理服务器这边会显示出浏览器版本的域名和端口。



至此,Go语言入门的三个实战项目就完成了。

相关文章
|
19天前
|
监控 数据管理 开发者
构建高效微服务架构:后端开发的现代实践
【5月更文挑战第30天】 在当今软件开发领域,微服务架构已成为提高系统可维护性、扩展性和开发效率的关键方案。本文深入探讨了构建高效微服务架构的策略,包括服务划分原则、通信机制、数据管理以及持续集成与部署的最佳实践。通过分析具体案例和最新技术趋势,文章旨在为后端开发者提供一套全面的指导,帮助他们在不断变化的技术环境中保持竞争力。
|
1天前
|
弹性计算 缓存 安全
云服务器 ECS产品使用问题之如何解决阿里云幻兽帕鲁服务器游戏版本不兼容
云服务器ECS(Elastic Compute Service)是各大云服务商阿里云提供的一种基础云计算服务,它允许用户租用云端计算资源来部署和运行各种应用程序。以下是一个关于如何使用ECS产品的综合指南。
|
1天前
|
弹性计算 安全 定位技术
云服务器 ECS产品使用问题之在幻兽帕鲁计算机巢管理控制台修改游戏配置参数时一直显示变配失败,该如何解决
云服务器ECS(Elastic Compute Service)是各大云服务商阿里云提供的一种基础云计算服务,它允许用户租用云端计算资源来部署和运行各种应用程序。以下是一个关于如何使用ECS产品的综合指南。
|
6天前
|
负载均衡 搜索推荐 应用服务中间件
后端开发中的微服务架构设计与实践
传统的单一应用架构已经无法满足当今快速变化的业务需求,微服务架构因其灵活性和扩展性逐渐成为后端开发的主流选择。本文将探讨微服务架构设计与实践,包括微服务架构的概念、优势以及在后端开发中的应用。同时,将结合实际案例分析微服务架构的设计原则和最佳实践,以帮助开发者更好地理解和应用微服务架构。
|
13天前
|
运维 Kubernetes 持续交付
构建高效后端:微服务架构的设计与实践
本文深入探讨了微服务架构的设计原则和实践方法,旨在为读者提供一套完整的微服务开发指南。通过分析微服务的核心优势,如灵活性、可扩展性与独立部署能力,文章详细阐述了如何有效规划服务边界、选择合适的通信协议以及确保服务的高可用性和弹性。此外,还讨论了在微服务实施过程中可能遇到的挑战,包括数据一致性和服务发现机制,以及如何通过现代技术栈和最佳实践来克服这些挑战。
|
18天前
|
消息中间件 缓存 负载均衡
构建高性能的后端服务:优化策略与实践
在当今互联网时代,构建高性能的后端服务至关重要。本文将深入探讨如何通过优化策略与实践来提升后端服务的性能。我们将从数据库优化、缓存策略、异步处理和负载均衡等方面展开讨论,帮助开发者构建出稳定、高效的后端架构。
18 2
|
20天前
|
项目管理 微服务
拥抱不确定性:技术实践中的敏捷思维构建高效微服务架构:后端开发的新趋势
【5月更文挑战第29天】 在快速变化的技术世界中,不确定性已成为常态。本文探讨了如何在技术实践中运用敏捷思维来应对不确定性,提出了一套实用的策略和心态调整方法。通过案例分析,展示了在项目开发、系统设计以及团队协作中如何有效地应用敏捷原则,以适应需求变动、技术演进和市场波动。文章强调了持续学习、灵活适应和以人为本的管理对于维持技术实践敏捷性的重要性,旨在为技术人员提供一种面对不断变化环境的心智工具箱。
|
20天前
|
缓存 安全 API
构建高效RESTful API的后端实践
【5月更文挑战第29天】 随着移动和Web应用的兴起,后端服务在软件架构中扮演着愈发重要的角色。本文深入探讨了构建高效RESTful API的实践方法,包括设计原则、性能优化技巧以及安全性考虑。文中不仅阐述了理论知识,还结合实例分析,旨在为开发者提供一套实用的后端开发指南。
|
20天前
|
存储 监控 API
构建高效微服务架构:后端开发的现代实践
【5月更文挑战第29天】 随着云计算和容器化技术的兴起,微服务架构已成为企业级应用开发的主流选择。本文探讨了构建一个高效、可扩展且易于维护的微服务系统的关键策略和技术。我们深入分析了服务划分原则、API网关的作用、服务发现机制以及持续集成与部署的重要性。通过采用这些最佳实践,后端开发人员可以确保他们的系统不仅能够应对当前的需求,而且还能灵活适应未来的挑战。
|
21天前
|
存储 关系型数据库 MySQL