Go语言基础——基础实战项目 | 青训营笔记

91 阅读12分钟

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

一:猜数字游戏

1:生成随机数

func main() {
   max := 100
   rand.Seed(time.Now().UnixNano()) //必须初始化随机数种子,这里用时间戳。否则多次生成的随机数相同
   result := rand.Intn(max) //获取随机数
   fmt.Println(result)
}

rand的范围依然是包头不包尾

2:读取输入

fmt.Println("请输入猜想")
reader := bufio.NewReader(os.Stdin)   //用buffer io 的newReader方法将os.stdin打开的文件转换为一个只读的流
input, err := reader.ReadString('\n') //从该流中读取一行,读取数据会默认带有换行符
if err != nil {
   fmt.Println("错误", err)
   return
}
input = strings.TrimSuffix(input, "\n") //去掉换行符
guess, err := strconv.Atoi(input)       //把string转换为int
if err != nil {
   fmt.Println("输入不合法")
   return
}
fmt.Println("你的猜想为", guess)

将读取的文件转换为流是为了能对该文件有更多方便的操作

3:判断逻辑实现

if guess > result {
   fmt.Println("超出范围")
} else if guess < result {
   fmt.Println("低于范围")
} else {
   fmt.Println("回答正确")
}

4:实现循环操作

通过for循环,使程序在未猜准结果时能循环运行,猜准以后直接break

func main() {
   max := 100
   rand.Seed(time.Now().UnixNano())
   result := rand.Intn(max)
   fmt.Println(result)

   fmt.Println("请输入猜想")
   reader := bufio.NewReader(os.Stdin) //用buffer io 的newReader方法将os.stdin打开的文件转换为一个只读的流
   //这个只读的流只需要创建一次,不需要进入循环
   for {
      input, err := reader.ReadString('\n') //从该流中读取一行,读取数据会默认带有换行符
      if err != nil {
         fmt.Println("错误", err)
         continue //如果出错,不能return结束,而应该重新循环
      }
      input = strings.TrimSuffix(input, "\r\n") //去掉换行符
      // 注意win系统中\r是换行,\n是回车,两者共同作用换行并回车,需要一起去掉,否则string转int失败
      guess, err := strconv.Atoi(input) //把string转换为int
      if err != nil {
         fmt.Println("输入不合法", err)
         continue
      }
      fmt.Println("你的猜想为", guess)
      if guess > result {
         fmt.Println("超出范围")
      } else if guess < result {
         fmt.Println("低于范围")
      } else {
         fmt.Println("回答正确")
         break //猜对以后直接break结束循环
      }
   }
}

注意点:
①只读的流只需要创建一次,类似java中的bufferreader,只创建一次,后续直接用br变量多次读取输入即可
strings.TrimSuffix(input, "\r\n") 去掉换行符时,win系统需要同时去掉\r\n,否则后面转换类型失败

二:在线词典

要点:用golang发送http请求,解析json,使用代码生成提高开发效率

1:抓包

image.png
当输入单词点击翻译时,前端以post方式发送一个json请求,请求内容包括要查询的单词和翻译方式:

image.png
在预览里还可以看到更详细的信息比如释义等:

image.png

2:代码生成

复制该请求的url:

image.png

复制为cmd和bash的区别:Bash是基于CMD的,Bash在CMD的基础上新增了一些命令和功能,故建议使用Bash更方便

  -H "authority: api.interpreter.caiyunai.com" ^
  -H "accept: application/json, text/plain, */*" ^
  -H "accept-language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6" ^
  -H "app-name: xy" ^
  -H "content-type: application/json;charset=UTF-8" ^
  -H "device-id;" ^
  -H "dnt: 1" ^
  -H "origin: https://fanyi.caiyunapp.com" ^
  -H "os-type: web" ^
  -H "os-version;" ^
  -H "referer: https://fanyi.caiyunapp.com/" ^
  -H "sec-ch-ua: ^\^"Not?A_Brand^\^";v=^\^"8^\^", ^\^"Chromium^\^";v=^\^"108^\^", ^\^"Microsoft Edge^\^";v=^\^"108^\^"" ^
  -H "sec-ch-ua-mobile: ?0" ^
  -H "sec-ch-ua-platform: ^\^"Windows^\^"" ^
  -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/108.0.0.0 Safari/537.36 Edg/108.0.1462.76" ^
  -H "x-authorization: token:qgemv4jr1y38jyq6vhvi" ^
  --data-raw "^{^\^"trans_type^\^":^\^"en2zh^\^",^\^"source^\^":^\^"good^\^"^}" ^
  --compressed

进入(Convert curl to Go (curlconverter.com))网址,进行cURL到go代码的转换并复制到编译器运行:

func main() {
   client := &http.Client{}
   //data作为string使用,用流读取
   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("authority", "api.interpreter.caiyunai.com")
   req.Header.Set("accept", "application/json, text/plain, */*")
   req.Header.Set("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6")
   req.Header.Set("app-name", "xy")
   req.Header.Set("content-type", "application/json;charset=UTF-8")
   req.Header.Set("device-id", "")
   req.Header.Set("dnt", "1")
   req.Header.Set("origin", "https://fanyi.caiyunapp.com")
   req.Header.Set("os-type", "web")
   req.Header.Set("os-version", "")
   req.Header.Set("referer", "https://fanyi.caiyunapp.com/")
   req.Header.Set("sec-ch-ua", `"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"`)
   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/108.0.0.0 Safari/537.36 Edg/108.0.1462.76")
   req.Header.Set("x-authorization", "token:qgemv4jr1y38jyq6vhvi")
   //发起请求
   resp, err := client.Do(req)
   if err != nil {
      log.Fatal(err)
   }
   //用defer手动关闭响应流
   defer resp.Body.Close()
   //读取响应,bodyText是字符串数组
   bodyText, err := ioutil.ReadAll(resp.Body)
   if err != nil {
      log.Fatal(err)
   }
   fmt.Printf("%s\n", bodyText)
}

运行结果:

image.png

3:生成request body

目的:此时抓包和生成的代码是死的,需要改造为根据输入动态查询翻译结果
步骤:序列化json,用结构体构造和json格式一样的结构体,用json的marshal将结构体序列化,返回一个字节数组,然后用newreader流读取该字节数组赋值给data

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

创建结构体,注意tag是``反引号包住

client := &http.Client{}
//创建结构体并初始化
request := DictRequest{TransType: "en2zh", Source: "good"}
//将结构体序列化
buf, err := json.Marshal(request)
if err != nil {
   log.Fatal(err)
}
//读取流,序列化后的buf为字节数组
var data = bytes.NewReader(buf)
//创建请求
req, err := http.NewRequest("POST", "https://api.interpreter.caiyunai.com/v1/dict", data)
if err != nil {
   log.Fatal(err)
}

用序列化后的json数据创建请求,此时运行结果和之前一样

4:解析response body(反序列化)

image.png
将图中响应体中的部分有用数据(比如explanations)挑出来,依然是采用结构体变量和响应数据变量一一对应的方式来讲响应体中反序列化的结果输出到结构体中

利用网站JSON转Golang Struct - 在线工具 - OKTools将预览中的json转换为go代码的结构体

image.png
图中转换展开会生成一个个独立的结构体,这里用嵌套,生成一个整体的结构体

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

修改原来的读取响应代码:

//发起请求
resp, err := client.Do(req)
if err != nil {
   log.Fatal(err)
}
//用defer手动关闭响应流
defer resp.Body.Close()
//读取响应,bodyText是字符串数组
bodyText, err := ioutil.ReadAll(resp.Body)
if err != nil {
   log.Fatal(err)
}
//定义结构体
var dictResponse DictResponse
//将响应的json数组反序列化到结构体里,注意这里需要用指针操作
err = json.Unmarshal(bodyText, &dictResponse)
if err != nil {
   log.Fatal(err)
}
//用%#v详细输出结构体的内容
fmt.Printf("%#v\n", dictResponse)

此时的输出结果:

image.png

5:打印结果

当前结果中很多字段并不需要,只需要音标、释义,需要自己找到这些字段并输出出来:

//定义结构体
var dictResponse DictResponse
//将响应的json数组反序列化到结构体里,注意这里需要用指针操作
err = json.Unmarshal(bodyText, &dictResponse)
if err != nil {
   log.Fatal(err)
}
//摘取需要的字段进行输出
fmt.Println("UK:", dictResponse.Dictionary.Prons.En, "US:", dictResponse.Dictionary.Prons.EnUs)
//利用for循环+range对释义的数组进行遍历
for _, item := range dictResponse.Dictionary.Explanations {
   fmt.Println(item)
}

同时,之前response是直接接收使用的,需要补上对于状态码的检查:

//读取响应,bodyText是字符串数组
bodyText, err := ioutil.ReadAll(resp.Body)
if err != nil {
   log.Fatal(err)
}
//对response进行安全检查,如果不是200,要把状态码和错误原因log出来以便检查
if resp.StatusCode != 200 {
   log.Fatal("bad statusCode:", resp.StatusCode, "body", string(bodyText))
}

6:完善代码

将之前的硬代码改为动态代码,将之前的代码封装为一个query函数,其中dictrequest的source改为动态变量word

func query(word string) {
   client := &http.Client{}
   //创建结构体并初始化
   request := DictRequest{TransType: "en2zh", Source: word}  
   

main函数修改为动态读取命令行输入:

func main() {
   //检查输入是否是一个单词
   //用os.args的len检查,该函数返回一个数组,第一个值为文件的绝对路径,第二个值为单词,如果len!=2,表示没有输入单词
   if len(os.Args) != 2 {
      fmt.Fprintf(os.Stderr, `usage:simpleDict WORD 
example:simpleDict hello`)
      os.Exit(1)
   }
   //如果没出错,则数组的第二个值就是要查询的单词
   word := os.Args[1]
   query(word)
}

注意,这种写法必须用cmd黑框指令运行,os.args才可以读到参数,不能用goland直接运行

image.png
如果要用goland直接运行,可以这样写:

func main() {
   reader := bufio.NewReader(os.Stdin)
   input, err := reader.ReadString('\n') //从该流中读取一行,读取数据会默认带有换行符
   if err != nil {
      fmt.Println("错误", err)
      return
   }
   input = strings.TrimSuffix(input, "\r\n") //去掉换行符
   query(input)
}

采用流的方式读取输入

image.png

7:项目地址

在线词典(github.com/LongBob001/…)

三:SOCKS5代理服务器

1:原理

①不使用socks5代理时,浏览器访问网站的过程:
建立TCP连接(三次握手)-发送http请求-获取http响应
②使用socks5代理时,浏览器访问网站的过程:
浏览器与代理服务器建立TCP连接-代理服务器和访问服务器建立TCP连接
可分为4个阶段:
协商(握手)阶段: 浏览区向代理服务器发送报文(协议版本号、支持的协议等),代理服务器根据报文内容返回给浏览器建立连接
认证阶段:根据上一步,看是否需要认证,不加密不需要认证
请求阶段:浏览器向代理服务器发送报文-代理浏览器与实际浏览器建立TCP连接-实际浏览器返回响应
relay阶段:传输数据

image.png

2:先实现一个简单版的TCP echo server

功能:发送什么就回复什么

func main() {
   //侦听端口,返回一个server
   server, err := net.Listen("tcp", "127.0.0.1.1080")
   if err != nil {
      panic(err)
   }
   for {
      //接收一个请求,接收成功返回一个连接client
      client, err := server.Accept()
      if err != nil {
         log.Printf("accept failed %v", err)
         continue
      }
      //处理该连接,go可以理解为启动一个子线程来处理连接,但实际上比子线程开销更小
      go process(client)
   }
}
// 参数为一个连接
func process(conn net.Conn) {
   //关闭连接,使得连接和生命周期和函数的生命周期一致
   defer conn.Close()
   //基于该连接创建一个只读的流
   reader := bufio.NewReader(conn)
   for {
      //每次读一个字节b
      b, err := reader.ReadByte()
      if err != nil {
         break
      }
      //把该字节写入连接,一般情况write的参数为一个切片,这里是只写一个字节,所以这里要强转为切片
      _, err = conn.Write([]byte{b})
      if err != nil {
         break
      }
   }
}

运行时依然使用cmd,这里使用nc命令可以直接和一个端口建立连接,win需要先下载一下,详见 www.cnblogs.com/linyufeng/p…
先在goland运行程序:

image.png

然后用cmd命令行 nc 127.0.0.1 1080:实现发送什么返回什么的功能

image.png

3:认证阶段,使用auth函数鉴权

认证阶段浏览器发送的报文内容:三个字段
version:版本号,占一个字节,socks5固定为0x05
鉴权方式的个数,占一个字节
每个鉴权方式的编码:0表示不需要鉴权,2表示需要用户名密码鉴权

定义常量代表对应的状态码

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

auth函数:

func auth(reader *bufio.Reader, conn net.Conn) (err error) {
   //前两个字段都是1个字节,用readbyte读取一个字节即可
   ver, err := reader.ReadByte()
   if err != nil { //出现错误直接return,此时调用auth的process也会return结束
      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缓冲区然后readfull读满
   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和认证方式,这里0x00表示不需要认证
   _, err = conn.Write([]byte{socks5Ver, 0x00})
   if err != nil {
      return fmt.Errorf("write failed:%w", err)
   }
   return nil
}

修改process函数,调用auth函数

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

4:请求阶段

(1)该阶段功能:

读取浏览器发送的报文(包括url,ip+端口等内容),将其打印

(2)浏览器向代理服务器发送报文:共6个字段

①ver:0x05,1字节
②cmd:这里只支持connection请求,即让代理服务器和实际服务器创建连接,1字节
③RSV:保留字段,为0x00,1字节
④ATYP:目标地址类型,1字节
0x01表示IPv4,DST.ADDR为4个字节
0x03表示域名,DST.ADDR为可变长度的域名
⑤DST.ADDR:可变长度的值,第一个值为长度,第二个值为域名
⑥DST.PORT:端口号,2个字节

(3)代理服务器回复浏览器报文:共6个字段

①ver :socks版本,0x05,1字节
②REP:relay field,0表示成功,1字节
③RSV:保留字段,为0x00,1字节
④ATYP:目标地址类型,1字节
0x01表示IPv4,DST.ADDR为4个字节
0x03表示域名,DST.ADDR为可变长度的域名
⑤BND.ADDR:本项目功能不涉及,设0值,此时4个字节
⑥BND.PORT:本项目功能不涉及,设0值,2字节

(4)connect函数:

func connect(reader *bufio.Reader, conn net.Conn) (err error) {
   //这里不再采用逐个字节读取的方式,采用创建4字节的缓冲区直接读取前四个字段
   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:%w", ver)
   }
   if cmd != cmdBind {
      return fmt.Errorf("not supported cmd:%w", ver)
   }
   //开始读取第5个字段,不定量长度
   addr := ""
   switch atyp {
   case atypIPV4:
      //IPv4正好也是4个字节,所以还是用上面的4字节缓冲区填充
      _, 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:
      //HOST还是逐个字节读
      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:not supported yet")
   default:
      return errors.New("invalid atyp")
   }
   //最后一个字段端口号2字节,这里复用之前的4字节缓冲区,用切片截取前两个字节,变成2字节缓冲区
   _, err = io.ReadFull(reader, buf[:2])
   if err != nil {
      return fmt.Errorf("read port failed:%w", err)
   }
   //利用binary函数的大端字节序解析出整型数字
   port := binary.BigEndian.Uint16(buf[:2])
   //输出目的地址和端口号
   log.Println("dial", addr, port)
   //接受浏览器请求后要回复报文,根据回复报文字段的字节特征,一个字节1个值
   _, 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
}

(5)process函数修改:

//调用connect函数
err = connect(reader, conn)
if err != nil {
   log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
   return
}

(6)运行结果:

curl指令依然失败,但是控制台会输出访问地址的IP和端口

image.png

image.png

(7)win系统curl命令安装使用

下载curl,并添加到环境变量即可

5:relay阶段

connect函数需要修改两个地方:

//利用binary函数的大端字节序解析出整型数字
port := binary.BigEndian.Uint16(buf[:2])
------
//net.dial函数,利用tcp给目的IP和端口建立TCP连接
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)
//接受浏览器请求后要回复报文,根据回复报文字段的字节特征,一个字节1个值
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
   return fmt.Errorf("write failed:%w", err)
}
------
//go routine启动是不耗时的,会瞬间跳转到return结束连接,所以这里用context函数,保证只有当任意一方copy失败,即cancel了,此时才终止连接
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
   _, _ = io.Copy(dest, reader) //从浏览器copy到服务器
   cancel()                     //copy失败的时候调用cancel函数
}()
go func() {
   _, _ = io.Copy(conn, dest) //从服务器copy到浏览器
   cancel()
}()
//当context函数完成后,即cancel函数被调用时,关闭连接
<-ctx.Done()
------
return nil

6:效果展示

首先goland启动服务器;
使用chrome浏览器的switchyOmega插件:

@OWJK0J{_3SZUUQKL9[96NU.png 此时访问网站即都是通过代理服务器访问的,此时goland控制台则输出访问域名和端口号: [S9ZAU)TUMXR2}A)KBESCCT.png

7:项目地址

socks5服务器实现(github.com/LongBob001/…)