GO - 基础语法 | 青训营笔记

53 阅读13分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第1天


GO - 基础语法

概述

  1. 语言简介
  2. 开发入门(语言及环境配置,基础语法,标准库)
  3. 基本语法
  4. 实战项目

语言简介

特点

  1. 高性能、高并发
  2. 语法简单、学习曲线平缓
  3. 标准库丰富
  4. 工具链完善
  5. 静态链接
  6. 快速编译
  7. 跨平台
  8. GC

优势

  • 几乎所有的云原生组件都使用Go开发
  • 多个领域拥有较高的市场占有率 (云计算、微服务、大数据、区块链、物联网、etc.)

开发入门

安装Go Runtime

按照 go.dev 的提示进行安装即可

配置Go

注意配置环境变量GOPROXY,将值设置为https://goproxy.cn

具体配置方法可参考七牛云 提供的教程

选择开发环境

编辑器方面,推荐使用VS Code (插件生态丰富);
IDE方面,推荐使用Goland (在重构、代码生成等方面比较好)

基础语法

可参考的学习材料

Hello World

package main

import (
    "fmt"
)

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

代码解析

  1. 表达式后无需添加分号;若需要在一行内执行 多个 表达式,则需要使用分号
  2. package main 表示文件属于 main 包的一部分,也就是程序的入口包
  3. import "fmt" 表示导入 fmt 包,用于输入输出格式化字符串。当一次性导入多个包时,可以使用括号,一个包占一行,使用 逗号 进行分隔
  4. func main() 为程序入口函数

代码运行/编译

  1. 运行:执行命令 go run [yourFileName].go
  2. 编译二进制文件:执行命令 go build [yourFileName].go ,完成编译后运行可执行二进制文件即可

可以进入 pkf.go.dev/[packageName] 查看对应包的在线文档

变量类型

Go为强类型语言,运算符使用和优先级与大多数编程语言类似。

基本数据类型

  • 整型
  • 浮点
  • 复数
  • 布尔
  • 字符串 (可使用 + 进行字符串拼接,也可使用 = 进行比较)
  • 常量 (常量没有确定类型,会根据上下文自动推导)

复合数据类型

  • 数组
  • Slice
  • Map
  • 结构体
  • JSON
  • 文本和HTML模板

复合数据类型的0值(zero value)均为 nil ,并非其他编程语言常用的 null

变量声明方式

  • var [name] [type] = [value]
  • [name] := [value] (自动推导数据类型)
  • const [name] = [value] (声明常量)

Go支持同时声明或定义多个变量

  • var a, b = int
  • c, d := 3, "hello"

逻辑控制

条件分支 (if-else)

语法类似于C/C++,区别为:

  1. if 的判断条件无需括号
  2. if 判断后的执行代码块不允许省略大括号
  3. 实际开发中,if 的判断条件中会执行多条语句

条件分支 (switch)

swich 中可以使用任何的变量类型,甚至可以取代任意的 if-else 。可在switch手不添加任何的变量,然后在case内写条件分支,会比多个 if-else 更为清晰。其他需要注意的事项为:

  1. 判断条件无需括号
  2. case中无需break,若需要单次执行后面的case,可以使用 fallthrough (此情况下,不会判断下一个case的表达式的值)

循环 (for)

Go中仅提供 for 循环,同样的:

  1. 判断条件无需括号,若无判断条件则为死循环
  2. 类似于 if-else 中的第三点,for 中可以实现经典的C循环:for i := 0; i < 4; i++
  3. 可以使用 breakcontinue 跳出循环

数组

因为其长度固定,所以在实际开发中更常使用 Slice

  • 创建空数组:var a [5]int
  • 创建数组同时初始化值:b := [5]int{1, 2, 3, 4, 5}

Slice

Slice 的长度不固定,因为其存储了长度,容量以及指向数组的指针 (所以在创建时可以指定初始长度);当容量不足时,Slice会自动扩容,并返回新的Slice。而且,Slice 可使用的方法更多:

  • 使用 make 创建Slice:s := make([]int, 5)
  • 使用 append 追加新元素:s = append(s, 6)
  • 可进行切片操作 (类似于Python,但是不支持 步长负数范围 )

Map

使用键值对进行数据存储,无序,并且在遍历时为随机顺序

  • 创建方式:var a = make(map[string]int)
  • 定义方式:b := map[string]int{"one":1, "two":2}
  • 删除键值对:delete(b, "two")

Range

对于Slice或Map,可以使用 Range 快速遍历。对于数组和Map,Ragne会返回两个值:IndexValue;若不需要其中某个值,可以使用 _ 表示忽略该值

nums := []int{1, 2, 3}
for _, num := range nums {
    ...
}

函数

Go的函数返回值类型是后置的,而且原生支持返回多个值 (参考变量声明时可以声明多个变量)。在实际的开发中,几乎所有的业务逻辑的函数代码都返回两个值:第一个为结果,第二个为错误信息

func exists(m map[string]string, k string) (v string, ok bool) {
    v, ok = m[k]
    return v, ok
}

func main() {
    v, ok := exists(map[string]string{"a":"A"}, "a")
}

指针

指针的主要用途之一:对于传入参数进行修改

func add2(n int) {
    n += 2
}

func add2ptr(n *int) {
    *n += 2
}

func main() {
    n := 1
    add2(n)
    print(n)    // 1
    add2ptr(&n)
    print(n)    // 3
}

结构体

带类型的字段的集合:首字母为大写的字段为公开字段,小写的为私有字段。

结构体同样支持指针 (避免较大结构体的拷贝开销)。构造时需要传入初始值 (全部初始化或部分初始化,未初始化的字段均为“0值” - zero value)。访问结构体内字段使用 . 运算符。

type user struct {
    name        string  // private field
    password    string
    Id          int64   // public field
}

func main() {
    a := user{name:"zhao", password:"hello"}
    b := user{password: "hello"}
    c := user{"zhao", "hello"}
    d := user{"zhao"}

    print(a.name)       // "zhao"
    print(b.name)       // ""
    print(c.password)   // "hello"
    print(d.password)   // ""
}

结构体方法

结构体中可以定义方法,但需要在结构体外部进行定义。其中:

  1. 定义结构体方法的结构体变量为指针时,实际操作的是结构体本身
  2. 定义结构体方法的结构体变量不为指针时,实际操作的为结构体的拷贝
type user struct {
    name        string
    password    string
}

func (u user) checkkPsw(password string) bool {
    return u.password == password
}

func (u *user) resetPsw(password string) {
    u.password = password
}

func main() {
    a := user{"zhao", "hello"}
    a.resetPsw("world")
    print(a.checkPsw("world"))  // true
}

错误处理

在Go中,error 作为一个接口类型来处理程序中的错误情况。不同于Java中的 try-catch 机制,在Go中只需 if-else 即可。

在函数中,如果出现了不符合流程的情况,可以使用 errors.New() 创建 error 并返回;当正常完成流程后,可以返回 nil

func findUser(users []user, name string) (v *user, err error) {
    for _, u := range users {
        if u.name == name {
            return &u, nil
        }
    }
    return nil, errors.New("not found")
}

字符串操作

Go的标准库 strings 中包含了很多常用的字符串工具函数

  • strings.Contains()
  • strings.Count()
  • strings.HasPrefix()
  • strings.HasSuffix()
  • strings.Index()
  • strings.Join()
  • strings.Repeat()
  • strings.Replace()
  • strings.Split()
  • strings.ToLower()
  • strings.ToUpper()
  • etc.

字符串格式化

fmt 包中,有很多的字符串格式相关的函数;例如类似于C中的 printf,但在Go中,可以直接使用 %v 打印任意类型的变量,或 %+v 打印详细结果,而 #%v 会更加的详细

type point struct {
    x, y int
}

func main() {
    p := point{1, 2}
    fmt.Printf("p=%v", p)   // p={1 2}
    fmt.Printf("p=%+v", p)  // p={x:1, y:2}
    fmt.Printf("p=#%v", p)  // p=main.point{x:1, y:2}
}

JSON处理

Go标准库中提供了对JSON的解析处理工具。对于JSON数据,我们只需要创建对应的结构体即可 (快速生成结构体的工具)

结构体(公开字段)和JSON数据的转换,需要使用 json.Marshal()json.Unmarshal() 等方法。而 json.MarshalIndent() 等方法则是用于转换时进行字符串格式化

在转换过程中,如果JSON对象中变量名命名规则不符合Go中的变量命名规范,可以使用 JSONTAG 等语法进行处理 (语法讲解)

import (
    "encoding/json"
    "fmt"
)

type userInfo struct {
    Name string
    Age int `json:"age"`
    Hobby []string
}

func main() {
    a := userInfo{Name:"zhao", Age:18, Hobby: []string{"sing", "dance", "basketball"}}
    buf, err := json.Marshal(a)
    if err != nil {
        panic(err)
    }
    fmt.Println(buf)            // {123 34 78 97...}
    fmt.Println(string(buf))    // {"Name":"zhao", "age":18, ...}

    buf, err = json.MarshalIndent(a, "", "\t")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(buf))

    var b userInfo
    err = json.Unmarshal(buf, &b)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%#v\n", b)      // main.userInfo{Name:"zhao", Age:18, ...}
}

时间处理

  • 最常用的就是 time.now() 获取当前时间
  • time.date() 构造带时区的时间
  • 有很多方法可以获取时间点的年月日小时分钟秒
  • 可以使用 [time].sub 对两个时间进行剑法,得到时间段(同样可以获得时分秒)
  • 使用 [time].Unix() 获得时间戳
  • time.format()time.parse()

数字解析

字符串和数字转换的方法存在于 strconv 包中

  • strconv.ParseInt()strconv.ParseFloat()
  • strconv.Atoi()strconv.itoA
  • 输入不合法则返回 error

进程信息

  • 使用 os.Args 获取程序执行时指定的命令行参数 (第一个参数为程序地址)
  • 使用 os.Getenv() 读取环境变量, os.Setenv() 设置环境变量
  • exec 用于执行命令行指令

实战项目

猜谜游戏

复习的内容:变量循环、流程控制和错误处理

其实就是猜数字,类似于聚会游戏“数字炸弹”。将程序实现进行拆分:

  1. 生成随机数
  2. 读取用户输入
  3. 实现判断逻辑
  4. 实现游戏循环

随机数

Version 1:使用 math/rand 包的 rand.Intn() 生成随机数 (但是每次生成的数字都是相同的) Version 2:为生成随机数设置种子(比如程序启动的时间戳 - rand.Seed(time.Now().UnixNano()))

func main() {
    maxNum := 100
    rand.Seed(time.Now().UnixNano())
    secretNumber := rand.Intn(maxNum)
    fmt.Println("the secret number is ", secretNumber)
}

输入

程序执行时会打开多个输入输出流文件(stdin, stdout, stderr等)。其中 stdin 可通过 os.Stdin 获取,但直接操作并不方便,可以使用 bufio.NewReader 把文件转换成 reader 变量。reader 变量中有很多用来操作流的方法;例如使用ReadString 方法读取一行数据(返回结果包含结尾的换行符)

Unix系统里,每行结尾只有“<换行>”,即“\n”
Windows系统里面,每行结尾是“<换行><回车>”,即“\n\r”
Mac系统里,每行结尾是“<回车>”,即“\r”

func main() {
    ...

    reader := bufio.NewReader(os.Stdin)
    input, err := reader.ReaderString('\n')
    if err != nil {
        fmt.Println("err occured!", err)
        return
    }
    input = strings.TrimSuffix(input, "\n")

    guess, err := strconv.Atoi(input)
    if err != nil {
        fmt.Println("invalid input!")
        return
    }
    fmt.Println("you guess: ", guess)
}

判断

func main() {
    ...

    if guess > secretNumber {
        fmt.Println("your guess is greater.")
    } else if guess < secretNumber {
        fmt.Println("your guess is smaller.")
    } else {
        fmt.Println("correct!")
    }
}

实现循环

现在只实现了单次的猜测(无论结果如何,只接收一次用户输入)。我们可以把刚刚获取输入的逻辑放入一个死循环中,在用户猜测错误的时候,continue 继续获取输入;用户猜测正确的时候,break 退出程序

func main() {
    ...
    for {
        input, err := reader.ReaderString('\n')
        if err != nil {
            fmt.Println("err occured!", err)
            continue
        }
        input = strings.TrimSuffix(input, "\n")

        guess, err := strconv.Atoi(input)
        if err != nil {
            fmt.Println("invalid input!")
            continue
        }
        fmt.Println("you guess: ", guess)
        if guess > secretNumber {
            fmt.Println("your guess is greater.")
        } else if guess < secretNumber {
            fmt.Println("your guess is smaller.")
        } else {
            fmt.Println("correct!")
            break
        }
    }
}

命令行词典

学习的内容:发送HTTP请求,解析JSON,使用代码生成提高开发效率

使用命令行指令和参数运行程序,通过调用第三方的API查询单词的翻译,并打印出来。大致的开发流程:

  1. 抓包
  2. 代码生成
  3. 生成 request body
  4. 解析 request body
  5. 打印结果

抓包

彩云翻译 为例。首先进入浏览器开发者工具 - 网络,对网络请求进行录制;然后手动发送一次翻译请求,获取其用于翻译的HTTP协议报文。

HTTP请求的header相对复杂,键值对有十多个。而请求头是一个JSON对象,里面有两个字段:一个是代表语言的转换类型,另一个source是要查询的单词。

API的返回结果里面,会有WIKI和Dictionary两个字段。我们需要用的结果主要在Dictionary.Explanation字段里面(其他字段中还包含了音标等信息)

代码生成

因为手动生成和浏览器中一样的请求比较麻烦,可以在浏览器的开发者工具中的该网络请求列表项目上右键,选择copy as curl,然后在terminal中粘贴刚刚复制好的命令,执行后即可获取到结果的JSON数据

之后可以在这个网站中粘贴刚才的curl请求,由工具自动生成对应的Go HTTP请求代码即可。

func main() {
    // create HTTP client
	client := &http.Client{}  
    // set string to stream(data maybe huge)
	var data = strings.NewReader(`{"trans_type":"en2zh","source":"hello"}`)
    // create POST request
    // use read-only stream data in order to support streaming transport
	req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
	if err != nil {
		log.Fatal(err)
	}
    // set headers
	req.Header.Set("authority", "api.interpreter.caiyunai.com")
	...
    // send request
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
    // resp.Body is stream, it need to be closed manually
    // defer means it will be excute after function finished
	defer resp.Body.Close()
    // read response
    // use io.Readall() to read resp.Body(stream data)
	bodyText, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s\n", bodyText)
}

生成request body

在Go中生成JSON的常用方法是利用结构体的Marshal方法,而这个结构体和需要生成的JSON结构是一一对应的。

根据抓包中的结果,request body中需要三个字段:trans_type, source, user_id

不同于工具生成的代码中的字符串,使用 json.Marshal()生成的是字节数组,所以data需要从string.NewReader()生成改为bytes.NewReader()

type DictRequest struct {
    TransType string `json:"trans_type"`
    Source string `json:"source"`
    UserID string `json:"user_id"`
}

func main() {
    ...
    request := DictRequest{TransType:"en2zh", "source":"hello"}
    buf, err := json.Marshal(request)
    if err != nil {
        log.Fatal(err)
    }
    var data = bytes.NewReader(buf)
    ...
}

解析response body (JSON数据)

使用前面提到的快速生成JSON对应结构体工具,将获取到的数据生成对应的结构体;然后使用json.Unmarshal()将body反序列化到结构体中。

打印结果

最后根据我们需要的数据(Dictionary.Explanations),在控制台中打印出来即可。

程序优化

把代码主体改为 query() 函数,查询的单词作为参数传入。在 main() 函数中判断命令和参数的个数,如果不符合要求就打印错误信息并退出程序。

SOCKS5 代理

socks5为明文传输的代理协议,让授权的用户通过单个端口访问内部的所有资源
许多爬虫的代理IP池的很多代理的协议就是socks5

具体的实现过程:

  1. TCP echo server
  2. auth
  3. 请求阶段
  4. relay

测试代理服务器:curl --sock5 服务器IP -v 访问地址

协议原理

浏览器和代理建立TCP连接,代理再和真正的服务器建立TCP连接。整个过程分为四个阶段:

  1. 握手:浏览器向代理发送请求,包含协议版本号和支持的认证类型,代理会选择其中一种认证方式返回给浏览器。如果返回值为00,则无需认证;返回其他类型,就开始认证流程
  2. 认证:浏览器给代理发送数据(version,固定为5;methods,认证的方法数目;每个method的编码,0代表无需认证,2代表用户名密码认证)
  3. 请求:认证通过后,浏览器再次向代理发起请求,包含版本号和请求的类型(一般主要是connection请求,代表代理要和某个域名/IP建立TCP连接)。代理收到响应之后,会和服务器建立连接,然后返回一个响应
  4. relay:浏览器发送“正常发送”请求,代理收到后,会直接把请求转换到服务器上。如果服务器返回响应,就会把请求转发到浏览器。实际上,代理并不关心流量的细节(可以是HTTP,也可以是其他任何的TCP请求)

TCP echo server

server的逻辑:发送什么就回复什么

  1. 使用net.Listen()监听某个端口,将返回的server放在死循环中,每次去accepct一个请求,成功就返回一个连接。之后就在process函数中处理这个连接。
  2. process函数中,使用bufio.NewReader()创建带缓冲的只读流(可以减少system call的次数,而且很多工具函数也是使用带缓冲的流读取数据),再使用ReadByte()函数读取单个字节,把这个字节写进连接中。
func main() {
	server, err := net.Listen("tcp", "127.0.0.1:1080")
	if err != nil {
		panic(err)
	}
	for {
		client, err := server.Accept()
		if err != nil {
			log.Printf("accept failed %v", err)
			continue
		}
		go process(client)
	}
}

func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	for {
        b, err := reader.ReadByte()
        if err != nil {
            break
        }
        _, err = conn.Write([]byte{b})
        if err != nil {
            break
        }
    }
}

测试:运行命令 nc 127.0.0.1 1080,输入任意字符串,观察服务器是否返回数据

auth

实现一个空的auth函数,在process函数内部调用

  1. 使用ReadBytes()读取版本号,再读取method size(一个字节),创建一个长度相同的slice,使用io.ReadFull()把它填充进去。
  2. 返回response,包含两个字段:version和method,也就是选中的鉴传方式。当前并不实现鉴传,所以返回00
const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	err := auth(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
	log.Println("auth success")
}

func auth(reader *bufio.Reader, conn net.Conn) (err error) {
	ver, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read ver failed:%w", err)
	}
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}
	methodSize, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read methodSize failed:%w", err)
	}
	method := make([]byte, methodSize)
	_, err = io.ReadFull(reader, method)
	if err != nil {
		return fmt.Errorf("read method failed:%w", err)
	}
	log.Println("ver", ver, "method", method)

	_, err = conn.Write([]byte{socks5Ver, 0x00})
	if err != nil {
		return fmt.Errorf("write failed:%w", err)
	}
	return nil
}

测试:使用curl进行连接,打印出version和method即可(失败是正常的,因为并未完成协议实现)

请求

读取携带URL/IP并打印,实现connect()函数,同样在process调用

浏览器发送请求的具体内容:

  • version (版本号,固定为5)
  • command (代表请求的类型,项目中只实现connection请求,也就是代理建立新的TCP连接。值为1)
  • RSV (保留字段)
  • atype (目标地址类型,可能为IPv4/IPv6/域名)
  • addr (根据atype类型而不同)
  • port (端口号,两个字节)

根据上面的内容,connect()函数的实现:

  1. 前四个字段长度为四个字节,使用长度为4的buffer读取,再判断具体的值。
  2. atyp如果为IPv4,再次复用上面的buffer读取IP地址(IPv4地址长度为4个字节),最后保存至addr变量中;如果为host(域名),则重新创建一个长度相同的buffer并填充进去,最后转成字符串,保存在addr变量中;IPv6因为目前使用较少,项目中暂时不做支持。
  3. 最后用两个字节的buffer读取端口,按协议规定进行转换(大端字节序)。此处可以再次复用之前的buffer,将端口填充进去,并使用临时slice读取前两字节。
  4. 返回数据(共10字节):version(值为5);返回类型(请求成功则返回0);保留字段(0);atype(此处填1);剩下字段暂时不使用,直接返回0
func connect(reader *bufio.Reader, conn net.Conn) (err error) {
	buf := make([]byte, 4)
	_, err = io.ReadFull(reader, buf)
	if err != nil {
		return fmt.Errorf("read header failed:%w", err)
	}
	ver, cmd, atyp := buf[0], buf[1], buf[3]
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver: %v", ver)
	}
	if cmd != cmdBind {
		return fmt.Errorf("not supported cmd: %v", ver)
	}
	addr := ""
	switch atyp {
	case atypIPV4:
		_, err := io.ReadFull(reader, buf)
		if err != nil {
			return fmt.Errorf("read atyp failed:%w", err)
		}
		addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
	case atypeHOST:
		hostSize, err := reader.ReadByte()
		if err != nil {
			return fmt.Errorf("read hostSize failed:%w", err)
		}
		host := make([]byte, hostSize)
		_, err = io.ReadFull(reader, host)
		if err != nil {
			return fmt.Errorf("read host failed:%w", err)
		}
		addr = string(host)
	case atypeIPV6:
		return errors.New("IPv6: no supported yet")
	default:
		return errors.New("invaild atyp")
	}

	_, err = io.ReadFull(reader, buf[:2])
	if err != nil {
		return fmt.Errorf("read port failed:%w", err)
	}
	port := binary.BigEndian.Uint16(buf[:2])

    log.Println("dial", addr, port)

	_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
	if err != nil {
		return fmt.Errorf("write failed: %w", err)
	}

	return nil
}

测试:运行curl,打印出访问的地址和端口即可

relay

使用net.dial()建立TCP连接,最后使用defer关闭连接。之后建立浏览器和服务器的双向数据转发。

io.copy()可以实现一个单向数据转发;要实现双向转发,则需要两个 goroutinue

func connection(reader *bufio.Reader, conn net.Conn) (err error) {
    ...
    port := binary.BigEndian.Uint16(buf[:2])

	dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
	if err != nil {
		return fmt.Errorf("dial dst failed:%w", err)
	}
	defer dest.Close()

	log.Println("dial", addr, port)
    ...
}

现在有一个问题,connect()函数会立刻返回,返回的时候连接就被关闭了。需要等待任意一个方向copy出错的时候,再返回connect()函数。

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

func connection(reader *bufio.Reader, conn net.Conn) (err error) {
    ...
    _, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
	if err != nil {
		return fmt.Errorf("write failed: %w", err)
	}
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	go func() {
		_, _ = io.Copy(dest, reader)
		cancel()
	}()
	go func() {
		_, _ = io.Copy(conn, dest)
		cancel()
	}()

	<-ctx.Done()
	return nil
    ...
}

测试1:运行curl,此时应能成功响应
测试2:在浏览器内安装插件 SwitchOmega,新建情景模式,代理服务器选择 socks5,端口1080。此时应该还能正常的访问网站,代理这边可以显示出浏览器版本的域名和端口

课后作业

  1. 修改猜谜游戏的最终代码,使用fmt.Scanf简化代码实现
  2. 修改命令行词典的最终代码,增加另一种翻译引擎支持
  3. 在2的基础上,实现并行请求两个翻译引擎以提高响应速度