11. Go 语言网络编程

Go 语言网络编程

Go语言在编写 web 应用方面非常得力。因为目前它还没有 GUI(Graphic User Interface 图形化用户界面)的框架,通过文本或者模板展现的 html 界面是目前 Go 编写应用程序的唯一方式。

本章我们将全面介绍如何使用 Go语言开发网络程序。Go语言标准库里提供的 net 包,支持基于 IP 层、TCP/UDP 层及更高层面(如 HTTP、FTP、SMTP)的网络操作,其中用于 IP 层的称为 Raw Socket。

Go语言Socket编程详解

在很多底层网络应用开发者的眼里一切编程都是 Socket,话虽然有点夸张,但却也几乎如此了,现在的网络编程几乎都是用 Socket 来编程。

你想过这些情景么?我们每天打开浏览器浏览网页时,浏览器进程怎么和 Web 服务器进行通信的呢?当你用 QQ 聊天时,QQ 进程怎么和服务器或者是你的好友所在的 QQ 进程进行通信的呢?当你打开 PPstream 观看视频时,PPstream 进程如何与视频服务器进行通信的呢?如此种种,都是靠 Socket 来进行通信的,以一斑窥全豹,可见 Socket 编程在现代编程中占据了多么重要的地位,这一节我们将介绍 Go语言中如何进行 Socket 编程。

什么是 Socket?

Socket 起源于 Unix,而 Unix 基本哲学之一就是“一切皆文件”,都可以用“打开 open –> 读写 write/read –> 关闭 close”模式来操作。Socket 就是该模式的一个实现,网络的 Socket 数据传输是一种特殊的 I/O,Socket 也是一种文件描述符。Socket 也具有一个类似于打开文件的函数调用:Socket(),该函数返回一个整型的 Socket 描述符,随后的连接建立、数据传输等操作都是通过该 Socket 实现的。

常用的 Socket 类型有两种:流式Socket(SOCK_STREAM)和数据报式 Socket(SOCK_DGRAM)。

  • 流式是一种面向连接的 Socket,针对于面向连接的 TCP 服务应用;
  • 数据报式 Socket 是一种无连接的 Socket,对应于无连接的 UDP 服务应用。

Socket 如何通信

网络中的进程之间如何通过 Socket 通信呢?首要解决的问题是如何唯一标识一个进程,否则通信无从谈起!在本地可以通过进程 PID 来唯一标识一个进程,但是在网络中这是行不通的。

其实 TCP/IP 协议族已经帮我们解决了这个问题,网络层的“ip 地址”可以唯一标识网络中的主机,而传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。这样利用三元组(ip 地址,协议,端口)就可以标识网络的进程了,网络中需要互相通信的进程,就可以利用这个标志在他们之间进行交互。请看下面这个 TCP/IP 协议结构图

图:七层网络协议图

使用 TCP/IP 协议的应用程序通常采用应用编程接口:UNIX BSD 的套接字(socket)和 UNIX System V 的 TLI(已经被淘汰),来实现网络进程之间的通信。

就目前而言,几乎所有的应用程序都是采用 socket,而现在又是网络时代,网络中进程通信是无处不在,这就是为什么说“一切皆 Socket”。

Socket 基础知识

通过上面的介绍我们知道 Socket 有两种:TCP Socket 和 UDP Socket,TCP 和 UDP 是协议,而要确定一个进程的需要三元组,需要 IP 地址和端口。

IPv4 地址

目前的全球因特网所采用的协议族是 TCP/IP 协议。IP 是 TCP/IP 协议中网络层的协议,是 TCP/IP 协议族的核心协议。目前主要采用的 IP 协议的版本号是 4(简称为 IPv4),发展至今已经使用了 30 多年。

IPv4 的地址位数为 32 位,也就是最多有 2 的 32 次方的网络设备可以联到 Internet 上。

近十年来由于互联网的蓬勃发展,IP 位址的需求量愈来愈大,使得 IP 位址的发放愈趋紧张,前一段时间,据报道 IPV4 的地址已经发放完毕,我们公司目前很多服务器的 IP 都是一个宝贵的资源。

地址格式类似这样:127.0.0.1, 172.122.121.111

IPv6 地址

IPv6 是下一版本的互联网协议,也可以说是下一代互联网的协议,它是为了解决 IPv4 在实施过程中遇到的各种问题而被提出的,IPv6 采用 128 位地址长度,几乎可以不受限制地提供地址。

按保守方法估算 IPv6 实际可分配的地址,整个地球的每平方米面积上仍可分配 1000 多个地址。在 IPv6 的设计过程中除了一劳永逸地解决了地址短缺问题以外,还考虑了在 IPv4 中解决不好的其它问题,主要有端到端 IP 连接、服务质量(QoS)、安全性、多播、移动性、即插即用等。

地址格式类似这样:2002:c0e8:82e7:0:0:0:c0e8:82e7

Go 支持的 IP 类型

在 Go语言的 net 包中定义了很多类型、函数和方法用来网络编程,其中 IP 的定义如下:

type IP []byte

在 net 包中有很多函数来操作 IP,但是其中比较有用的也就几个,其中 ParseIP(s string) IP 函数会把一个 IPv4 或者 IPv6 的地址转化成 IP 类型,请看下面的例子:

package main
import (
    "net"
    "os"
    "fmt"
)
func main() {
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s ip-addr\n", os.Args[0])
        os.Exit(1)
    }
    name := os.Args[1]
    addr := net.ParseIP(name)
    if addr == nil {
        fmt.Println("Invalid address")
    } else {
        fmt.Println("The address is ", addr.String())
    }
    os.Exit(0)
}

执行之后你就会发现只要你输入一个 IP 地址就会给出相应的 IP 格式

TCP Socket

当我们知道如何通过网络端口访问一个服务时,那么我们能够做什么呢?作为客户端来说,我们可以通过向远端某台机器的的某个网络端口发送一个请求,然后得到在机器的此端口上监听的服务反馈的信息。

作为服务端,我们需要把服务绑定到某个指定端口,并且在此端口上监听,当有客户端来访问时能够读取信息并且写入反馈信息。

在 Go语言的 net 包中有一个类型 TCPConn,这个类型可以用来作为客户端和服务器端交互的通道,他有两个主要的函数:

func (c *TCPConn) Write(b []byte) (n int, err os.Error)
func (c *TCPConn) Read(b []byte) (n int, err os.Error)

TCPConn 可以用在客户端和服务器端来读写数据。

还有我们需要知道一个 TCPAddr 类型,他表示一个 TCP 的地址信息,他的定义如下:

type TCPAddr struct {
    IP IP
    Port int
}

在 Go语言中通过 ResolveTCPAddr 获取一个 TCPAddr

func ResolveTCPAddr(net, addr string) (*TCPAddr, os.Error)
  • net 参数是"tcp4"、"tcp6"、"tcp"中的任意一个,分别表示 TCP(IPv4-only),TCP(IPv6-only) 或者 TCP(IPv4,IPv6 的任意一个)。
  • addr 表示域名或者 IP 地址,例如"www.google.com:80" 或者"127.0.0.1:22"。

TCP client

Go语言中通过 net 包中的 DialTCP 函数来建立一个 TCP 连接,并返回一个 TCPConn 类型的对象,当连接建立时服务器端也创建一个同类型的对象,此时客户端和服务器段通过各自拥有的 TCPConn 对象来进行数据交换。

一般而言,客户端通过 TCPConn 对象将请求信息发送到服务器端,读取服务器端响应的信息。服务器端读取并解析来自客户端的请求,并返回应答信息,这个连接只有当任一端关闭了连接之后才失效,不然这连接可以一直在使用。建立连接的函数定义如下:

func DialTCP(net string, laddr, raddr *TCPAddr) (c *TCPConn, err os.Error)
  • net 参数是"tcp4"、"tcp6"、"tcp"中的任意一个,分别表示 TCP(IPv4-only)、TCP(IPv6-only) 或者 TCP(IPv4,IPv6 的任意一个)
  • laddr 表示本机地址,一般设置为 nil
  • raddr 表示远程的服务地址

接下来通过一个简单的例子,模拟一个基于 HTTP 协议的客户端请求去连接一个 Web 服务端。要写一个简单的 http 请求头,格式类似如下:

"HEAD / HTTP/1.0\r\n\r\n"

从服务端接收到的响应信息格式可能如下:

HTTP/1.0 200 OK
ETag: "-9985996"
Last-Modified: Thu, 25 Mar 2010 17:51:10 GMT
Content-Length: 18074
Connection: close
Date: Sat, 28 Aug 2010 00:43:48 GMT
Server: lighttpd/1.4.23

客户端代码如下所示:

package main
import (
    "fmt"
    "io/ioutil"
    "net"
    "os"
)
func main() {
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0])
        os.Exit(1)
    }
    service := os.Args[1]
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    conn, err := net.DialTCP("tcp", nil, tcpAddr)
    checkError(err)
    _, err = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
    checkError(err)
    result, err := ioutil.ReadAll(conn)
    checkError(err)
    fmt.Println(string(result))
    os.Exit(0)
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

通过上面的代码可以看出:首先程序将用户的输入作为参数 service 传入 net.ResolveTCPAddr 获取一个 tcpAddr,然后把 tcpAddr 传入 DialTCP 后创建了一个 TCP 连接 conn,通过 conn 发送请求信息,最后通过 ioutil.ReadAll 从 conn 中读取全部的文本,也就是服务端响应反馈的信息。

TCP server

上面我们编写了一个 TCP 的客户端程序,也可以通过 net 包来创建一个服务器端程序,在服务器端我们需要绑定服务到指定的非激活端口,并监听此端口,当有客户端请求到达的时候可以接收到来自客户端连接的请求。

net 包中有相应功能的函数,函数定义如下:

func ListenTCP(net string, laddr *TCPAddr) (l *TCPListener, err os.Error)
func (l *TCPListener) Accept() (c Conn, err os.Error)

参数说明同 DialTCP 的参数一样。下面我们实现一个简单的时间同步服务,监听 7777 端口:

package main
import (
    "fmt"
    "net"
    "os"
    "time"
)
func main() {
    service := ":7777"
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        daytime := time.Now().String()
        conn.Write([]byte(daytime)) 
        conn.Close() 
    }
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

上面的服务跑起来之后,它将会一直在那里等待,直到有新的客户端请求到达。当有新的客户端请求到达并同意接受 Accept 该请求的时候他会反馈当前的时间信息。

值得注意的是,在代码中 for 循环里,当有错误发生时,直接 continue 而不是退出,是因为在服务器端跑代码的时候,当有错误发生的情况下最好是由服务端记录错误,然后当前连接的客户端直接报错而退出,从而不会影响到当前服务端运行的整个服务。

上面的代码有个缺点,执行的时候是单任务的,不能同时接收多个请求,那么该如何改造以使它支持多并发呢?Go 里面有一个 goroutine 机制,请看下面改造后的代码:

package main
import (
    "fmt"
    "net"
    "os"
    "time"
)
func main() {
    service := ":1200"
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        go handleClient(conn)
    }
}
func handleClient(conn net.Conn) {
    defer conn.Close()
    daytime := time.Now().String()
    conn.Write([]byte(daytime))
    
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

通过把业务处理分离到函数 handleClient,我们就可以进一步地实现多并发执行了。看上去是不是很帅,增加 go 关键词就实现了服务端的多并发,从这个小例子也可以看出 goroutine 的强大之处。

有的朋友可能要问:这个服务端没有处理客户端实际请求的内容。如果我们需要通过从客户端发送不同的请求来获取不同的时间格式,而且需要一个长连接,该怎么做呢?请看:

package main
import (
    "fmt"
    "net"
    "os"
    "time"
    "strconv"
    "strings"
)
func main() {
    service := ":1200"
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        go handleClient(conn)
    }
}
func handleClient(conn net.Conn) {
    conn.SetReadDeadline(time.Now().Add(2 * time.Minute)) // 设置2分钟超时
    request := make([]byte, 128) // 退出前关闭连接
    for {
        read_len, err := conn.Read(request)
        if err != nil {
            fmt.Println(err)
            break
        }
        if read_len == 0 {
            break // 客户端已关闭连接
        } else if strings.TrimSpace(string(request[:read_len])) == "timestamp" daytime := strconv.FormatInt(time.Now().Unix(), 10) {
            conn.Write([]byte(daytime))
        } else {
            daytime := time.Now().String()
            conn.Write([]byte(daytime))
        }
        request = make([]byte, 128) // 清除上次读取的内容
    }
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

在上面这个例子中,我们使用 conn.Read() 不断读取客户端发来的请求。由于我们需要保持与客户端的长连接,所以不能在读取完一次请求后就关闭连接。由于 conn.SetReadDeadline() 设置了超时,当一定时间内客户端无请求发送,conn 便会自动关闭,下面的 for 循环即会因为连接已关闭而跳出。

需要注意的是,request 在创建时需要指定一个最大长度以防止 flood attack;每次读取到请求处理完毕后,需要清理 request,因为 conn.Read() 会将新读取到的内容 append 到原内容之后。

控制 TCP 连接

TCP 有很多连接控制函数,我们平常用到比较多的有如下几个函数:

func DialTimeout(net, addr string, timeout time.Duration) (Conn, error)

设置建立连接的超时时间,客户端和服务器端都适用,当超过设置时间时,连接自动关闭。

func (c *TCPConn) SetReadDeadline(t time.Time) error
func (c *TCPConn) SetWriteDeadline(t time.Time) error

用来设置写入/读取一个连接的超时时间。当超过设置时间时,连接自动关闭。

func (c *TCPConn) SetKeepAlive(keepalive bool) os.Error

设置客户端是否和服务器端保持长连接,可以降低建立 TCP 连接时的握手开销,对于一些需要频繁交换数据的应用场景比较适用。

UDP Socket

Go语言包中处理 UDP Socket 和 TCP Socket 不同的地方就是在服务器端处理多个客户端请求数据包的方式不同,UDP 缺少了对客户端连接请求的 Accept 函数。其他基本几乎一模一样,只有 TCP 换成了 UDP 而已。UDP 的几个主要函数如下所示:

func ResolveUDPAddr(net, addr string) (*UDPAddr, os.Error)
func DialUDP(net string, laddr, raddr *UDPAddr) (c *UDPConn, err os.Error)
func ListenUDP(net string, laddr *UDPAddr) (c *UDPConn, err os.Error)
func (c *UDPConn) ReadFromUDP(b []byte) (n int, addr *UDPAddr, err os.Error
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (n int, err os.Error)

一个 UDP 的客户端代码如下所示,我们可以看到不同的就是 TCP 换成了 UDP 而已:

package main
import (
    "fmt"
    "net"
    "os"
)
func main() {
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
        os.Exit(1)
    }
    service := os.Args[1]
    udpAddr, err := net.ResolveUDPAddr("udp4", service)
    checkError(err)
    conn, err := net.DialUDP("udp", nil, udpAddr)
    checkError(err)
    _, err = conn.Write([]byte("anything"))
    checkError(err)
    var buf [512]byte
    n, err := conn.Read(buf[0:])
    checkError(err)
    fmt.Println(string(buf[0:n]))
    os.Exit(0)
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error ", err.Error())
        os.Exit(1)
    }
}

我们来看一下 UDP 服务器端如何来处理:

package main
import (
    "fmt"
    "net"
    "os"
    "time"
)
func main() {
    service := ":1200"
    udpAddr, err := net.ResolveUDPAddr("udp4", service)
    checkError(err)
    conn, err := net.ListenUDP("udp", udpAddr)
    checkError(err)
    for {
        handleClient(conn)
    }
}
func handleClient(conn * net.UDPConn) {
    var buf [512]byte
    _, addr, err := conn.ReadFromUDP(buf[0:])
    if err != nil {
        return
    }
    daytime := time.Now().String()
    conn.WriteToUDP([]byte(daytime), addr)
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error ", err.Error())
        os.Exit(1)
    }
}

总结

通过对 TCP 和 UDP Socket 编程的描述和实现,可见 Go 已经完备地支持了 Socket 编程,而且使用起来相当的方便,Go 提供了很多函数,通过这些函数可以很容易就编写出高性能的 Socket 应用。

Go语言Dial()函数:建立网络连接

Go语言中 Dial() 函数的原型如下:

func Dial(net, addr string) (Conn, error)

其中 net 参数是网络协议的名字,addr 参数是 IP 地址或域名,而端口号以“:”的形式跟随在地址或域名的后面,端口号可选。如果连接成功,返回连接对象,否则返回 error。

我们来看一下几种常见协议的调用方式。

1) TCP 链接:

conn, err := net.Dial("tcp", "192.168.0.10:2100")

2) UDP 链接:

conn, err := net.Dial("udp", "192.168.0.12:975")

3) ICMP 链接(使用协议名称):

conn, err := net.Dial("ip4:icmp", "www.baidu.com")

4) ICMP 链接(使用协议编号):

conn, err := net.Dial("ip4:1", "10.0.0.3")

这里我们可以通过以下链接查看协议编号的含义:http://www.iana.org/assignments/protocol-numbers/protocol-numbers.xml

目前,Dial() 函数支持如下几种网络协议:"tcp"、"tcp4"(仅限 IPv4)、"tcp6"(仅限 IPv6)、"udp"、"udp4"(仅限 IPv4)、"udp6"(仅限 IPv6)、"ip"、"ip4"(仅限 IPv4)和"ip6"
(仅限 IPv6)。

在成功建立连接后,我们就可以进行数据的发送和接收。发送数据时,使用 conn 的 Write() 成员方法,接收数据时使用 Read() 方法。

Go语言ICMP协议:向主机发送消息

下面我们实现这样一个例子:使用 ICMP 协议向在线的主机发送一个问候,并等待主机返回,具体代码如下所示。

package main
import (
    "net"
    "os"
    "bytes"
    "fmt"
)
func main() {
    if len(os.Args) != 2 {
        fmt.Println("Usage: ", os.Args[0], "host")
        os.Exit(1)
    }
    service := os.Args[1]
    conn, err := net.Dial("ip4:icmp", service)
    checkError(err)
    var msg [512]byte
    msg[0] = 8 // echo
    msg[1] = 0 // code 0
    msg[2] = 0 // checksum
    msg[3] = 0 // checksum
    msg[4] = 0 // identifier[0]
    msg[5] = 13 //identifier[1]
    msg[6] = 0 // sequence[0]
    msg[7] = 37 // sequence[1]
    len := 8
    check := checkSum(msg[0:len])
    msg[2] = byte(check >> 8)
    msg[3] = byte(check & 255)
    _, err = conn.Write(msg[0:len])
    checkError(err)
    _, err = conn.Read(msg[0:])
    checkError(err)
    fmt.Println("Got response")
    if msg[5] == 13 {
        fmt.Println("Identifier matches")
    }
    if msg[7] == 37 {
        fmt.Println("Sequence matches")
    }
    os.Exit(0)
}
func checkSum(msg []byte) uint16 {
    sum := 0
    // 先假设为偶数
    for n := 1; n <len(msg)-1; n += 2 {
        sum += int(msg[n])*256 + int(msg[n+1])
    }
    sum = (sum >> 16) + (sum & 0xffff)
    sum += (sum >> 16)
    var answer uint16 = uint16(^sum)
    return answer
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}
func readFully(conn net.Conn) ([]byte, error) {
    defer conn.Close()
    result := bytes.NewBuffer(nil)
    var buf [512]byte
    for {
        n, err := conn.Read(buf[0:])
        result.Write(buf[0:n])
        if err != nil {
            if err == io.EOF {
                break
            }
            return nil, err
        }
    }
    return result.Bytes(), nil
}

执行结果如下:

$ go build icmptest.go
$ ./icmptest www.baidu.com
Got response
Identifier matches
Sequence matches

示例:建立TCP链接

下面我们建立 TCP 链接来实现初步的 HTTP 协议,通过向网络主机发送 HTTP Head 请求,读取网络主机返回的信息,具体代码如下所示。

package main
import (
    "net"
    "os"
    "bytes"
    "fmt"
)
func main() {
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
        os.Exit(1)
    }
    service := os.Args[1]
    conn, err := net.Dial("tcp", service)
    checkError(err)
    _, err = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
    checkError(err)
    result, err := readFully(conn)
    checkError(err)
    fmt.Println(string(result))
    os.Exit(0)
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}
func readFully(conn net.Conn) ([]byte, error) {
    defer conn.Close()
    result := bytes.NewBuffer(nil)
    var buf [512]byte
    for {
        n, err := conn.Read(buf[0:])
        result.Write(buf[0:n])
        if err != nil {
            if err == io.EOF {
                break
            }
            return nil, err
        }
    }
    return result.Bytes(), nil
}

执行这段程序并查看执行结果:

$ go build simplehttp.go
$ ./simplehttp qbox.me:80

HTTP/1.1 301 Moved Permanently
Server: nginx/1.0.14
Date: Mon, 21 May 2012 03:15:08 GMT
Content-Type: text/html
Content-Length: 184
Connection: close
Location: https://qbox.me/

Go语言DialTCP():网络通信

实际上,在前面《Dial()函数》一节中介绍的 Dial() 函数其实是对 DialTCP()、DialUDP()、DialIP() 和 DialUnix() 的封装。我们也可以直接调用这些函数,它们的功能是一致的。这些函数的原型如下:

func DialTCP(net string, laddr, raddr *TCPAddr) (c *TCPConn, err error)
func DialUDP(net string, laddr, raddr *UDPAddr) (c *UDPConn, err error)
func DialIP(netProto string, laddr, raddr *IPAddr) (*IPConn, error)
func DialUnix(net string, laddr, raddr *UnixAddr) (c *UnixConn, err error)

之前基于 TCP 发送 HTTP 请求,读取服务器返回的 HTTP Head 的整个流程也可以使用下面代码所示的实现方式。

package main
import (
    "net"
    "os"
    "fmt"
    "io/ioutil"
)
func main() {
    if len(os.Args) != 2 {
    fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
    os.Exit(1)
    }
    service := os.Args[1]
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    conn, err := net.DialTCP("tcp", nil, tcpAddr)
    checkError(err)
    _, err = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
    checkError(err)
    result, err := ioutil.ReadAll(conn)
    checkError(err)
    fmt.Println(string(result))
    os.Exit(0)
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

与之前使用 Dail() 的例子相比,这里有两个不同:

  • net.ResolveTCPAddr(),用于解析地址和端口号;
  • net.DialTCP(),用于建立链接。

这两个函数在 Dial() 中都得到了封装。

此外,net 包中还包含了一系列的工具函数,合理地使用这些函数可以更好地保障程序的质量。

验证 IP 地址有效性的代码如下:

func net.ParseIP()

创建子网掩码的代码如下:

func IPv4Mask(a, b, c, d byte) IPMask

获取默认子网掩码的代码如下:

func (ip IP) DefaultMask() IPMask

根据域名查找 IP 的代码如下:

func ResolveIPAddr(net, addr string) (*IPAddr, error)
func LookupHost(name string) (cname string, addrs []string, err error);

Go语言HTTP客户端实现简述

Go语言内置的 net/http 包提供了最简洁的 HTTP 客户端实现,我们无需借助第三方网络通信库(比如 libcurl)就可以直接使用 HTTP 中用得最多的 GET 和 POST 方式请求数据。

基本方法

net/http 包的 Client 类型提供了如下几个方法,让我们可以用最简洁的方式实现 HTTP 请求:

func (c *Client) Get(url string) (r *Response, err error)
func (c *Client) Post(url string, bodyType string, body io.Reader) (r *Response, err error)
func (c *Client) PostForm(url string, data url.Values) (r *Response, err error)
func (c *Client) Head(url string) (r *Response, err error)
func (c *Client) Do(req *Request) (resp *Response, err error)

下面概要介绍这几个方法。

1) http.Get()

要请求一个资源,只需调用 http.Get() 方法(等价于 http.DefaultClient.Get())即可,示例代码如下:

resp, err := http.Get("http://example.com/")
if err != nil {
    // 处理错误 ...
    return
}
defer resp.Body.close()
io.Copy(os.Stdout, resp.Body)

上面这段代码请求一个网站首页,并将其网页内容打印到标准输出流中。

2) http.Post()

要以 POST 的方式发送数据,也很简单,只需调用 http.Post() 方法并依次传递下面的 3 个参数即可:

  • 请求的目标 URL
  • 将要 POST 数据的资源类型(MIMEType)
  • 数据的比特流([]byte形式)

下面的示例代码演示了如何上传一张图片:

resp, err := http.Post("http://example.com/upload", "image/jpeg", &imageDataBuf)
if err != nil {
    // 处理错误
    return
}
if resp.StatusCode != http.StatusOK {
    // 处理错误
    return
}
// ...

3) http.PostForm()

http.PostForm() 方法实现了标准编码格式为 application/x-www-form-urlencoded 的表单提交。下面的示例代码模拟 HTML 表单提交一篇新文章:

resp, err := http.PostForm("http://example.com/posts", url.Values{"title":{"article title"}, "content": {"article body"}})
if err != nil {
    // 处理错误
    return
}
// ...

4) http.Head()

HTTP 中的 Head 请求方式表明只请求目标 URL 的头部信息,即 HTTP Header 而不返回 HTTP Body。Go 内置的 net/http 包同样也提供了 http.Head() 方法,该方法同 http.Get() 方法一样,只需传入目标 URL 一个参数即可。

下面的示例代码请求一个网站首页的 HTTP Header 信息:

resp, err := http.Head("http://example.com/")

5) (*http.Client).Do()

在多数情况下,http.Get() 和 http.PostForm() 就可以满足需求,但是如果我们发起的 HTTP 请求需要更多的定制信息,我们希望设定一些自定义的 Http Header 字段,比如:

  • 设定自定义的"User-Agent",而不是默认的 "Go http package"
  • 传递 Cookie

此时可以使用 net/http 包 http.Client 对象的 Do() 方法来实现:

req, err := http.NewRequest("GET", "http://example.com", nil)
// ...
req.Header.Add("User-Agent", "Gobook Custom User-Agent")
// ...
client := &http.Client{ //... }
resp, err := client.Do(req)
// ...

高级封装

除了之前介绍的基本 HTTP 操作,Go语言标准库也暴露了比较底层的 HTTP 相关库,让开发者可以基于这些库灵活定制 HTTP 服务器和使用 HTTP 服务。

1) 自定义 http.Client

前面我们使用的 http.Get()、http.Post()、http.PostForm() 和 http.Head() 方法其实都是在 http.DefaultClient 的基础上进行调用的,比如 http.Get() 等价于 http.Default-Client.Get(),依次类推。

http.DefaultClient 在字面上就向我们传达了一个信息,既然存在默认的 Client,那么 HTTP Client 大概是可以自定义的。实际上确实如此,在 net/http 包中,的确提供了 Client 类型。让我们来看一看 http.Client 类型的结构:

type Client struct {
    // Transport 用于确定HTTP请求的创建机制。
    // 如果为空,将会使用DefaultTransport
    Transport RoundTripper
    // CheckRedirect定义重定向策略。
    // 如果CheckRedirect不为空,客户端将在跟踪HTTP重定向前调用该函数。
    // 两个参数req和via分别为即将发起的请求和已经发起的所有请求,最早的
    // 已发起请求在最前面。
    // 如果CheckRedirect返回错误,客户端将直接返回错误,不会再发起该请求。
    // 如果CheckRedirect为空,Client将采用一种确认策略,将在10个连续
    // 请求后终止
    CheckRedirect func(req *Request, via []*Request) error
    // 如果Jar为空,Cookie将不会在请求中发送,并会
    // 在响应中被忽略
    Jar CookieJar
}

在 Go语言标准库中,http.Client 类型包含了 3 个公开数据成员:

Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar

其中 Transport 类型必须实现 http.RoundTripper 接口。Transport 指定了执行一个 HTTP 请求的运行机制,倘若不指定具体的 Transport,默认会使用 http.DefaultTransport,这意味着 http.Transport 也是可以自定义的。net/http 包中的 http.Transport 类型实现了 http.RoundTripper 接口。

CheckRedirect 函数指定处理重定向的策略。当使用 HTTP Client 的 Get() 或者是 Head() 方法发送 HTTP 请求时,若响应返回的状态码为 30x (比如 301 / 302 / 303 / 307),HTTP Client 会在遵循跳转规则之前先调用这个 CheckRedirect 函数。

Jar 可用于在 HTTP Client 中设定 Cookie,Jar 的类型必须实现了 http.CookieJar 接口,该接口预定义了 SetCookies() 和 Cookies() 两个方法。

如果 HTTP Client 中没有设定 Jar,Cookie 将被忽略而不会发送到客户端。实际上,我们一般都用 http.SetCookie() 方法来设定 Cookie。

使用自定义的 http.Client 及其 Do() 方法,我们可以非常灵活地控制 HTTP 请求,比如发送自定义 HTTP Header 或是改写重定向策略等。创建自定义的 HTTP Client 非常简单,具体代码如下:

client := &http.Client {
    CheckRedirect: redirectPolicyFunc,
}
resp, err := client.Get("http://example.com")
// ...
req, err := http.NewRequest("GET", "http://example.com", nil)
// ...
req.Header.Add("User-Agent", "Our Custom User-Agent")
req.Header.Add("If-None-Match", `W/"TheFileEtag"`)
resp, err := client.Do(req)
// ...

2) 自定义 http.Transport

在 http.Client 类型的结构定义中,我们看到的第一个数据成员就是一个 http.Transport 对象,该对象指定执行一个 HTTP 请求时的运行规则。下面我们来看看 http.Transport 类型的具体结构:

type Transport struct {
    // Proxy指定用于针对特定请求返回代理的函数。
    // 如果该函数返回一个非空的错误,请求将终止并返回该错误。
    // 如果Proxy为空或者返回一个空的URL指针,将不使用代理
    Proxy func(*Request) (*url.URL, error)
    // Dial指定用于创建TCP连接的dail()函数。
    // 如果Dial为空,将默认使用net.Dial()函数
    Dial func(net, addr string) (c net.Conn, err error)
    // TLSClientConfig指定用于tls.Client的TLS配置。
    // 如果为空则使用默认配置
    TLSClientConfig *tls.Config
    DisableKeepAlives bool
    DisableCompression bool
    // 如果MaxIdleConnsPerHost为非零值,它用于控制每个host所需要
    // 保持的最大空闲连接数。如果该值为空,则使用DefaultMaxIdleConnsPerHost
    MaxIdleConnsPerHost int
    // ...
}

在上面的代码中,我们定义了 http.Transport 类型中的公开数据成员,下面详细说明其中的各行代码。

Proxy func(*Request) (*url.URL, error)

Proxy 指定了一个代理方法,该方法接受一个 Request 类型的请求实例作为参数并返回一个最终的 HTTP 代理。如果 Proxy 未指定或者返回的 URL 为零值,将不会有代理被启用。

Dial func(net, addr string) (c net.Conn, err error)

Dial 指定具体的 dial() 方法来创建 TCP 连接。如果不指定,默认将使用 net.Dial() 方法。

TLSClientConfig *tls.Config

SSL 连接专用,TLSClientConfig 指定 tls.Client 所用的 TLS 配置信息,如果不指定,也会使用默认的配置。

DisableKeepAlives bool

是否取消长连接,默认值为 false,即启用长连接。

DisableCompression bool

是否取消压缩(GZip),默认值为 false,即启用压缩。

MaxIdleConnsPerHost int

指定与每个请求的目标主机之间的最大非活跃连接(keep-alive)数量。如果不指定,默认使用 DefaultMaxIdleConnsPerHost 的常量值。

除了 http.Transport 类型中定义的公开数据成员以外,它同时还提供了几个公开的成员方法。

  • func(t *Transport) CloseIdleConnections()。该方法用于关闭所有非活跃的连接。
  • func(t *Transport) RegisterProtocol(scheme string, rt RoundTripper)。该方法可用于注册并启用一个新的传输协议,比如 WebSocket 的传输协议标准(ws),或者 FTP、File 协议等。
  • func(t Transport) RoundTrip(req Request) (resp *Response, err error)。用于实现 http.RoundTripper 接口。

自定义 http.Transport 也很简单,如下列代码所示:

tr := &http.Transport{
    TLSClientConfig: &tls.Config{RootCAs: pool},
    DisableCompression: true,
}
client := &http.Client{Transport: tr}
resp, err := client.Get("https://example.com")

Client 和 Transport 在执行多个 goroutine 的并发过程中都是安全的,但出于性能考虑,应当创建一次后反复使用。

3) 灵活的 http.RoundTripper 接口

在前面的两小节中,我们知道 HTTP Client 是可以自定义的,而 http.Client 定义的第一个公开成员就是一个 http.Transport 类型的实例,且该成员所对应的类型必须实现 http.RoundTripper 接口。

下面我们来看看 http.RoundTripper 接口的具体定义:

type RoundTripper interface {
    // RoundTrip执行一个单一的HTTP事务,返回相应的响应信息。
    // RoundTrip函数的实现不应试图去理解响应的内容。如果RoundTrip得到一个响应,
    // 无论该响应的HTTP状态码如何,都应将返回的err设置为nil。非空的err
    // 只意味着没有成功获取到响应。
    // 类似地,RoundTrip也不应试图处理更高级别的协议,比如重定向、认证和
    // Cookie等。
    //
    // RoundTrip不应修改请求内容, 除非了是为了理解Body内容。每一个请求
    // 的URL和Header域都应被正确初始化
    RoundTrip(*Request) (*Response, error)
}

从上述代码中可以看到,http.RoundTripper 接口很简单,只定义了一个名为 RoundTrip 的方法。任何实现了 RoundTrip() 方法的类型即可实现 http.RoundTripper 接口。前面我们看到的 http.Transport 类型正是实现了 RoundTrip() 方法继而实现了该接口。

http.RoundTripper 接口定义的 RoundTrip() 方法用于执行一个独立的 HTTP 事务,接受传入的 *Request 请求值作为参数并返回对应的 *Response 响应值,以及一个 error 值。

在实现具体的 RoundTrip() 方法时,不应该试图在该函数里边解析 HTTP 响应信息。若响应成功,error 的值必须为 nil,而与返回的 HTTP 状态码无关。若不能成功得到服务端的响应,error 必须为非零值。类似地,也不应该试图在 RoundTrip() 中处理协议层面的相关细节,比如重定向、认证或是 cookie 等。

非必要情况下,不应该在 RoundTrip() 中改写传入的请求体(*Request),请求体的内容(比如 URL 和 Header 等)必须在传入 RoundTrip() 之前就已组织好并完成初始化。

通常,我们可以在默认的 http.Transport 之上包一层 Transport 并实现 RoundTrip() 方法,代码如下所示。

package main
import(
    "net/http"
)
type OurCustomTransport struct {
    Transport http.RoundTripper
}
func (t *OurCustomTransport) transport() http.RoundTripper {
    if t.Transport != nil {
        return t.Transport
    }
    return http.DefaultTransport
}
func (t *OurCustomTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    // 处理一些事情 ...
    // 发起HTTP请求
    // 添加一些域到req.Header中
    return t.transport().RoundTrip(req)
}
func (t *OurCustomTransport) Client() *http.Client {
    return &http.Client{Transport: t}
}
func main() {
    t := &OurCustomTransport{
        //...
    }
    c := t.Client()
    resp, err := c.Get("http://example.com")
    // ...
}

因为实现了 http.RoundTripper 接口的代码通常需要在多个 goroutine 中并发执行,因此我们必须确保实现代码的线程安全性。

4) 设计优雅的 HTTP Client

综上示例讲解可以看到,Go语言标准库提供的 HTTP Client 是相当优雅的。一方面提供了极其简单的使用方式,另一方面又具备极大的灵活性。

Go语言标准库提供的 HTTP Client 被设计成上下两层结构。一层是上述提到的 http.Client 类及其封装的基础方法,我们不妨将其称为“业务层”。之所以称为业务层,是因为调用方通常只需要关心请求的业务逻辑本身,而无需关心非业务相关的技术细节,这些细节包括:

  • HTTP 底层传输细节
  • HTTP 代理
  • gzip 压缩
  • 连接池及其管理
  • 认证(SSL 或其他认证方式)

之所以 HTTP Client 可以做到这么好的封装性,是因为 HTTP Client 在底层抽象了 http.RoundTripper 接口,而 http.Transport 实现了该接口,从而能够处理更多的细节,我们不妨将其称为“传输层”。

HTTP Client 在业务层初始化 HTTP Method、目标 URL、请求参数、请求内容等重要信息后,经过“传输层”,“传输层”在业务层处理的基础上补充其他细节,然后再发起 HTTP 请求,接收服务端返回的 HTTP 响应。

Go语言服务端处理HTTP、HTTPS请求

本节我们将介绍 HTTP 服务端技术,包括如何处理 HTTP 请求和 HTTPS 请求。

处理 HTTP 请求

使用 net/http 包提供的 http.ListenAndServe() 方法,可以在指定的地址进行监听,开启一个 HTTP,服务端该方法的原型如下:

func ListenAndServe(addr string, handler Handler) error

该方法用于在指定的 TCP 网络地址 addr 进行监听,然后调用服务端处理程序来处理传入的连接请求。

该方法有两个参数:第一个参数 addr 即监听地址;第二个参数表示服务端处理程序,通常为空,这意味着服务端调用 http.DefaultServeMux 进行处理,而服务端编写的业务逻辑处理程序 http.Handle() 或 http.HandleFunc() 默认注入 http.DefaultServeMux 中,具体代码如下:

http.Handle("/foo", fooHandler)
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
})
log.Fatal(http.ListenAndServe(":8080", nil))

如果想更多地控制服务端的行为,可以自定义 http.Server,代码如下:

s := &http.Server{
    Addr: ":8080",
    Handler: myHandler,
    ReadTimeout: 10 * time.Second,
    WriteTimeout: 10 * time.Second,
    MaxHeaderBytes: 1 << 20,
}
log.Fatal(s.ListenAndServe())

处理 HTTPS 请求

net/http 包还提供 http.ListenAndServeTLS() 方法,用于处理 HTTPS 连接请求:

func ListenAndServeTLS(addr string, certFile string, keyFile string, handler Handler)
    error

ListenAndServeTLS() 和 ListenAndServe() 的行为一致,区别在于只处理HTTPS请求。

此外,服务器上必须存在包含证书和与之匹配的私钥的相关文件,比如 certFile 对应 SSL 证书文件存放路径,keyFile 对应证书私钥文件路径。如果证书是由证书颁发机构签署的,certFile 参数指定的路径必须是存放在服务器上的经由 CA 认证过的 SSL 证书。

开启 SSL 监听服务也很简单,如下列代码所示:

http.Handle("/foo", fooHandler)
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
})
log.Fatal(http.ListenAndServeTLS(":10443", "cert.pem", "key.pem", nil))

或者是:

ss := &http.Server{
    Addr: ":10443",
    Handler: myHandler,
    ReadTimeout: 10 * time.Second,
    WriteTimeout: 10 * time.Second,
    MaxHeaderBytes: 1 << 20,
}
log.Fatal(ss.ListenAndServeTLS("cert.pem", "key.pem"))

Go语言RPC协议:远程过程调用

Go语言中 RPC(Remote Procedure Call,远程过程调用)是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络细节的应用程序通信协议。RPC 协议构建于 TCP 或 UDP,或者是 HTTP 之上,允许开发者直接调用另一台计算机上的程序,而开发者无需额外地为这个调用过程编写网络通信相关代码,使得开发包括网络分布式程序在内的应用程序更加容易。

RPC 采用客户端—服务器(Client/Server)的工作模式。请求程序就是一个客户端(Client),而服务提供程序就是一个服务器(Server)。当执行一个远程过程调用时,客户端程序首先发送一个带有参数的调用信息到服务端,然后等待服务端响应。

在服务端,服务进程保持睡眠状态直到客户端的调用信息到达为止。当一个调用信息到达时,服务端获得进程参数,计算出结果,并向客户端发送应答信息,然后等待下一个调用。最后,客户端接收来自服务端的应答信息,获得进程结果,然后调用执行并继续进行。

在 Go 中,标准库提供的 net/rpc 包实现了 RPC 协议需要的相关细节,开发者可以很方便地使用该包编写 RPC 的服务端和客户端程序,这使得用 Go语言开发的多个进程之间的通信变得非常简单。

net/rpc 包允许 RPC 客户端程序通过网络或是其他 I/O 连接调用一个远端对象的公开方法(必须是大写字母开头、可外部调用的)。在 RPC 服务端,可将一个对象注册为可访问的服务,之后该对象的公开方法就能够以远程的方式提供访问。一个 RPC 服务端可以注册多个不同类型的对象,但不允许注册同一类型的多个对象。

一个对象中只有满足如下这些条件的方法,才能被 RPC 服务端设置为可供远程访问:

  • 必须是在对象外部可公开调用的方法(首字母大写);
  • 必须有两个参数,且参数的类型都必须是包外部可以访问的类型或者是 Go 内建支持的类型;
  • 第二个参数必须是一个指针;
  • 方法必须返回一个error类型的值。

以上 4 个条件,可以简单地用如下一行代码表示:

func (t *T) MethodName(argType T1, replyType *T2) error

在上面这行代码中,类型 T、T1 和 T2 默认会使用 Go 内置的 encoding/gob 包进行编码解码。关于 encoding/gob 包的内容,稍后我们将会对其进行介绍。

该方法(MethodName)的第一个参数表示由 RPC 客户端传入的参数,第二个参数表示要返回给 RPC 客户端的结果,该方法最后返回一个 error 类型的值。

RPC 服务端可以通过调用 rpc.ServeConn 处理单个连接请求。多数情况下,通过 TCP 或是 HTTP 在某个网络地址上进行监听来创建该服务是个不错的选择。

在 RPC 客户端,Go 的 net/rpc 包提供了便利的 rpc.Dial() 和 rpc.DialHTTP() 方法来与指定的 RPC 服务端建立连接。在建立连接之后,Go 的 net/rpc 包允许我们使用同步或者异步的方式接收 RPC 服务端的处理结果。

调用 RPC 客户端的 Call() 方法则进行同步处理,这时候客户端程序按顺序执行,只有接收完 RPC 服务端的处理结果之后才可以继续执行后面的程序。

当调用 RPC 客户端的 Go() 方法时,则可以进行异步处理,RPC 客户端程序无需等待服务端的结果即可执行后面的程序,而当接收到 RPC 服务端的处理结果时,再对其进行相应的处理。

无论是调用 RPC 客户端的 Call() 或者是 Go() 方法,都必须指定要调用的服务及其方法名称,以及一个客户端传入参数的引用,还有一个用于接收处理结果参数的指针。

如果没有明确指定 RPC 传输过程中使用何种编码解码器,默认将使用 Go 标准库提供的 encoding/gob 包进行数据传输。

接下来,我们来看一组 RPC 服务端和客户端交互的示例程序。下面的代码是 RPC 服务端程序。

package server
type Args struct {
    A, B int
}
type Quotient struct {
    Quo, Rem int
}
type Arith int
func (t *Arith) Multiply(args *Args, reply *int) error {
    *reply = args.A * args.B
    return nil
}
func (t *Arith) Divide(args *Args, quo *Quotient) error {
    if args.B == 0 {
        return errors.New("divide by zero")
    }
    quo.Quo = args.A / args.B
    quo.Rem = args.A % args.B
    return nil
}

注册服务对象并开启该 RPC 服务的代码如下:

arith := new(Arith)
rpc.Register(arith)
rpc.HandleHTTP()
l, e := net.Listen("tcp", ":1234")
if e != nil {
    log.Fatal("listen error:", e)
}
go http.Serve(l, nil)

此时,RPC 服务端注册了一个 Arith 类型的对象及其公开方法 Arith.Multiply() 和 Arith.Divide() 供 RPC 客户端调用。RPC 在调用服务端提供的方法之前,必须先与 RPC 服务端建立连接,如下列代码所示:

client, err := rpc.DialHTTP("tcp", serverAddress + ":1234")
if err != nil {
    log.Fatal("dialing:", err)
}

在建立连接之后,RPC 客户端可以调用服务端提供的方法。首先,我们来看同步调用程序顺序执行的方式:

args := &server.Args{7,8}
var reply int
err = client.Call("Arith.Multiply", args, &reply)
if err != nil {
    log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d*%d=%d", args.A, args.B, reply)

此外,还可以以异步方式进行调用,具体代码如下:

quotient := new(Quotient)
divCall := client.Go("Arith.Divide", args, &quotient, nil)
replyCall := <-divCall.Done

如何设计优雅的RPC接口

Go语言的 net/rpc 很灵活,它在数据传输前后实现了编码解码器的接口定义。这意味着,开发者可以自定义数据的传输方式以及 RPC 服务端和客户端之间的交互行为。

RPC 提供的编码解码器接口如下:

type ClientCodec interface {
    WriteRequest(*Request, interface{}) error
    ReadResponseHeader(*Response) error
    ReadResponseBody(interface{}) error
    Close() error
}
type ServerCodec interface {
    ReadRequestHeader(*Request) error
    ReadRequestBody(interface{}) error
    WriteResponse(*Response, interface{}) error
    Close() error
}

接口 ClientCodec 定义了 RPC 客户端如何在一个 RPC 会话中发送请求和读取响应。客户端程序通过 WriteRequest() 方法将一个请求写入到 RPC 连接中,并通过 ReadResponseHeader() 和 ReadResponseBody() 读取服务端的响应信息。当整个过程执行完毕后,再通过 Close() 方法来关闭该连接。

接口 ServerCodec 定义了 RPC 服务端如何在一个 RPC 会话中接收请求并发送响应。服务端程序通过 ReadRequestHeader() 和 ReadRequestBody() 方法从一个 RPC 连接中读取请求信息。

然后再通过 WriteResponse() 方法向该连接中的 RPC 客户端发送响应。当完成该过程后,通过 Close() 方法来关闭连接。

通过实现上述接口,我们可以自定义数据传输前后的编码解码方式,而不仅仅局限于 Gob。

同样,可以自定义 RPC 服务端和客户端的交互行为。实际上,Go 标准库提供的 net/rpc/json 包,就是一套实现了 rpc.ClientCodec 和 rpc.ServerCodec 接口的 JSON-RPC 模块。

Go语言解码未知结构的JSON数据

我们已经知道,Go语言支持接口。在 Go语言里,接口是一组预定义方法的组合,任何一个类型均可通过实现接口预定义的方法来实现,且无需显示声明,所以没有任何方法的空接口可以代表任何类型。换句话说,每一个类型其实都至少实现了一个空接口。

Go 内建这样灵活的类型系统,向我们传达了一个很有价值的信息:空接口是通用类型。如果要解码一段未知结构的 JSON,只需将这段 JSON 数据解码输出到一个空接口即可。关于 JSON 数据的编码和解码的详细介绍可以阅读《Json数据编码和解码》一节。

在解码 JSON 数据的过程中,JSON 数据里边的元素类型将做如下转换:

  • JSON 中的布尔值将会转换为 Go 中的 bool 类型;
  • 数值会被转换为 Go 中的 float64 类型;
  • 字符串转换后还是 string 类型;
  • JSON 数组会转换为 []interface{} 类型;
  • JSON 对象会转换为 map[string]interface{} 类型;
  • null 值会转换为 nil。

在 Go 的标准库 encoding/json 包中,允许使用 map[string]interface{} 和 []interface{} 类型的值来分别存放未知结构的JSON对象或数组,示例代码如下:

b := []byte(`{
    "Title": "Go语言编程",
    "Authors": ["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan",
    "XuDaoli"],
    "Publisher": "ituring.com.cn",
    "IsPublished": true,
    "Price": 9.99,
    "Sales": 1000000
}`)
var r interface{}
err := json.Unmarshal(b, &r)

在上述代码中,r 被定义为一个空接口。json.Unmarshal() 函数将一个 JSON 对象解码到空接口 r 中,最终 r 将会是一个键值对的 map[string]interface{} 结构:

map[string]interface{}{
    "Title": "Go语言编程",
    "Authors": ["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan",
    "XuDaoli"],
    "Publisher": "ituring.com.cn",
    "IsPublished": true,
    "Price": 9.99,
    "Sales": 1000000
}

要访问解码后的数据结构,需要先判断目标结构是否为预期的数据类型:

gobook, ok := r.(map[string]interface{})

然后,我们可以通过 for 循环搭配 range 语句一一访问解码后的目标数据:

if ok {
    for k, v := range gobook {
        switch v2 := v.(type) {
            case string:
                fmt.Println(k, "is string", v2)
            case int:
                fmt.Println(k, "is int", v2)
            case bool:
                fmt.Println(k, "is bool", v2)
            case []interface{}:
                fmt.Println(k, "is an array:")
                for i, iv := range v2 {
                    fmt.Println(i, iv)
                }
            default:
                fmt.Println(k, "is another type not handle yet")
        }
    }
}

虽然有些烦琐,但的确是一种解码未知结构的 JSON 数据的安全方式。

JSON 的流式读写

Go 内建的 encoding/json 包还提供 Decoder 和 Encoder 两个类型,用于支持 JSON 数据的流式读写,并提供 NewDecoder() 和 NewEncoder() 两个函数来便于具体实现:

func NewDecoder(r io.Reader) *Decoder
func NewEncoder(w io.Writer) *Encoder

下面代码演示了从标准输入流中读取 JSON 数据,然后将其解码,但只保留 Title 字段(书名),再写入到标准输出流中。

package main
import (
    "encoding/json"
    "log"
    "os"
)
func main() {
    dec := json.NewDecoder(os.Stdin)
    enc := json.NewEncoder(os.Stdout)
    for {
        var v map[string]interface{}
        if err := dec.Decode(&v); err != nil {
            log.Println(err)
            return
        }
        for k := range v {
            if k != "Title" {
                v[k] = nil, false
            }
        }
        if err := enc.Encode(&v); err != nil {
            log.Println(err)
        }
    }
}

使用 Decoder 和 Encoder 对数据流进行处理可以应用得更为广泛些,比如读写 HTTP 连接、WebSocket 或文件等,Go 的标准库 net/rpc/jsonrpc 就是一个应用了 Decoder 和 Encoder 的实际例子。

Go语言如何搭建网站程序

本节我们来学习如何搭建一个简单的网站程序。

首先打开你最喜爱的编辑器,编写如下所示的几行代码,并将其保存为 hello.go。

package main
import (
    "io"
    "log"
    "net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
    io.WriteString(w, "Hello, world!")
}
func main() {
    http.HandleFunc("/hello", helloHandler)
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

我们引入了 Go语言标准库中的 net/http 包,主要用于提供 Web 服务,响应并处理客户端(浏览器)的 HTTP请求。

同时,使用 io 包而不是 fmt 包来输出字符串,这样源文件编译成可执行文件后,体积要小很多,运行起来也更省资源。

接下来,让我们简单地了解 Go语言的 http 包在上述示例中所做的工作。

net/http 包简介

可以看到,我们在 main() 方法中调用了 http.HandleFunc(),该方法用于分发请求,即针对某一路径请求将其映射到指定的业务逻辑处理方法中。如果你有其他编程语言(比如 Ruby、Python 或者 PHP 等)的 Web 开发经验,可以将其形象地理解为提供类似 URL 路由或者 URL 映射之类的功能。

在 hello.go 中,http.HandleFunc() 方法接受两个参数,第一个参数是 HTTP 请求的目标路径"/hello",该参数值可以是字符串,也可以是字符串形式的正则表达式,第二个参数指定具体的回调方法,比如 helloHandler。

当我们的程序运行起来后,访问 http://localhost:8080/hello,程序就会去调用 helloHandler() 方法中的业务逻辑程序。

在上述例子中, helloHandler() 方法是 http.HandlerFunc 类型的实例,并传入 http.ResponseWriter 和 http.Request 作为其必要的两个参数。http.ResponseWriter 类型的对象用于包装处理 HTTP 服务端的响应信息。

我们将字符串"Hello, world!"写入类型为 http.ResponseWriter 的 w 实例中,即可将该字符串数据发送到 HTTP 客户端。第二个参数 r *http.Request 表示的是此次 HTTP 请求的一个数据结构体,即代表一个客户端,不过该示例中我们尚未用到它。

还看到,在 main() 方法中调用了 http.ListenAndServe(),该方法用于在示例中监听 8080 端口,接受并调用内部程序来处理连接到此端口的请求。如果端口监听失败,会调用 log.Fatal() 方法输出异常出错信息。

正如你所见,main() 方法中的短短两行即开启了一个 HTTP 服务,使用 Go语言的 net/http 包搭建一个 Web 是如此简单!当然,net/http 包的作用远不止这些,我们只用到其功能的一小部分。

试着编译并运行当前的这份 hello.go 源文件:

$ go run hello.go

然后在浏览器访问 http://localhost:8080/hello,会看到如下图所示的界面

示例:开发一个简单的相册网站

本节我们将综合之前介绍的网站开发相关知识,一步步介绍如何开发一个虽然简单但五脏俱全的相册网站。

新建工程

首先创建一个用于存放工程源代码的目录并切换到该目录中去,随后创建一个名为 photoweb.go 的文件,用于后面编辑我们的代码:

$ mkdir -p photoweb/uploads
$ cd photoweb
$ touch photoweb.go

我们的示例程序不是再造一个 Flickr 那样的网站或者比其更强大的图片分享网站,虽然我们可能很想这么玩。不过还是先让我们快速开发一个简单的网站小程序,暂且只实现以下最基本的几个功能:

  • 支持图片上传;
  • 在网页中可以查看已上传的图片;
  • 能看到所有上传的图片列表;
  • 可以删除指定的图片。

功能不多,也很简单。在大概了解上一节中的网页输出 Hello world 示例后,想必你已经知道可以引入 net/http 包来提供更多的路由分派并编写与之对应的业务逻辑处理方法,只不过会比输出一行 Hello, world! 多一些环节,还有些细节需要关注和处理。

使用 net/http 包提供网络服务

接下来,我们继续使用 Go 标准库中的 net/http 包来一步步构建整个相册程序的网络服务。

1) 上传图片

先从最基本的图片上传着手,具体代码如下所示。

package main
import (
    "io"
    "log"
    "net/http"
)
func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        io.WriteString(w, "<form method=\"POST\" action=\"/upload\" "+
            " enctype=\"multipart/form-data\">"+
            "Choose an image to upload: <input name=\"image\" type=\"file\" />"+
            "<input type=\"submit\" value=\"Upload\" />"+
            "</form>")
        return
    }
}
func main() {
    http.HandleFunc("/upload", uploadHandler)
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

可以看到,结合 main() 和 uploadHandler() 方法,针对 HTTP GET 方式请求 /upload 路径,程序将会往 http.ResponseWriter 类型的实例对象 w 中写入一段 HTML 文本,即输出一个 HTML 上传表单。

如果我们使用浏览器访问这个地址,那么网页上将会是一个可以上传文件的表单。光有上传表单还不能完成图片上传,服务端程序还必须有接收上传图片的相关处理。针对上传表单提交过来的文件,我们对 uploadHandler() 方法再添加些业务逻辑程序:

const (
    UPLOAD_DIR = "./uploads"
)
func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        io.WriteString(w, "<form method=\"POST\" action=\"/upload\" "+
            " enctype=\"multipart/form-data\">"+
            "Choose an image to upload: <input name=\"image\" type=\"file\" />"+
            "<input type=\"submit\" value=\"Upload\" />"+
            "</form>")
        return
    }
    if r.Method == "POST" {
        f, h, err := r.FormFile("image")
        if err != nil {
            http.Error(w, err.Error(),
            http.StatusInternalServerError)
            return
        }
        filename := h.Filename
        defer f.Close()
        t, err := os.Create(UPLOAD_DIR + "/" + filename)
        if err != nil {
            http.Error(w, err.Error(),
            http.StatusInternalServerError)
            return
        }
        defer t.Close()
        if _, err := io.Copy(t, f); err != nil {
            http.Error(w, err.Error(),
            http.StatusInternalServerError)
            return
        }
        http.Redirect(w, r, "/view?id="+filename,
        http.StatusFound)
    }
}

如果是客户端发起的 HTTP POST 请求,那么首先从表单提交过来的字段寻找名为 image 的文件域并对其接值,调用 r.FormFile() 方法会返回 3 个值,各个值的类型分别是 multipart.File、*multipart.FileHeader 和 error。

如果上传的图片接收不成功,那么在示例程序中返回一个 HTTP 服务端的内部错误给客户端。如果上传的图片接收成功,则将该图片的内容复制到一个临时文件里。如果临时文件创建失败,或者图片副本保存失败,都将触发服务端内部错误。

如果临时文件创建成功并且图片副本保存成功,即表示图片上传成功,就跳转到查看图片页面。此外,我们还定义了两个 defer 语句,无论图片上传成功还是失败,当 uploadHandler() 方法执行结束时,都会先关闭临时文件句柄,继而关闭图片上传到服务器文件流的句柄。

别忘了在程序开头引入 io/ioutil 这个包,因为示例程序中用到了 ioutil.TempFile() 这个方法。

当图片上传成功后,我们即可在网页上查看这张图片,顺便确认图片是否真正上传到了服务端。接下来在网页中呈现这张图片。

2) 在网页上显示图片

要在网页中显示图片,必须有一个可以访问到该图片的网址。在前面的示例代码中,图片上传成功后会跳转到 /view?id= 这样的网址,因此我们的程序要能够将对 /view 路径的访问映射到某个具体的业务逻辑处理方法。

首先,在 photoweb 程序中新增一个名为 viewHanlder() 的方法,其代码如下:

func viewHandler(w http.ResponseWriter, r *http.Request) {
    imageId = r.FormValue("id")
    imagePath = UPLOAD_DIR + "/" + imageId
    w.Header().Set("Content-Type", "image")
    http.ServeFile(w, r, imagePath)
}

在上述代码中,我们首先从客户端请求中对参数进行接值。r.FormValue("id") 即可得到客户端请求传递的图片唯一 ID,然后我们将图片 ID 结合之前保存图片用的目录进行组装,即可得到文件在服务器上的存放路径。

接着,调用 http.ServeFile() 方法将该路径下的文件从磁盘中读取并作为服务端的返回信息输出给客户端。同时,也将 HTTP 响应头输出格式预设为 image 类型。

这是一种比较简单的示意写法,实际上应该严谨些,准确解析出文件的 MimeType 并将其作为 Content-Type 进行输出,具体可参考 Go语言标准库中的 http.DetectContentType() 方法和 mime 包提供的相关方法。

完成 viewHandler() 的业务逻辑后,我们将该方法注册到程序的 main() 方法中,与 /view 路径访问形成映射关联。main() 方法的代码如下:

func main() {
    http.HandleFunc("/view", viewHandler)
    http.HandleFunc("/upload", uploadHandler)
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

这样当客户端(浏览器)访问 /view 路径并传递 id 参数时,即可直接以 HTTP 形式看到图片的内容。在网页上,将会呈现一张可视化的图片。

3) 处理不存在的图片访问

理论上,只要是 uploads/ 目录下有的图片,都能够访问到,但我们还是假设有意外情况,比如网址中传入的图片 ID 在 uploads/ 没有对应的文件,这时,我们的 viewHandler() 方法就显得很脆弱了。

不管是给出友好的错误提示还是返回 404 页面,都应该对这种情况作相应处理。我们不妨先以最简单有效的方式对其进行处理,修改 viewHandler() 方法,具体如下:

func viewHandler(w http.ResponseWriter, r *http.Request) {
    imageId = r.FormValue("id")
    imagePath = UPLOAD_DIR + "/" + imageId
    if exists := isExists(imagePath);!exists {
        http.NotFound(w, r)
        return
    }
    w.Header().Set("Content-Type", "image")
    http.ServeFile(w, r, imagePath)
}
func isExists(path string) bool {
    _, err := os.Stat(path)
    if err == nil {
        return true
    }
    return os.IsExist(err)
}

同时,我们增加了 isExists() 辅助函数,用于检查文件是否真的存在。

4) 列出所有已上传图片

应该有个入口,可以看到所有已上传的图片。对于所有列出的这些图片,我们可以选择进行查看或者删除等操作。下面假设在访问首页时列出所有上传的图片。

由于我们将客户端上传的图片全部保存在工程的 ./uploads 目录下,所以程序中应该有个名叫 listHandler() 的方法,用于在网页上列出该目录下存放的所有文件。暂时我们不考虑以缩略图的形式列出所有已上传图片,只需列出可供访问的文件名称即可。下面我们就来实现这个 listHandler() 方法:

func listHandler(w http.ResponseWriter, r *http.Request) {
    fileInfoArr, err := ioutil.ReadDir("./uploads")
    if err != nil {
        http.Error(w, err.Error(),
        http.StatusInternalServerError)
        return
    }
    var listHtml string
    for _, fileInfo := range fileInfoArr {
        imgid := fileInfo.Name
        listHtml += "<li><a href=\"/view?id="+imgid+"\">imgid</a></li>"
    }
    io.WriteString(w, "<ol>"+listHtml+"</ol>")
}

从上面的 listHandler() 方法中可以看到,程序先从 ./uploads 目录中遍历得到所有文件并赋值到 fileInfoArr 变量里。fileInfoArr 是一个数组,其中的每一个元素都是一个文件对象。

然后,程序遍历 fileInfoArr 数组并从中得到图片的名称,用于在后续的 HTML 片段中显示文件名和传入的参数内容。listHtml 变量用于在 for 循序中将图片名称一一串联起来生成一段 HTML,最后调用 io.WriteString() 方法将这段 HTML 输出返回给客户端。

然后在 photoweb. go 程序的 main() 方法中,我们将对首页的访问映射到 listHandler() 方法。main() 方法的代码如下:

func main() {
    http.HandleFunc("/", listHandler)
    http.HandleFunc("/view", viewHandler)
    http.HandleFunc("/upload", uploadHandler)
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

这样在访问网站首页的时候,即可看到已上传的所有图片列表了。

不过,你是否注意到一个事实,我们在 photoweb.go 程序的 uploadHandler() 和 listHandler() 方法中都使用 io.WriteString() 方法输出 HTML。

正如你想到的那样,在业务逻辑处理程序中混杂 HTML 可不是什么好事情,代码多起来后会导致程序不够清晰,而且改动程序里边的 HTML 文本时,每次都要重新编译整个工程的源代码才能看到修改后的效果。

正确的做法是,应该将业务逻辑程序和表现层分离开来,各自单独处理。这时候,就需要使用网页模板技术了。

Go 标准库中的 html/template 包对网页模板有着良好的支持。接下来,让我们来了解如何在 photoweb.go 程序中用上 Go 的模板功能。

渲染网页模板

使用 Go 标准库提供的 html/template 包,可以让我们将 HTML 从业务逻辑程序中抽离出来形成独立的模板文件,这样业务逻辑程序只负责处理业务逻辑部分和提供模板需要的数据,模板文件负责数据要表现的具体形式。

然后模板解析器将这些数据以定义好的模板规则结合模板文件进行渲染,最终将渲染后的结果一并输出,构成一个完整的网页。

下面我们把 photoweb.go 程序的 uploadHandler() 和 listHandler() 方法中的 HTML 文本 抽出,生成模板文件。

新建一个名为 upload.html 的文件,内容如下:

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Upload</title>
</head>
<body>
    <form method="POST" action="/upload" enctype="multipart/form-data">
        Choose an image to upload: <input name="image" type="file" />
        <input type="submit" value="Upload" />
    </form>
</body>
</html>

然后新建一个名为 list.html 的文件,内容如下:

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>List</title>
</head>
<body>
    <ol>
        {{range $.images}}
            <li><a href="/view?id={{.|urlquery}}">{{.|html}}</a></li>
        {{end}}
    </ol>
</body>
</html>

在上述模板中,双大括号 {{}} 是区分模板代码和 HTML 的分隔符,括号里边可以是要显示输出的数据,或者是控制语句,比如 if 判断式或者 range 循环体等。

range 语句在模板中是一个循环过程体,紧跟在 range 后面的必须是一个 array、slice 或 map 类型的变量。在 list.html 模板中,images 是一组 string 类型的切片。

在使用 range 语句遍历的过程中,. 即表示该循环体中的当前元素,.|formatter 表示对当前这个元素的值以 formatter 方式进行格式化输出,比如 .|urlquery} 即表示对当前元素的值进行转换以适合作为 URL 一部分,而 {{.|html 表示对当前元素的值进行适合用于 HTML 显示的字符转化,比如">"会被转义成">"。

如果 range 关键字后面紧跟的是 map 这样的多维复合结构,循环体中的当前元素可以用 .key1.key2.keyN 这样的形式表示。

如果要更改模板中默认的分隔符,可以使用 template 包提供的 Delims() 方法。

在了解模板语法后,接着我们修改 photoweb.go 源文件,引入 html/template 包,并修改 uploadHandler() 和 listHandler() 方法,具体如下所示。

package main
import (
    "io"
    "log"
    "net/http"
    "io/ioutil"
    "html/template"
)
func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        t, err := template.ParseFiles("upload.html")
        if err != nil {
            http.Error(w, err.Error(),http.StatusInternalServerError)
            return
        }
        t.Execute(w, nil)
        return
    }
    if r.Method == "POST" {
        // ...
    }
}
func listHandler(w http.ResponseWriter, r *http.Request) {
    fileInfoArr, err := ioutil.ReadDir("./uploads")
    if err != nil {
        http.Error(w, err.Error(),
        http.StatusInternalServerError)
        return
    }
    locals := make(map[string]interface{})
    images := []string{}
    for _, fileInfo := range fileInfoArr {
        images = append(images, fileInfo.Name)
    }
    locals["images"] = images t, err := template.ParseFiles("list.html")
    if err != nil {
        http.Error(w, err.Error(),
        http.StatusInternalServerError)
        return
    }
    t.Execute(w, locals)
}

在上面的代码中,template.ParseFiles() 函数将会读取指定模板的内容并且返回一个 *template.Template 值。

t.Execute() 方法会根据模板语法来执行模板的渲染,并将渲染后的结果作为 HTTP 的返回数据输出。

在 uploadHandler() 方法和 listHandler() 方法中,均调用了 template.ParseFiles() 和 t.Execute() 这两个方法。根据 DRY(Don’t Repeat Yourself)原则,我们可以将模板渲染代码分离出来,单独编写一个处理函数,以便其他业务逻辑处理函数都可以使用。于是,我们可以定义一个名为 renderHtml() 的方法用来渲染模板:

func renderHtml(w http.ResponseWriter, tmpl string, locals map[string]interface{})
err error {
    t, err = template.ParseFiles(tmpl + ".html")
    if err != nil {
        return
    }
    err = t.Execute(w, locals)
}

有了 renderHtml() 这个通用的模板渲染方法,uploadHandler() 和 listHandler() 方法的代码可以再精简些,如下:

func uploadHandler(w http.ResponseWriter, r *http.Request){
    if r.Method == "GET" {
        if err := renderHtml(w, "upload", nil); err != nil{
            http.Error(w, err.Error(),
            http.StatusInternalServerError)
            return
        }
    }
    if r.Method == "POST" {
        // ...
    }
}
func listHandler(w http.ResponseWriter, r *http.Request) {
    fileInfoArr, err := ioutil.ReadDir("./uploads")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    locals := make(map[string]interface{})
    images := []string{}
    for _, fileInfo := range fileInfoArr {
        images = append(images, fileInfo.Name)
    }
    locals["images"] = images
    if err = renderHtml(w, "list", locals); err != nil {
        http.Error(w, err.Error(),
        http.StatusInternalServerError)
    }
}

当我们引入了 Go 标准库中的 html/template 包,实现了业务逻辑层与表现层分离后,对模板渲染逻辑去重,编写并使用通用模板渲染方法 renderHtml(),这让业务逻辑处理层的代码看起来确实要清晰简洁许多。

不过,直觉敏锐的你可能已经发现,无论是重构后的 uploadHandler() 还是 listHandler() 方法,每次调用这两个方法时都会重新读取并渲染模板。很明显,这很低效,也比较浪费资源,有没有一种办法可以让模板只加载一次呢?

答案是肯定的,聪明的你可能已经想到怎么对模板进行缓存了。

模板缓存

对模板进行缓存,即指一次性预加载模板。我们可以在 photoweb 程序初始化运行的时候,将所有模板一次性加载到程序中。正好 Go 的包加载机制允许我们在 init() 函数中做这样的事情,init() 会在 main() 函数之前执行。

首先,我们在 photoweb 程序中声明并初始化一个全局变量 templates,用于存放所有模板内容:

templates := make(map[string]*template.Template)

templates 是一个 map 类型的复合结构,map 的键(key)是字符串类型,即模板的名字,值(value)是 *template.Template 类型。

接着,我们在 photoweb 程序的 init() 函数中一次性加载所有模板:

func init() {
    for _, tmpl := range []string{"upload", "list"} {
        t := template.Must(template.ParseFiles(tmpl + ".html"))
        templates[tmpl] = t
    }
}

在上面的代码中,我们在 template.ParseFiles() 方法的外层强制使用 template.Must() 进行封装,template.Must() 确保了模板不能解析成功时,一定会触发错误处理流程。之所以这么做,是因为倘若模板不能成功加载,程序能做的唯一有意义的事情就是退出。

在 range 语句中,包含了我们希望加载的 upload.html 和 list.html 两个模板,如果我们想加载更多模板,只需往这个数组中添加更多元素即可。当然,最好的办法应该是将所有 HTML 模板文件统一放到一个子文件夹中,然后对这个模板文件夹进行遍历和预加载。

如果需要加载新的模板,只需在这个文件夹中新建模板即可。这样做的好处是不用反复修改代码即可重新编译程序,而且实现了业务层和表现层真正意义上的分离。

不妨让我们这样试试看!

首先创建一个名为 ./views 的目录,然后将当前目录下所有 html 文件移动到该目录下:

$ mkdir ./views $ mv *.html ./views

接着适当地对 init() 方法中的代码进行改写,好让程序初始化时即可预加载该目录下的所有模板文件,如下列代码所示:

const (
    TEMPLATE_DIR = "./views"
)
templates := make(map[string]*template.Template)
func init() {
    fileInfoArr, err := ioutil.ReadDir(TEMPLATE_DIR)
    if err != nil {
        panic(err)
        return
    }
    var templateName, templatePath string
    for _, fileInfo := range fileInfoArr {
        templateName = fileInfo.Name
        if ext := path.Ext(templateName); ext != ".html" {
            continue
        }
        templatePath = TEMPLATE_DIR + "/" + templateName
        log.Println("Loading template:", templatePath)
        t := template.Must(template.ParseFiles(templatePath))
        templates[tmpl] = t
    }
}

同时,别忘了对 renderHtml() 的代码进行相应的调整:

func renderHtml(w http.ResponseWriter, tmpl string, locals map[string]interface{})
    err error {
    err = templates[tmpl].Execute(w, locals)
}

此时,renderHtml() 函数的代码也变得更为简洁。还好我们之前单独封装了 renderHtml() 函数,这样全局代码中只需更改这一个地方,这无疑是代码解耦的好处之一!

错误处理

在前面的代码中,有不少地方对于出错处理都是直接返回 http.Error() 50x 系列的服务端内部错误。从 DRY 的原则来看,不应该在程序中到处使用一样的代码。我们可以定义一个名为 check() 的方法,用于统一捕获 50x 系列的服务端内部错误:

func check(err error) {
    if err != nil {
        panic(err)
    }
}

此时,我们可以将 photoweb 程序中出现的以下代码:

if err != nil {
    http.Error(w, err.Error(),http.StatusInternalServerError)
    return
}

统一替换为 check() 处理:

check(err)

错误处理虽然简单很多,但是也带来一个问题。由于发生错误触发错误处理流程必然会引发程序停止运行,这种改法有点像搬起石头砸自己的脚。

其实我们可以换一种思维方式。尽管我们从书写上能保证大多数错误都能得到相应的处理,但根据墨菲定律,有可能出问题的地方就一定会出问题,在计算机程序里尤其如此。如果程序中我们正确地处理了 99 个错误,但若有一个系统错误意外导致程序出现异常,那么程序同样还是会终止运行。

我们不能预计一个工程里边会出现多少意外的情况,但是不管什么意外,只要会触发错误处理流程,我们就有办法对其进行处理。如果这样思考,那么前面这种改法又何尝不是置死地而后生呢?

接下来,让我们了解如何处理 panic 导致程序崩溃的情况。

巧用闭包避免程序运行时出错崩溃

Go 支持闭包。闭包可以是一个函数里边返回的另一个匿名函数,该匿名函数包含了定义在它外面的值。使用闭包,可以让我们网站的业务逻辑处理程序更安全地运行。

我们可以在 photoweb 程序中针对所有的业务逻辑处理函数(listHandler()、viewHandler() 和 uploadHandler())再进行一次包装。

在如下的代码中,我们定义了一个名为 safeHandler() 的函数,该函数有一个参数并且返回一个值,传入的参数和返回值都是一个函数,且都是http.HandlerFunc类型,这种类型的函数有两个参数:http.ResponseWriter 和 *http.Request。

函数规格同 photoweb 的业务逻辑处理函数完全一致。事实上,我们正是要把业务逻辑处理函数作为参数传入到 safeHandler() 方法中,这样任何一个错误处理流程向上回溯的时候,我们都能对其进行拦截处理,从而也能避免程序停止运行:

func safeHandler(fn http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if e, ok := recover().(error); ok {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                // 或者输出自定义的 50x 错误页面
                // w.WriteHeader(http.StatusInternalServerError)
                // renderHtml(w, "error", e)
                // logging
                log.Println("WARN: panic in %v - %v", fn, e)
                log.Println(string(debug.Stack()))
            }
        }()
        fn(w, r)
    }
}

在上述这段代码中,我们巧妙地使用了 defer 关键字搭配 recover() 方法终结 panic 的肆行。safeHandler() 接收一个业务逻辑处理函数作为参数,同时调用这个业务逻辑处理函数。该业
务逻辑函数执行完毕后,safeHandler() 中 defer 指定的匿名函数会执行。

倘若业务逻辑处理函数里边引发了 panic,则调用 recover() 对其进行检测,若为一般性的错误,则输出 HTTP 50x 出错信息并记录日志,而程序将继续良好运行。

要应用 safeHandler() 函数,只需在 main() 中对各个业务逻辑处理函数做一次包装,如下面的代码所示:

func main() {
    http.HandleFunc("/", safeHandler(listHandler))
    http.HandleFunc("/view", safeHandler(viewHandler))
    http.HandleFunc("/upload", safeHandler(uploadHandler))
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

动态请求和静态资源分离

你一定还有一个疑问,那就是前面的业务逻辑层都是动态请求,但若是针对静态资源(比如 CSS 和 JavaScript 等),是没有业务逻辑处理的,只需提供静态输出。在 Go 里边,这当然是可行的。

还记得前面我们在 viewHandler() 函数里边有用到 http.ServeFile() 这个方法吗?net/http 包提供的这个 ServeFile() 函数可以将服务端的一个文件内容读写到 http.Response-Writer 并返回给请求来源的 *http.Request 客户端。

用前面介绍的闭包技巧结合这个 http.ServeFile() 方法,我们就能轻而易举地实现业务逻辑的动态请求和静态资源的完全分离。

假设我们有 ./public 这样一个存放 css/、js/、images/ 等静态资源的目录,原则上所有如下的请求规则都指向该 ./public 目录下相对应的文件:

[GET] /assets/css/*.css
[GET] /assets/js/*.js
[GET] /assets/images/*.js

然后,我们定义一个名为 staticDirHandler() 的方法,用于实现上述需求:

const (
    ListDir = 0x0001
)
func staticDirHandler(mux *http.ServeMux, prefix string, staticDir string, flags int)
{
    mux.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) {
        file := staticDir + r.URL.Path[len(prefix)-1:]
        if (flags & ListDir) == 0 {
            if exists := isExists(file); !exists {
                http.NotFound(w, r)
                return
            }
        }
        http.ServeFile(w, r, file)
    })
}

最后,我们需要稍微改动下 main() 函数:

func main() {
    mux := http.NewServeMux()
    staticDirHandler(mux, "/assets/", "./public", 0)
    mux.HandleFunc("/", safeHandler(listHandler))
    mux.HandleFunc("/view", safeHandler(viewHandler))
    mux.HandleFunc("/upload", safeHandler(uploadHandler))
    err := http.ListenAndServe(":8080", mux)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

如此即完美实现了静态资源和动态请求的分离。

当然,我们要思考是否确实需要用 Go 来提供静态资源的访问。如果使用外部 Web 服务器(比如 Nginx 等),就没必要使用 Go 编写的静态文件服务了。在本机做开发时有一个程序内置的静态文件服务器还是很实用的。

重构

经过前面对 photoweb 程序一一重整之后,整个工程的目录结构如下:

├── photoweb.go
├── public
    ├── css
    ├── images
    └── js
├── uploads
└── views
    ├── list.html
    └── upload.html

photoweb.go 程序的源码最终如下所示。

package main
import (
    "io"
    "log"
    "path"
    "net/http"
    "io/ioutil"
    "html/template"
    "runtime/debug"
)
const (
    ListDir = 0x0001
    UPLOAD_DIR = "./uploads"
    TEMPLATE_DIR = "./views"
)
templates := make(map[string]*template.Template)
func init() {
    fileInfoArr, err := ioutil.ReadDir(TEMPLATE_DIR)
    check(err)
    var templateName, templatePath string
    for _, fileInfo := range fileInfoArr {
        templateName = fileInfo.Name
        if ext := path.Ext(templateName); ext != ".html" {
            continue
        }
        templatePath = TEMPLATE_DIR + "/" + templateName
        log.Println("Loading template:", templatePath)
        t := template.Must(template.ParseFiles(templatePath))
        templates[tmpl] = t
    }
}
func check(err error) {
    if err != nil {
        panic(err)
    }
}
func renderHtml(w http.ResponseWriter, tmpl string, locals map[string]interface{}) {
    err := templates[tmpl].Execute(w, locals)
    check(err)
}
func isExists(path string) bool {
    _, err := os.Stat(path)
    if err == nil {
        return true
    }
    return os.IsExist(err)
}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        renderHtml(w, "upload", nil);
    }
    if r.Method == "POST" {
        f, h, err := r.FormFile("image")
        check(err)
        filename := h.Filename
        defer f.Close()
        t, err := ioutil.TempFile(UPLOAD_DIR, filename)
        check(err)
        defer t.Close()
        _, err := io.Copy(t, f)
        check(err)
        http.Redirect(w, r, "/view?id="+filename,
            http.StatusFound)
    }
}
func viewHandler(w http.ResponseWriter, r *http.Request) {
    imageId = r.FormValue("id")
    imagePath = UPLOAD_DIR + "/" + imageId
    if exists := isExists(imagePath);!exists {
        http.NotFound(w, r)
        return
    }
    w.Header().Set("Content-Type", "image")
    http.ServeFile(w, r, imagePath)
}
func listHandler(w http.ResponseWriter, r *http.Request) {
    fileInfoArr, err := ioutil.ReadDir("./uploads")
    check(err)
    locals := make(map[string]interface{})
    images := []string{}
    for _, fileInfo := range fileInfoArr {
        images = append(images, fileInfo.Name)
    }
    locals["images"] = images
    renderHtml(w, "list", locals)
}
func safeHandler(fn http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if e, ok := recover().(error); ok {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                // 或者输出自定义的50x错误页面
                // w.WriteHeader(http.StatusInternalServerError)
                // renderHtml(w, "error", e)
                // logging
                log.Println("WARN: panic in %v. - %v", fn, e)
                log.Println(string(debug.Stack()))
            }
        }()
        fn(w, r)
    }
}
func staticDirHandler(mux *http.ServeMux, prefix string, staticDir string, flags int)
{
    mux.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) {
        file := staticDir + r.URL.Path[len(prefix)-1:]
        if (flags & ListDir) == 0 {
            if exists := isExists(file); !exists {
                http.NotFound(w, r)
                return
            }
        }
        http.ServeFile(w, r, file)
    })
}
func main() {
    mux := http.NewServeMux()
    staticDirHandler(mux, "/assets/", "./public", 0)
    mux.HandleFunc("/", safeHandler(listHandler))
    mux.HandleFunc("/view", safeHandler(viewHandler))
    mux.HandleFunc("/upload", safeHandler(uploadHandler))
    err := http.ListenAndServe(":8080", mux)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

更多资源

Go 的第三方库很丰富,无论是对于关系型数据库驱动还是非关系型的键值存储系统的接入,都有着良好的支持,而且还有丰富的 Go语言 Web 开发框架以及用于 Web 开发的相关工具包。可以访问 http://godashboard.appspot.com/project,了解更多第三方库的详细信息。

猜你喜欢

转载自www.cnblogs.com/kershaw/p/12077190.html