乐趣区

关于golang:实战150行Go实现高性能socks5代理

光说不练假把式,不如上手试试,这篇来写个有点卵用的货色。

TCP Server

用 Go 实现一个 TCP Server 切实是太简略了,什么 c10k problem、select、poll、epoll、kqueue、iocp、libevent,统统不须要( 但为了通过面试你还是得去看呀 ),只须要这样两步:

  • 监听端口 1080(socks5 的默认端口)
  • 每收到一个申请,启动一个 goroutine 来解决它

搭起这样一个架子,实现一个 Hello world,大概须要 30 行代码:

func main() {server, err := net.Listen("tcp", ":1080")
  if err != nil {fmt.Printf("Listen failed: %v\n", err)
    return
  }

  for {client, err := server.Accept()
    if err != nil {fmt.Printf("Accept failed: %v", err)
      continue
    }
    go process(client)
  }
}

func process(client net.Conn) {remoteAddr := client.RemoteAddr().String()
  fmt.Printf("Connection from %s\n", remoteAddr)
  client.Write([]byte("Hello world!\n"))
  client.Close()}

SOCKS5

socks5 是 SOCKS Protocol Version 5 的缩写,其标准定义于 RFC 1928[1],感兴趣的同学能够本人去翻一翻。

它是个二进制协定,不那么直观,不过实际上非常简单,次要分成三个步骤:

  • 认证
  • 建设连贯
  • 转发数据

咱们只需 16 行就能把 socks5 的架子搭起来:

func process(client net.Conn) {if err := Socks5Auth(client); err != nil {fmt.Println("auth error:", err)
    client.Close()
    return
  }

  target, err := Socks5Connect(client)
  if err != nil {fmt.Println("connect error:", err)
    client.Close()
    return
  }

  Socks5Forward(client, target)
}

这样一看是不是特地简略?

而后你只有把 Socks5Auth、Socks5Connect 和 Socks5Forward 给补上,一个残缺的 socks5 代理就实现啦!是不是就像画一匹马一样简略?

全文完 (不是)

Socks5Auth

言归正传,socks5 协定规定,客户端须要先闭口:

VER NMETHODS METHODS
1 1 1 to 255

(RFC 1928,首行是字段名,次行是字节数)

解释一下:

  • VER

    • 本次申请的协定版本号,取固定值 0x05(示意 socks 5
  • NMETHODS

    • 客户端反对的认证形式数量,可取值 1~255
  • METHODS

    • 可用的认证形式列表

咱们用如下代码来读取客户端的发言:

func Socks5Auth(client net.Conn) (err error) {buf := make([]byte, 256)

  // 读取 VER 和 NMETHODS
  n, err := io.ReadFull(client, buf[:2])
  if n != 2 {return errors.New("reading header:" + err.Error())
  }

  ver, nMethods := int(buf[0]), int(buf[1])
  if ver != 5 {return errors.New("invalid version")
  }

  // 读取 METHODS 列表
  n, err = io.ReadFull(client, buf[:nMethods])
  if n != nMethods {return errors.New("reading methods:" + err.Error())
  }

  //TO BE CONTINUED...

而后服务端得抉择一种认证形式,通知客户端:

  • VER

    • 也是 0x05,对上 SOCKS 5 的暗号
  • METHOD

    • 选定的认证形式;其中 0x00 示意不须要认证,0x02 是用户名 / 明码认证,……

简略起见咱们就不认证了,给客户端回复 0x05、0x00 即可:

  // 无需认证
  n, err = client.Write([]byte{0x05, 0x00})
  if n != 2 || err != nil {return errors.New("write rsp err:" + err.Error())
  }

  return nil
}

以上 Socks5Auth 总共 28 行。

Socks5Connect

在实现认证当前,客户端须要告知服务端它的指标地址,协定具体要求为:

VER CMD RSV ATYP DST.ADDR DST.PORT
1 1 X’00’ 1 Variable 2
  • VER

    • 0x05,老暗号了
  • CMD

    • 连贯形式,0x01=CONNECT, 0x02=BIND, 0x03=UDP ASSOCIATE
  • RSV

    • 保留字段,当初没卵用
  • ATYP

    • 地址类型,0x01=IPv4,0x03= 域名,0x04=IPv6
  • DST.ADDR

    • 指标地址,细节前面讲
  • DST.PORT

    • 指标端口,2 字节,网络字节序(network octec order)

咱们先读取前四个字段:

func Socks5Connect(client net.Conn) (net.Conn, error) {buf := make([]byte, 256)

  n, err := io.ReadFull(client, buf[:4])
  if n != 4 {return nil, errors.New("read header:" + err.Error())
  }

  ver, cmd, _, atyp := buf[0], buf[1], buf[2], buf[3]
  if ver != 5 || cmd != 1 {return nil, errors.New("invalid ver/cmd")
  }

  //TO BE CONTINUED...

注:BIND 和 UDP ASSOCIATE 这两个 cmd 咱们这里就先偷懒不反对了。

接下来问题是如何读取 DST.ADDR 和 DST.PORT。

如前所述,ADDR 的格局取决于 ATYP:

  • 0x01:4 个字节,对应 IPv4 地址
  • 0x02:先来一个字节 n 示意域名长度,而后跟着 n 个字节。留神这里不是 NUL 结尾的。
  • 0x03:16 个字节,对应 IPv6 地址
  addr := ""
  switch atyp {
  case 1:
    n, err = io.ReadFull(client, buf[:4])
    if n != 4 {return nil, errors.New("invalid IPv4:" + err.Error())
    }
    addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])

  case 3:
    n, err = io.ReadFull(client, buf[:1])
    if n != 1 {return nil, errors.New("invalid hostname:" + err.Error())
    }
    addrLen := int(buf[0])

    n, err = io.ReadFull(client, buf[:addrLen])
    if n != addrLen {return nil, errors.New("invalid hostname:" + err.Error())
    }
    addr = string(buf[:addrLen])

  case 4:
    return nil, errors.New("IPv6: no supported yet")

  default:
    return nil, errors.New("invalid atyp")
  }

注:这里再偷个懒,IPv6 也不论了。

接着要读取的 PORT 是一个 2 字节的无符号整数。

须要留神的是,协定里说,这里用了“network octec order”网络字节序,其实就是 BigEndian(还记得咱们在《UTF-8:一些如同没什么用的冷常识》里讲的小人国的故事吗?)。别放心,Golang 曾经帮咱们筹备了个 BigEndian 类型:

  n, err = io.ReadFull(client, buf[:2])
  if n != 2 {return nil, errors.New("read port:" + err.Error())
  }
  port := binary.BigEndian.Uint16(buf[:2])

既然 ADDR 和 PORT 都就位了,咱们马上创立一个到 dst 的连贯:

 destAddrPort := fmt.Sprintf("%s:%d", addr, port)
 dest, err := net.Dial("tcp", destAddrPort)
 if err != nil {return nil, errors.New("dial dst:" + err.Error())
 }

最初一步是通知客户端,咱们曾经筹备好了,协定要求是:

VER REP RSV ATYP BND.ADDR BND.PORT
1 1 X’00’ 1 Variable 2
  • VER

    • 暗号,还是暗号!
  • REP

    • 状态码,0x00= 胜利,0x01= 未知谬误,……
  • RSV

    • 仍然是没卵用的 RESERVED
  • ATYP

    • 地址类型
  • BND.ADDR

    • 服务器和 DST 创立连贯用的地址
  • BND.PORT

    • 服务器和 DST 创立连贯用的端口

BND.ADDR/PORT 本应填入 dest.LocalAddr(),但因为基本上也没甚卵用,咱们就间接用 0 填充了:

  n, err = client.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
  if err != nil {dest.Close()
    return nil, errors.New("write rsp:" + err.Error())
  }
  return dest, nil
}

注:ATYP = 0x01 示意 IPv4,所以须要填充 6 个 0 —— 4 for ADDR, 2 for PORT。

这个函数加在一起有点长,整整用了 62 行,但其实也就这么回事,对吧?

Socks5Forward

万事俱备,剩下的事件就是转发、转发、转发。

所谓“转发”,其实就是从一头读,往另一头写。

须要留神的是,因为 TCP 连贯是双工通信,咱们须要创立两个 goroutine,用于实现“双工转发”。

因为 golang 有一个 io.Copy 用来做转发的事件,代码只有 9 行,简略到难以形容:

func Socks5Forward(client, target net.Conn) {forward := func(src, dest net.Conn) {defer src.Close()
    defer dest.Close()
    io.Copy(src, dest)
  }
  go forward(client, target)
  go forward(target, client)
}

留神:在发送完当前须要敞开连贯。

验证

把下面的代码组装起来,补上 package main 和必要的 import,总共 145 行,一个能用的 socks5 代理服务器就成型了(残缺代码可参见这个 gist[2])。

上手跑起来:

$ go run socks5_proxy.go

发动代理拜访申请:

$ curl --proxy "socks5://127.0.0.1:1080" \
  https://job.toutiao.com/s/JxLbWby

注:这个链接很有用,倡议在浏览器里关上查看。

代码是没啥问题了,不过题目里的“高性能”这个 flag 立得起来吗?

压测

说到压测,天然就想到老牌工具 ab(apache benchmark),不过它只反对 http 代理,这就有点难堪了。

不过还好,开源的世界里什么都有,在  大型同性交友网站 Github 上,@cnlh 同学写了个反对 socks5 代理的 benchmark 工具 [3],马上就能够燥起来:

$ go get github.com/cnlh/benchmark

因为代理自身不提供 http 服务,咱们能够基于 gin 写一个高性能的 http server:

package main
import "github.com/gin-gonic/gin"

func main() {r := gin.Default()
  r.GET("/ping", func(c *gin.Context) {c.String(200, "pong")
  })
  r.Run(":8080")
}

跑起来:

$ go run http_server.go

先对它进行一轮压测,测试机是 Xeon 6130(16c32t) *2 + 376G RAM。

简略粗犷,间接上 c10k + 100w 申请:

$ benchmark -c 10000 -n 1000000 \
  http://127.0.0.1:8080/ping

Running 1000000 test @ 127.0.0.1:8080 by 10000 connections
...
1000000 requests in 10.57s, 115.59MB read, 42.38MB write
Requests/sec: 94633.20
Transfer/sec: 14.95MB
Error       : 0
Percentage of the requests served within a certain time (ms)
 50%           47
 90%           299
 95%           403
 99%           608
 100%          1722

10 行代码就能扛住 c10k problem,还做到了 94.6k QPS!

不过因为并发量太大,导致 p99 须要 608ms;如果换成 1000 个并发,QPS 没太大变动,p99 能够降落到 63ms。

接下来该咱们的 socks5 代理上场了:

$ go run socks_proxy.go
$ benchmark -c 10000 -n 1000000 \
  -proxy socks5://127.0.0.1:1080  \
  http://127.0.0.1:8080/ping

Running 1000000 test @ 127.0.0.1:8080 by 10000 connections
...
1000000 requests in 11.47s, 115.59MB read, 42.38MB write
Requests/sec: 87220.83
Transfer/sec: 13.78MB
Error       : 0
Percentage of the requests served within a certain time (ms)
 50%           102
 90%           318
 95%           424
 99%           649
 100%          1848

QPS 微降到 87.2k,p99 649ms 也不算显著上涨;换成 1000 并发,QPS 89.2k,p99 则降落到了 66ms —— 阐明代理自身对申请性能的影响十分小(注:如果把 benchmark、http server、代理放在不同的机器上执行,应该会看到更小的性能损耗)。

题目里的“高性能”这个 flag 算是立住了。

– 小结 –

最初照例简略总结下:

  • Go 语言非常适合实现网络服务,代码短小精悍,性能弱小
  • Socks 5 是一个简略的二进制网络代理协定
  • 网络字节序实际上就是 BigEndian,大端存储

顺便一提:实际上字节跳动晚期的很多服务(比方今日头条的 Feed 流服务)都是用 Python 实现的,因为性能的起因,咱们在 2015 年开始用 Go 重构,并逐步演化出了自研的微服务框架,感兴趣的同学能够浏览 InfoQ 的这篇《今日头条 Go 建千亿级微服务的实际》[4]。

当然,想要进一步理解的话,最好的形式还是能间接看到这个微服务框架的源码,并且实际上手用它 ——

↓↓↓ 长期招聘 ↓↓↓

投放研发工程师 — 穿山甲 @上海

https://job.toutiao.com/s/JP6…

后端研发工程师 – 穿山甲 @北京

https://job.toutiao.com/s/JP6…

字节跳动所有职位

https://job.toutiao.com/s/JP6…

欢送关注

参考链接

  1. RFC1928 – SOCKS Protocol Version 5
  2. Minimal socks5 proxy in Golang
  3. Benchmark by @cnlh
  4. 今日头条 Go 建千亿级微服务的实际
退出移动版