这是我参与「第五届青训营 」伴学笔记创作活动的第1天
一、重点内容概览
- go基本语法(主要侧重和java不同的地方)
- go实现的小案例
二、知识点介绍
- java和go的一些常见的区别
-
变量方面
- go函数作为第一类值,不用像java使用Method对象封装函数,或者使用函数式接口,使用更灵活
- go支持函数的多返回值,因此如果函数返回值出现在if语句中,格式如下
if a, b := f(); expr { } - 上面的用法有很多常见场景: map查询,接口断言,recover()等,都返回多个值。因此不能像java一样直接判断
- go采取值传递,在传参时要注意甄别。
- go的内置类型map, slice使得常见集合使用很方便。
-
oop方面
- 非侵入式接口实现,
implements不在的第n天,想它 - 接口/结构体方法实现,声明时传入接收者
receiver,初次使用时可能难以理解,可以把这个receiver理解为java方法里的this。this不在的第n天,想它 - 接口查询,语法为
x.(T),同样具有两个返回值,如果成功转换,返回转换后的对象和true。有点类似java的instanceof关键字。
- 非侵入式接口实现,
-
并发编程方面
- 内存模型:CSP,goroutine通信使用channel。但是go也有锁实现(sync.Mutex),当访问全局的指针时,不得不进行枷锁
-
网络库方面
- net/http对socket进行了高度封装,而java则只提供了基础的BIO,还是基于socket的,java也提供了NIO,但是实现的偏底层,难以直接进行服务器开发
-
三、实践练习例子
- go语言的爬虫程序
实例:爬虫字典
- httpClient的使用:能模拟发送http请求的都可以作为爬虫程序
- 工具cURL的转换。无工具不工程,使用工具可以简单的构造出go语言封装好的http报文,而不需要手动构造。
- 工具json-go的使用:如何把后台接口的json数据映射为go的结构。这是一种mapping。对于脚本语言,可以轻松的处理,但是对于go这种语言,如果将其视为
map[string]any来处理,不仅涉及大量的map操作,看起来也很不美观。我们要把json按照其key-value塞到结构体里。使用工具是最好不过的。code-gen - 使用code-gen之后,就可以解析数据得到想要的。
首先分析下翻译页面,也是经典的键盘输入事件触发的ajax请求。直接对api进行模拟请求即可。
前提是我们要构建出数据包来。手动塞入载荷和请求体?让工具来做!
在开发人员工具中,右键点击请求,可以看到复制选项,可以把这次请求的报文复制为用cURL命令行的请求。
为什么复制为curl呢?curl作为老牌的httpClient,其功能就是构造http请求报文。而我们的go也好,java也好,其也是具备http客户端功能的,因此只要修改下curl的命令,就可以转换为go版本的,比如
-H选项,就对应go的req.Header.Set()一些没用的可以删除,之所以使用工具,那些防盗链的措施,用工具直接copy现有的请求往往更方便。
curl-to-Go: Convert curl commands to Go code (mholt.github.io) 工具的github
curl 'https://lingocloud.caiyunapp.com/v1/dict' \ -H 'Accept: application/json, text/plain, */*' \ ... --data-raw '{"trans_type":"en2zh","source":"hello"}' \ --compressed
// Generated by curl-to-Go: https://mholt.github.io/curl-to-go
// 转换后的go代码,可以直接使用了~
client := &http.Client{}
var data = strings.NewReader(`{"trans_type":"en2zh","source":"hello"}`)
req, err := http.NewRequest("POST", "https://lingocloud.caiyunapp.com/v1/dict", data)
if err != nil {
log.Fatal(err)
}
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")
// ...
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)
}
fmt.Printf("%s\n", bodyText)
运行上面的代码,我们可以立即获得json数据。
下一步就是把json输入填入到go的结构中。
{"rc":0,"wiki":{"known_in_laguages":19 ...}
JSON转Golang Struct - 在线工具 - OKTools
把上面那一坨用工具转换为go的结构体
这要是自己实现,也够吃一壶的,所以我们一定要注意善用工具,搜索工具也很简单,xx to xx,别加什么关键字,比如这个json在哪个框架中,不管在哪里,都是json。
type AutoGenerated struct { Rc int `json:"rc"` ... }
现在只需要把json反序列化后,用这个对象接受就可以。咋来的咋回去。。
作为一个httpClient。优化逻辑和封装逻辑go帮我们做了。请求构造和响应解析工具帮我们做了,我们几乎啥也不用干。
最后提取出结构体里我们需要的内容即可。
func PaChong(kw string, c *http.Client) {
// Generated by curl-to-Go: https://mholt.github.io/curl-to-go
// 转换后的go代码,可以直接使用了~
var data = strings.NewReader(`{"trans_type":"en2zh","source":"` + kw + `"}`)
req, err := http.NewRequest("POST", "https://lingocloud.caiyunapp.com/v1/dict", data)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Accept", "application/json, text/plain, */*")
// ...
resp, err := c.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
bodyText, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
d := DictResp{}
err = json.Unmarshal(bodyText, &d)
if err != nil {
return
}
//fmt.Printf("%+v", d)
for _, v := range d.Dictionary.Explanations {
fmt.Println(v)
}
}
最后一步:抽取关键字并制成方法
进一步优化:由于涉及到网络请求,main线程会被方法调用阻塞。同时执行查询时,其他查询也无法进行。我们希望同时进行三次查询。
然后使用chan作为同步机制,让main线程等待三个goroutine完成任务。这下可以三个查询goroutine并发查询。大大节约时间。如果是串行,下一次查询需要等待上一次查询完全完成才可以,而每一次查询是IO密集操作,比较耗时。
func main() {
client := &http.Client{}
var kw1, kw2, kw3 string
fmt.Scanf("%s %s %s", &kw1, &kw2, &kw3)
//fmt.Println(kw1, kw2, kw3)
cs := make([]chan int, 3)
go PaChong(kw1, client, cs[0])
go PaChong(kw2, client, cs[1])
go PaChong(kw3, client, cs[2])
for _, ch := range cs {
<-ch
}
}
- go语言实现socks5协议客户端/服务端
实例:sock5代理
sock5客户端:能够发送sock5协议的报文,能解析sock5协议的响应报文,就可以称为sock5客户端,在这里,curl就可以作为sock5客户端。
sock5服务端:能解析并理解sock5报文,处理业务逻辑并返回正确结果,就是sock5服务端。
上面的都是最基础的要求,有些高性能客户端/服务端,只不过是在最基础的要求上进行了性能的保障。
我们要完成的sock5代理 需要完成的任务
- 认识sock5的握手请求,并能正确返回响应,才可以和sock5服务端进行握手和鉴权。(在握手包里,进行了版本的协商和权限鉴定)
- 认识sock5的连接请求,并能通过读取请求的控制字段,完成请求希望的业务逻辑(代理协议当然是转发请求了),并把业务逻辑执行结果按照sock5协议封装并写回响应体。
package main
import (
// ...
)
// 一些sock5协议需要的常量字段
const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04
func main() {
// 服务器的主题逻辑,进行监听,不断监听连接请求,生成conn对象,下一步交给process处理
s, err := net.Listen("tcp", "127.0.0.1:1080")
if err != nil {
panic(err)
}
for {
conn, err := s.Accept()
if err != nil {
fmt.Println("建立连接失败", err)
continue
}
go process(conn)
}
}
// 这个函数就是sock5服务器的主要运行机理了,sock5服务器作为一个状态机,实际上就是在运行这个代码。
// 这个代码 不断的处理握手包,并建立连接。sock5服务器的本质就是针对一个个的net.Conn对象,进行握手包的响应和连接的建立
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
}
}
// 此方法用于sock握手,解析sock的请求,并做出响应。
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
// sock5握手请求报文格式。我们需要解析报文,提取出VER:客户端支持的协议版本,NMETHODS,客户端支持的认证方法数
// +----+----------+----------+
// |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
// 读取1byte的版本号
ver, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("握手包中,解析客户端请求获取版本号失败:%w", err)
}
if ver != socks5Ver {
return fmt.Errorf("服务端不支持sock5以外的版本 客户端需要的版本为:%v", ver)
}
// 读取1byte的支持方法数
nMethod, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("握手包中,解析客户端请求,获取支持方法数失败:%w", err)
}
// 获取支持的方法列表
method := make([]byte, nMethod)
// 读出握手请求剩余内容,为支持的方法列表
_, err = io.ReadFull(reader, method)
if err != nil {
return fmt.Errorf("解析握手请求中的方法列表失败:%w", err)
}
// 解析完了,构造握手响应
// +----+--------+
// |VER | METHOD |
// +----+--------+
// | 1 | 1 |
// +----+--------+
_, err = conn.Write([]byte{socks5Ver, 0x00}) // 0x00 表示不进行鉴权
if err != nil {
return fmt.Errorf("构造响应包并回传时发生错误:%w", err)
}
return nil
}
// 此方法在握手后调用,用于建立sock5的连接。经过了握手协商,鉴权和连接建立后,就可以以代理的方式传输数据。
// 此方法用于解析客户端的连接请求,并返回响应,执行成功后将建立sock5连接。
// 涉及到协议的方法,都是基于一问一答的。
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请求:目前我们的协议只支持处理connect请求,也就是代理转发。
// RSV 保留字段,值为0x00
// ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。
// 0x01表示IPv4地址,DST.ADDR为4个字节
// 0x03表示域名,DST.ADDR是一个可变长度的域名
// DST.ADDR 一个可变长度的值 : 想表示可变字段,需要增加一字节的控制变量,指示变量长度。
// DST.PORT 目标端口,固定2个字节
bytes := make([]byte, 4)
io.ReadFull(reader, bytes)
ver, cmd, atyp := bytes[0], bytes[1], bytes[3]
if ver != socks5Ver {
return fmt.Errorf("本sock5代理服务器无法处理v5以外的版本:%w", err)
}
if cmd != cmdBind {
return fmt.Errorf("不支持的指令代码:%d", cmd)
}
addr := ""
switch atyp {
case atypIPV4:
// 目标地址为ipv4(形如192.168.1.5)类型
_, err = io.ReadFull(reader, bytes)
if err != nil {
return fmt.Errorf("读取目标地址时解析失败:%w", err)
}
// 我们可以看到,sprintf返回一个新的字符串,这是由于字符串的不可变性,go的字符串不能基于原字符串直接修改
addr = fmt.Sprintf("%d.%d.%d.%d", bytes[0], bytes[1], bytes[2], bytes[3])
case atypeHOST:
// 目标地址为域名类型
// 域名是可变长度,因此有1byte的域名长度
hostSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("解析域名长度时出错:%w", err)
}
host := make([]byte, hostSize)
_, err = io.ReadFull(reader, host)
if err != nil {
return fmt.Errorf("读取域名时出错:%w", err)
}
addr = string(host)
case atypeIPV6:
// 目标地址为ipv6格式 诸如:::
return fmt.Errorf("不支持解析ipv6:%w", err)
default:
return errors.New("非法的方法")
}
// 读取请求中的端口字段
_, err = io.ReadFull(reader, bytes[:2])
if err != nil {
return fmt.Errorf("读取端口时出错:%w", err)
}
// 注意:由于网络报文采取大端法传输,因此先转换为操作系统可以处理的正确数据,并转换为uint16
// 这里的BigEndian,只是指定一个byte[] 作为大端还是小端存储,具体处理依赖底层
port := binary.BigEndian.Uint16(bytes[:2])
// 根据读取到的地址和端口,进行请求转发。
// dest:完整的响应报文
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
if err != nil {
return fmt.Errorf("代理服务器和目标建立连接时出错:%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 返回为0x00表示成功执行
// RSV 保留字段
// ATYPE 地址类型
// BND.ADDR 服务绑定的地址 如果是响应包,这里填4字节0就行,
// BND.PORT 服务绑定的端口DST.PORT 如果是响应包,这里填2字节0就行,
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
return fmt.Errorf("代理服务器连接建立响应失败:%w", err)
}
// 此方法负责把代理服务器转发的请求对应的响应,写回客户端。
// 这里涉及到一个进程同步
// 下面的代码比较抽象
// ctx:当前goroutine对应的上下文,cancel:调用后给ctx发送信号。如果发送成功,ctx.Done()会返回一个只读管道。届时,该ctx下的所有例程将被强行终止
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// reader:客户端请求,此goroutine将客户端请求,经过代理原封不动发送到目标服务器。
// 注意,io.Copy() 会阻塞运行,直到写入的一方遇到EOF。但是我们的src和dst都是网络io流,只有遇到通信的一方关闭了连接时,才会给另一方发送EOF
// 这就意味着,cancel()方法只有在 目标服务器或者客户端一方关闭连接后,才会执行到。
// 而无论客户端,还是目标服务器哪一方主动关闭了连接,都会导致 客户端 -- sock5代理服务端 -- 目标服务端 这两组连接失效
// 所以无论是下面哪一个goroutine因通信关闭而执行到了cancel(),都会关闭ctx下的所有goroutine,释放资源,
// 此时无论是客户端到代理服务器的连接,还是代理服务器到目标服务器的连接都会随之关闭
go func() {
_, _ = io.Copy(dest, reader)
cancel()
}()
go func() {
_, _ = io.Copy(conn, dest)
cancel()
}()
<-ctx.Done()
return nil
}
四、课后个人总结
- go的语法和java的差异
- 无工具不工程 ==> 大的工程要借助第三方工具,简化开发,尽量不把精力放在造轮子/无用功上。比如案例二的json转换为go对象,案例二直接通过cURL cmd转换为go net/http的请求构造。
- 服务器/客户端的本质:都是基于网络io的,只不过二者需要手动解析协议罢了。
- 代理服务器的本质:维护了 C -- Proxy -- S 的链接。规定只要链路的一条断开,整个链路就结束。通信采取流式IO(本质就是实现了read/write的带缓冲对象,两个都实现就是双工的,单独实现read/write其中之一,就是单工),特点就是数据以流的方式,首尾互联,读取EOF才停止。通信结束的标志就是:关闭连接的一方向对端发送EOF
五、引用参考: