java转go的第一课|青训营笔记

163 阅读6分钟

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

一、重点内容概览

  • go基本语法(主要侧重和java不同的地方)
  • go实现的小案例

二、知识点介绍

  • java和go的一些常见的区别
    • 变量方面

      1. go函数作为第一类值,不用像java使用Method对象封装函数,或者使用函数式接口,使用更灵活
      2. go支持函数的多返回值,因此如果函数返回值出现在if语句中,格式如下
        if a, b := f(); expr {
        }
        
      3. 上面的用法有很多常见场景: map查询,接口断言,recover()等,都返回多个值。因此不能像java一样直接判断
      4. go采取值传递,在传参时要注意甄别。
      5. go的内置类型map, slice使得常见集合使用很方便。
    • oop方面

      1. 非侵入式接口实现,implements不在的第n天,想它
      2. 接口/结构体方法实现,声明时传入接收者receiver,初次使用时可能难以理解,可以把这个receiver 理解为java方法里的thisthis 不在的第n天,想它
      3. 接口查询,语法为x.(T),同样具有两个返回值,如果成功转换,返回转换后的对象和true。有点类似java的instanceof 关键字。
    • 并发编程方面

      1. 内存模型:CSP,goroutine通信使用channel。但是go也有锁实现(sync.Mutex),当访问全局的指针时,不得不进行枷锁
    • 网络库方面

      1. 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 = 0x04func 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

五、引用参考: