项目做了半年,现在要开发iOS版本。由于iOS的Network Extension对内存有15M限制,现成的实现方案都太耗内存,需要自己从头开发一个精简版。所以最近两周在学Go语言。
话不多说,正式开始,分为几步逐步来完善一个TCP客户端:
由于后面要做TCP客户端,需要一个方便可调试的服务端,也可以自己用Go写一个,但不是本篇重点,因此选用有网络工具瑞士军刀之称的Netcat。
启动服务器,监听1234端口
nc -l 1234
客户端测试连接
nc -v 127.0.0.1 1234
尝试在两端随意输入文字,会传输到另外一侧,按Ctrl+C
中断连接
编辑tcpclient.go
文件:
package main
import (
"net"
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:1234") // 以TCP协议连通127.0.0.1的1234端口
if err != nil {
conn.Close()
panic(err)
}
c := []byte("hello\n") // 将字符串转换为byte数组(slice)
conn.Write(c) // 将byte写入连接/发送至服务端
conn.Close()
}
启动nc服务器
nc -l 1234
运行并向nc服务器发送hello
go run tcpclient.go
查看nc服务器那边,会显示接收到hello
以上代码逻辑没问题,但有个不优雅的地方,就是我们在if err != nil
的分支和最后两次调用了conn.Close()
,如果一个正常函数,有七八处判断error的地方,难道我们也要写这么多次?
答案是defer
,defer
是用于安排一段逻辑在离开函数时执行的,修改后的代码是:
package main
import (
"net"
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:1234")
if err != nil {
panic(err)
}
defer conn.Close() // 这段逻辑一定会在以任何方式离开函数时执行
c := []byte("hello\n")
conn.Write(c)
}
上面一步我们只实现了发送消息,那么接收服务端消息要怎么实现呢?
修改一下上一步的代码:
package main
import (
"net"
"fmt"
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:1234")
if err != nil {
panic(err)
}
defer conn.Close()
c := []byte("hello\n")
conn.Write(c)
buf := make([]byte, 1024) // 创建长度为1k的缓存区
n, err := conn.Read(buf) // 将接收到的服务端内容放入缓存区,并返回此次读取的长度
if err != nil {
panic(err)
}
fmt.Printf("received: %v", string(buf[:n])) // 打印出来
}
make 是Go的内置函数; 上节提到
conn
实现了 io.Reader 接口,因此它有Read
方法; 与Read
相关的函数还有很多,这里仅介绍这个是因为他是其他一切高级Read
函数的基础。
启动后在服务端输入how are you
,会在客户端收到received: how are you
注意 conn.Read()
函数会阻塞住程序,直到它真正读取到数据,这很关键。
再次启动服务端和客户端,然后尝试在服务端按Ctrl+C
中断,观察客户端,抛出了异常。但在实际生产环境中,服务端因为各种原因关闭连接是很正常的,那我们要如何来判断呢?答案是io.EOF
package main
import (
"net"
"fmt"
"io" // 注意引入io包
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:1234")
if err != nil {
panic(err)
}
defer conn.Close()
c := []byte("hello\n")
conn.Write(c)
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
if err == io.EOF { // 若异常是io.EOF,则正常退出函数不做panic
return
}
panic(err)
}
fmt.Printf("received: %v", string(buf[:n]))
}
以后所有读取的地方,都需要注意这点。
上面仅能读取一次服务端消息,有没有办法让它能持续接收呢?答案是用for
循环
package main
import (
"net"
"fmt"
"io"
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:1234")
if err != nil {
panic(err)
}
defer conn.Close()
c := []byte("hello\n")
conn.Write(c)
buf := make([]byte, 1024)
for { // 无参for指令代表无限循环
n, err := conn.Read(buf)
if err != nil {
if err == io.EOF {
return
}
panic(err)
}
fmt.Printf("received: %v", string(buf[:n]))
}
}
再次运行,在服务端输入nice to meet you
和whats up
,观察客户端的表现。按Ctrl+C
结束客户端程序。
以上代码已经完成了客户端持续接收服务端消息的能力,但客户端却不能对自己有任何操作,我们需要实现客户端同时可以输入。
先看一段错误代码:
package main
import (
"net"
"fmt"
"io"
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:1234")
if err != nil {
panic(err)
}
defer conn.Close()
c := []byte("hello\n")
conn.Write(c)
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
if err == io.EOF {
return
}
panic(err)
}
fmt.Printf("received: %v", string(buf[:n]))
// 客户端可以输入消息并发送到服务端
var inp string
fmt.Scanln(&inp)
conn.Write([]byte(inp + "\n"))
}
}
启动之后我们会发现,仅有在服务端发送消息给客户端后,客户端才能够开始输入消息,这是因为我们之前说的conn.Read()
会阻塞整个代码。那我们该怎么办呢?答案是协程(goroutine)
,看下面修改的代码:
package main
import (
"net"
"fmt"
"io"
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:1234")
if err != nil {
panic(err)
}
defer conn.Close()
c := []byte("hello\n")
conn.Write(c)
// 将读取部分放入到子协程中,不阻塞主协程运行
go func() {
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
if err == io.EOF {
return
}
panic(err)
}
fmt.Printf("received: %v", string(buf[:n]))
}
}()
// 客户端可以输入消息并发送到服务端
for {
var inp string
fmt.Scanln(&inp)
conn.Write([]byte(inp + "\n"))
}
}
go
指令启动子协程,其中任何操作都不会阻塞当前协程,因此主协程会直接执行到输入指令处。
上面的程序已经运转良好了,如果我们现在有一个需求,是当接收到服务端发来的bye
消息时,客户端退出,该如何实现?可以先思考一下再看代码
package main
import (
"net"
"fmt"
"io"
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:1234")
if err != nil {
panic(err)
}
defer conn.Close()
c := []byte("hello\n")
conn.Write(c)
quit := make(chan string, 1) // 1. 创建长度为1的通道(chan)
// 读取协程
go func() {
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
if err == io.EOF {
return
}
panic(err)
}
r := string(buf[:n])
fmt.Printf("received: %v", r)
if r == "bye\n" { // 若接收到服务端发送过来的bye
quit <-"server quit" // 3. 向通道内写入内容
return
}
}
}()
// 用户输入协程
go func() { // 将用户输入也变为子协程
for {
var inp string
fmt.Scanln(&inp)
conn.Write([]byte(inp + "\n"))
}
}()
r := <-quit // 2. 尝试从通道中读取内容,若通道为空,则阻塞在此
fmt.Printf("command: %v", r)
}
以上代码解读:
用户输入
也变为与网络读取一样的子协程quit
quit
通道中取出内容,若通道为空,则阻塞在此bye
,则向quit
通道中写入一个值(这个值可以是任意值)quit
中被写入一个值,r := <-quit
就会成功取出,不再阻塞,退出程序所以,关键点是通道的取出是阻塞的
练习题:尝试将以上代码改为客户端输入bye
也可以退出
如果我们希望添加超时怎么做?
思路其实很简单,要超时退出,就是要在刚刚以上的bye
命令通知机制上,再加上时间通知。
package main
import (
"net"
"fmt"
"io"
"time" // 引入time包
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:1234")
if err != nil {
panic(err)
}
defer conn.Close()
c := []byte("hello\n")
conn.Write(c)
quit := make(chan string, 1)
// 读取协程
go func() {
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
if err == io.EOF {
return
}
panic(err)
}
r := string(buf[:n])
fmt.Printf("received: %v", r)
if r == "bye\n" {
quit <-"server quit"
return
}
}
}()
// 用户输入协程
go func() {
for {
var inp string
fmt.Scanln(&inp)
conn.Write([]byte(inp + "\n"))
}
}()
// 将简单的读取quit通道,改为select多路通道监听
select {
case r := <-quit:
fmt.Printf("command: %v", r)
case <-time.After(5 * time.Second): // 新增一个通道条件是5s之后通道中有值
fmt.Printf("timeout")
}
}
time.After 返回一个通道,在指定时间到达时,将向通道内写入当前时间。而 select 关键字则用于多通道监听。
运行后,任意互动,5s后程序就会退出。
我们再看另一种更标准化的超时:
package main
import (
"net"
"fmt"
"io"
"time"
"context"
)
func main() {
// 创建context用于协程间传递
ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
defer cancel() // 函数退出时需关闭context
conn, err := net.Dial("tcp", "127.0.0.1:1234")
if err != nil {
panic(err)
}
defer conn.Close()
c := []byte("hello\n")
conn.Write(c)
quit := make(chan string, 1)
// 读取协程
go func() {
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
if err == io.EOF {
return
}
panic(err)
}
r := string(buf[:n])
fmt.Printf("received: %v", r)
if r == "bye\n" {
quit <-"server quit"
return
}
}
}()
// 用户输入协程
go func() {
for {
var inp string
fmt.Scanln(&inp)
conn.Write([]byte(inp + "\n"))
}
}()
select {
case r := <-quit:
fmt.Printf("command: %v", r)
case <-ctx.Done(): // 改为监听ctx.Done()通道
fmt.Printf("timeout")
}
}
效果和刚刚一样,那为什么要用这种而不是time.After()
?因为context更加强大,除此之外还可以做很多事情,看下面我们优化的代码:
package main
import (
"net"
"fmt"
"io"
"time"
"context"
)
func main() {
// 创建context用于协程间传递
ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
defer cancel() // 函数退出时需关闭context
var dialer net.Dialer // 创建dialer
conn, err := dialer.DialContext(ctx, "tcp", "127.0.0.1:1234") // 在连接时就将context传入,可以确保连接时长也受context限制
if err != nil {
panic(err)
}
defer conn.Close()
c := []byte("hello\n")
conn.Write(c)
// 读取协程
go func() {
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
if err == io.EOF || ctx.Err() != nil { // 由context进行的主动退出,不要panic
return
}
panic(err)
}
r := string(buf[:n])
fmt.Printf("received: %v", r)
if r == "bye\n" {
cancel() // 直接调用cancel函数完成退出消息传递
return
}
}
}()
// 用户输入协程
go func() {
for {
var inp string
fmt.Scanln(&inp)
conn.Write([]byte(inp + "\n"))
}
}()
select {
case <-ctx.Done(): // 仅监听ctx.Done()通道
}
// 根据context的异常来判断退出原因
err = ctx.Err()
if err == context.Canceled {
fmt.Printf("user canceled")
} else if err == context.DeadlineExceeded {
fmt.Printf("timeout")
}
}
context
还实现了连接超时,并且现在超时之后报的panic
错误也处理了,退出的原因也做了统一处理,一切都归到了context上,很和谐。
练习题:把以上代码改为5s没有接收到也没有发送任何消息才算超时
好了,以上就是本次的内容。涉及了以下知识:
建议平时再多看看其他资料,多写写。