Go的实战,字典爬虫、socks5代理服务器| 青训营笔记

110 阅读8分钟

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

Go语言实战案例

猜数字游戏

func TestTestMain(t *testing.T) {
   maxNum:=100
   num := rand.Intn(maxNum)
   println(num)
}

测试执行发现随机数并不随机,执行出的结果一直为81。出现这种结果的原因是,没有为随机数设置随机数种子。在使用随机数之前使用rand.Seed(time.Now().UnixNano())来设置随机数种子,一般在使用时使用,启动时间戳来初始化随机数种子。

完整实例

func TestTestMain(t *testing.T) {
   maxNum := 100
   rand.Seed(time.Now().UnixNano())
   num := rand.Intn(maxNum)
   println(num)

   println("Please input your guess")
   for {

      reader := bufio.NewReader(os.Stdin)
      readString, err := reader.ReadString('\n')
      if err != nil {
         println("input error")
      }
      input := strings.TrimSuffix(readString, "\n")
      inputNum, err := strconv.Atoi(input)
      if err != nil {
         println("parse error")
      }
      if inputNum == num {
         println("yes")
         break
      } else if inputNum > num {
         println("you guess big")
      } else if inputNum < num {
         println("you guess small")
      }

   }
}

我们使用bufio.NewReader(os.Stdin)来实现输入。因为输入的内容带'\n'。所以要去掉后面的'\n',如果输入不正确程序要一直循环,直到输入正确。

字典查询

输入要查询单词可以直接得到,翻译和发音。

func main() {

   translation("small")
}

func translation(str string)  {
   client := &http.Client{}
   var data = strings.NewReader(`{"trans_type":"en2zh","source":"`+str+`"}`)
   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,no;q=0.5")
   req.Header.Set("app-name", "xy")
   req.Header.Set("content-type", "application/json;charset=UTF-8")
   req.Header.Set("device-id", "")
   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 resp.Body.Close()
   bodyText, err := ioutil.ReadAll(resp.Body)
   if err != nil {
      log.Fatal(err)
   }
   v := &result{}
   err = json.Unmarshal(bodyText, v)
   v.getDict()
}

type result struct {
   Dictionary struct{
      Entry        string `json:"entry"`
      Explanations []string `json:"explanations"`
      Prons struct{
         En string `json:"en"`
         EnUs string `json:"en-us"`
      }
   }
}

func (r result)getDict(){
   fmt.Printf("单词:%s\n翻译:%s\n美式发音:%s\n英式发音:%s\n",
      r.Dictionary.Entry,
      strings.Join(r.Dictionary.Explanations,"\t\n"),
      r.Dictionary.Prons.En,r.Dictionary.Prons.EnUs)
}

字典爬虫,需要在彩云翻译获取api 彩云小译 - 在线翻译,打开控制台在请求链接中找到dict请求,找到的dict请求的请求头复制为cmd格式

image.png 然后到curlconverter.com/go/ 将请求转换为go语言请求方式。这个网站会给我们自动的进行go请求的生成。

image.png 请求返回json字符串,对json字符串进行解析,由于go为强语言我们只能通过,让json解析为结构体。所以我们需要创建相应的结构体。提供结构体解析json,会给结构体赋值。对得到的结构体再进行打印就完成了。

image.png 对于根据json生成相应的结构体可以使用,goland的type json模式可以通过json来生成结构体。

socks5 代理

socks代理在互联网初期就已经存在。在企业中防火墙非常严格,包括管理员访问都很麻烦。通过socks就相当于开了一个后门。可以通过socks的代理来进行访问。在实际的网络开发中暴露出的接口就是socks代理的,提供给浏览器使用。 包括网络不好的时候使用加速器也是通过socks5协议来进行传输的。

image.png

socks5原理

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

socks代理请求需要多个阶段

  1. 协商阶段:客户端会向socks服务器发送报文,报文包含协议的版本和支持的认证方式
  2. socks代理服务器收到请求后会将支持的认证方式返回到客户端。如果返回的是00的话就代表不需要认证。返回其他类型就会开始认证流程。
  3. 如果代理服务器不需要认证,客户端将直接向代理服务器发送真实请求
  4. 代理服务器收到该请求之后连接客户端请求的目标服务器
  5. 代理服务器开始转发客户端与目标服务器之间的流量。

代理服务器实现较为复杂。我们可以先实现一个echo server我们发送的内容和回复的内容一致。

func main() {
   listen, err := net.Listen("tcp", "127.0.0.1:1883")
   if err != nil {
      println("启动失败")
   }
   for {
      accept, err := listen.Accept()
      if err != nil {
         continue
      }
      go process(accept)
   }
}

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

   }
}

先使用net.Listen创建一个TCP服务。然后在一个死循环里监听accept,每多一个连接就会返回一个accept连接,接下来在process函数里面去处理这个连接。前面的go 就是启动一个goroutinue,可以暂时类比为其他语言里的启动一个子线程。这样的线程开销很小可以轻松的处理上万的并发。

在连接初会先加一个defer connection.close(),defer 是Golang里面的一个语法,这一行的含义就是代表在这个函数退出的时候要把这个连接关闭,否则会有连接泄露。

接下来使用bufio.NewReader来创建一个带缓冲区的只读流。bufio和Java的buffer和java的Netty框架的Buf类似。带缓冲的流的作用是,可以减少底层系 统的调用次数。带缓冲的流也会有更多的工具函数用来读取数据。

在读取到数据后再通过conn写回。这样一个响应服务器就完成了。

const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 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", 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
}

第一步认证阶段

创建auth函数第一个参数是只读流,第二个是tcp连接。认证的报文会有三个字段,第一个字段是version协议的版本号,第二个字段是认证方法的数量,第三个是所对应的认证方法。通过第二个字段读取到的methodSize来创建缓冲区,然后通过io.ReadFull方法读取到方法数据。

按照协议规则我们需要返回一个包,说明我们使用那种认证方式。返回两个字节第一个是协议版本号,第二个是协议的方法0x00说明不需要认证。

第二步建立连接

前四个字节分别为版本号、cmd请求类型0x01代表connect请求、RSV保留字段、目标地址类型。先读取四个字节进行判断解析,目标地址类型0x01表示ipv4类型长度为4个字节,0x03表示域名长度为可变。最后两个字节表示为端口号。

如果ipv4就直接读取四个字节。如果是不定长度域名,先读取一个字节代表域名长度,然后通过域名长度创建缓冲区读取域名数据。

解析出ip与端口号后使用net.Dial与远程服务器建立Tcp连接。得到dest连接。使用defer dest.close在函数返回之前执行此方法。通过io.copy实现流的双向copy。如果双方任何连接出现失败,就把连接关闭。