【免费下载链接】build-web-application-with-golang
A golang ebook intro how to build a web with golang
本文是《Go Web 编程》第 8 章「Web 服务」的深度技术总结。该章系统讲解了 Go 语言开发网络应用的四种主流方式:作为一切网络编程基石的 Socket 编程、实现服务器主动推送的 HTML5 WebSocket、适合移动应用 API 开发的 REST 架构风格,以及 Go 标准库原生支持、性能出色的 RPC 远程过程调用。读完本文,你将掌握这四种方式的核心原理、完整可运行的 Go 代码示例及其适用场景,并知道如何深入 net 包源码理解底层实现。
第 8 章总览:Go 语言网络应用的四种开发方式
Web 服务让我们可以在 HTTP 协议的基础上,通过 XML 或 JSON 交换信息,实现平台无关的分布式交互:一个运行在 Linux 上的服务,可以与 Windows 上的 ASP.NET 程序、FreeBSD 上的 JSP 应用通过同一接口无障碍通信。
在这一章中,作者围绕「性能、简单」两个 Go 语言的核心理念,安排了四条主线:
| 小节 | 技术主题 | 核心定位 |
|---|---|---|
| 8.1 Socket 编程 | TCP/UDP Socket | 底层网络编程基石,游戏服务端等高性能场景的主力 |
| 8.2 WebSocket | HTML5 WebSocket | 全双工通信,服务器主动 push,替代 ajax 轮询 |
| 8.3 REST | RESTful 架构 | 以资源为中心、无状态的 API 设计风格 |
| 8.4 RPC | Go 原生 RPC | 函数调用模式的网络化,支持 TCP/HTTP/JSON 三种载体 |
如小节总结所言:「net 包及其子包,是所有涉及到网络编程的工具的所在地」。想要深入了解实现细节,直接阅读 net 包源码是最佳路径。下文依次展开每种方式的原理与实战。
一、Socket 编程:一切网络编程的基石
1.1 什么是 Socket
Socket 起源于 Unix,而 Unix 的基本哲学之一是「一切皆文件」——所有操作都可以用「打开 open → 读写 write/read → 关闭 close」的模式完成。Socket 正是这一模式在网络数据传输上的实现:它本身是一种特殊的 I/O,也是一种文件描述符。调用 Socket() 函数会返回一个整型的 Socket 描述符,随后的连接建立、数据传输都通过它完成。
Socket 有两种常用类型:
- 流式 Socket(SOCK_STREAM):面向连接的 Socket,对应面向连接的 TCP 服务;
- 数据报式 Socket(SOCK_DGRAM):无连接的 Socket,对应无连接的 UDP 服务。
1.2 Socket 如何通信:IP + 协议 + 端口 三元组
网络中进程间通信首先要解决「如何唯一标识一个进程」的问题。本地可以用 PID 标识进程,但在网络中行不通。TCP/IP 协议族已经给出了答案:
- 网络层的 IP 地址唯一标识网络中的主机;
- **传输层的「协议 + 端口」**唯一标识主机中的应用程序(进程)。
于是,利用三元组 (IP 地址,协议,端口) 即可唯一标识网络中的进程,需要通信的进程就可以借此建立交互。
1.3 IP 地址基础:IPv4 与 IPv6
- IPv4:32 位地址长度,理论上最多可接入 2 的 32 次方台设备。随着互联网蓬勃发展,IPv4 地址发放日趋紧张,格式形如
127.0.0.1、172.122.121.111。 - IPv6:128 位地址长度,几乎可以不受限制地提供地址,同时解决了 IPv4 时代难以完善的端到端 IP 连接、服务质量(QoS)、安全性、多播、移动性、即插即用等问题,格式形如
2002:c0e8:82e7:0:0:0:c0e8:82e7。
Go 的 net 包中 IP 类型定义为 type IP []byte,其中最有用的函数之一是 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)
}
1.4 TCP Socket 编程
net 包中的 TCPConn 类型可作为客户端与服务器端交互的通道,其两个核心方法为:
func (c *TCPConn) Write(b []byte) (int, error)
func (c *TCPConn) Read(b []byte) (int, error)
TCPAddr 表示一个 TCP 地址信息:
type TCPAddr struct {
IP IP
Port int
Zone string // IPv6 scoped addressing zone
}
通过 ResolveTCPAddr 获取 TCPAddr:
func ResolveTCPAddr(net, addr string) (*TCPAddr, os.Error)
net参数取值"tcp4"、"tcp6"、"tcp",分别表示 TCP(仅 IPv4)、TCP(仅 IPv6)或 TCP(IPv4/IPv6 皆可);addr表示域名或 IP 地址,例如"www.google.com:80"或"127.0.0.1:22"。
注意:原文档中的签名
os.Error是 Go 1 之前的旧写法,现代 Go 中统一为error类型,函数语义不变。
TCP 客户端
通过 DialTCP 建立连接并返回 TCPConn 对象:
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)
network参数同ResolveTCPAddr;laddr表示本机地址,一般设置为nil;raddr表示远程服务地址。
下面的客户端代码模拟基于 HTTP 协议的请求,向 Web 服务端发送 "HEAD / HTTP/1.0\r\n\r\n" 请求头并读取响应:
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)
result := make([]byte, 256)
_, err = conn.Read(result)
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 发送请求 → 读取响应。
TCP 服务器端
服务端需要把服务绑定到指定端口并监听,net 包提供了:
func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error)
func (l *TCPListener) Accept() (Conn, error)
先看一个简单的时间同步服务,监听 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)) // don't care about return value
conn.Close() // we're finished with this client
}
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
注意两点设计细节:
- 服务启动后持续等待新客户端请求,
Accept成功后反馈当前时间; for循环中出错时用continue而不是退出——服务器端应记录错误后让当前连接报错退出,而不影响整个服务继续运行。
用 goroutine 实现多并发
上面的版本是单任务的,无法同时接收多个请求。Go 的 goroutine 机制可轻松改造:把业务处理分离到 handleClient 函数,仅加一个 go 关键字即实现并发:
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)) // don't care about return value
// we're finished with this client
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
处理长连接与请求内容
如果服务端需要根据客户端发送的不同请求返回不同时间格式,并保持长连接,可这样实现:
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)) // set 2 minutes timeout
request := make([]byte, 128) // set maxium request length to 128B to prevent flood attack
defer conn.Close() // close connection before exit
for {
read_len, err := conn.Read(request)
if err != nil {
fmt.Println(err)
break
}
if read_len == 0 {
break // connection already closed by client
} 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) // clear last read content
}
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
三个关键点:
conn.SetReadDeadline()设置超时,客户端在设定时间内无请求时连接自动关闭,for循环随之跳出;request创建时指定 128 字节的最大长度,防止 flood attack;- 每次处理完请求后必须重建
request缓冲区,因为conn.Read()会把新内容 append 到原内容之后。
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
设置 keepAlive 属性。操作系统在网络层没有数据和 ACK 时,会间隔性地发送 keepalive 包来判断 TCP 连接是否断开(Windows 默认 2 小时未收到数据和 keepalive 包即判定断开),其作用与应用层的心跳包类似。
1.5 UDP Socket 编程
UDP 与 TCP 的编程差异主要体现在服务器端:UDP 缺少 Accept 函数,没有「接受客户端连接」这一步,其余 API 只是把 TCP 换成 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 客户端相比仅将 DialTCP 换成 DialUDP、ResolveTCPAddr 换成 ResolveUDPAddr):
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 %s", err.Error())
os.Exit(1)
}
}
UDP 服务器端示例,通过 ReadFromUDP / WriteToUDP 收发数据报:
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 %s", err.Error())
os.Exit(1)
}
}
从上述 TCP 与 UDP 的实现可以看出,Go 已完备地支持 Socket 编程,net 包提供的函数足以轻松编写高性能的 Socket 应用。完整讲解见 8.1 Socket 编程。
二、WebSocket:浏览器与服务器的全双工通信
2.1 为什么需要 WebSocket
WebSocket 是 HTML5 的重要特性,实现了基于浏览器的远程 Socket,让浏览器与服务器可以进行全双工通信,Firefox、Google Chrome、Safari 等浏览器早已支持。
在 WebSocket 出现之前,实现即时通信靠的是「轮询」:浏览器在特定时间间隔内不断向服务器发 HTTP Request,服务器返回最新数据供浏览器刷新。轮询占用大量带宽,效率低下。WebSocket 通过特殊报头让浏览器与服务器只需一次握手即可建立连接通道,且连接持续保持活动状态,可以用 JavaScript 像操作常规 TCP Socket 一样读写数据。
相比传统 HTTP,WebSocket 的核心优势:
- 一个 Web 客户端只建立一个 TCP 连接;
- WebSocket 服务端可以推送(push)数据到 Web 客户端;
- 有更轻量级的头,减少数据传送量。
WebSocket URL 以 ws:// 或 wss://(SSL 上)起始。下图展示了其通信过程:一个带有特定报头的 HTTP 握手被发送到服务器端,随后双方通过套接口以事件句柄异步收发数据。
图 8.2 WebSocket 原理图
2.2 WebSocket 握手原理
WebSocket 协议本身颇为简单:第一次 handshake 通过后连接即建立成功,其后的通讯数据都以 \x00 开头、以 \xFF 结尾(在客户端是透明的,WebSocket 组件会自动「掐头去尾」)。
握手的核心流程如下:
- 浏览器发出 WebSocket 连接请求,服务器发出回应,连接建立成功——这个过程称为「握手」(handshaking);
- 请求中的
Sec-WebSocket-Key是随机的,本质是经过 base64 编码的数据; - 服务器端收到请求后,把该字符串连接上一个固定的字符串:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11
即形如 f7cb4ezEAl6C3wRaU6JORA== 的 Key 拼接上固定字符串,得到:
f7cb4ezEAl6C3wRaU6JORA==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
- 对该字符串先用 SHA-1 安全散列算法计算二进制值,再用 base64 编码,得到握手后的字符串:
rE91AJhfC+6JdVcVXOGJEADEJdQ=
- 将该值作为响应头
Sec-WebSocket-Accept的值反馈给客户端,握手完成。
2.3 Go 实现 WebSocket
Go 语言标准包中没有提供 WebSocket 支持,但官方维护的 go.net 子包中有此支持,获取方式:
go get golang.org/x/net/websocket
(说明:该包为历史官方实验包,当前 Go 生态更常用 github.com/gorilla/websocket 等社区实现;本章示例以 golang.org/x/net/websocket 演示核心原理。)
下面实现一个回显示例:用户输入信息,客户端通过 WebSocket 发送给服务器端,服务器收到后主动 push 消息回客户端。客户端 HTML/JavaScript 代码如下:
<html>
<head></head>
<body>
<script type="text/javascript">
var sock = null;
var wsuri = "ws://127.0.0.1:1234";
window.onload = function() {
console.log("onload");
sock = new WebSocket(wsuri);
sock.onopen = function() {
console.log("connected to " + wsuri);
}
sock.onclose = function(e) {
console.log("connection closed (" + e.code + ")");
}
sock.onmessage = function(e) {
console.log("message received: " + e.data);
}
};
function send() {
var msg = document.getElementById('message').value;
sock.send(msg);
};
</script>
<h1>WebSocket Echo Test</h1>
<form>
<p>
Message: <input id="message" type="text" value="Hello, world!">
</p>
</form>
<button onclick="send();">Send Message</button>
</body>
</html>
客户端通过 new WebSocket(wsuri) 建立连接 sock,握手成功后触发 onopen 事件。客户端一共绑定四个事件:
- onopen:建立连接后触发;
- onmessage:收到消息后触发;
- onerror:发生错误时触发;
- onclose:关闭连接时触发。
服务器端实现如下:
package main
import (
"golang.org/x/net/websocket"
"fmt"
"log"
"net/http"
)
func Echo(ws *websocket.Conn) {
var err error
for {
var reply string
if err = websocket.Message.Receive(ws, &reply); err != nil {
fmt.Println("Can't receive")
break
}
fmt.Println("Received back from client: " + reply)
msg := "Received: " + reply
fmt.Println("Sending to client: " + msg)
if err = websocket.Message.Send(ws, msg); err != nil {
fmt.Println("Can't send")
break
}
}
}
func main() {
http.Handle("/", websocket.Handler(Echo))
if err := http.ListenAndServe(":1234", nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
}
流程为:客户端 Send 用户输入 → 服务器端通过 websocket.Message.Receive 接收 → 构造应答消息 → 通过 websocket.Message.Send 发送回客户端。服务端通过 websocket.Handler(Echo) 将处理器注册到 HTTP 路由,监听 1234 端口。
从 Go 的 net 分支源码中已经实现了该协议,可直接使用。随着 HTML5 的发展,WebSocket 已成为 Web 开发的重要方向。完整讲解见 8.2 WebSocket。
三、REST:以资源为中心的 API 架构风格
3.1 什么是 REST
REST(REpresentational State Transfer,表现层状态转化)概念首次出现在 2000 年 Roy Thomas Fielding(HTTP 规范的主要编写者之一)的博士论文中,指的是一组架构约束条件和原则。满足这些约束和原则的应用程序或设计即称为 RESTful。
理解 REST 需要先理解三个概念:
- 资源(Resources):就是我们上网访问的一张图片、一个文档、一个视频等实体信息,通过 URI 来定位——一个 URI 表示一个资源。
- 表现层(Representation):资源是具体实体信息,可以有多种展现方式。把实体展现出来就是表现层,例如一个 txt 文本可输出为 html、json、xml 等格式,一张图片可以 jpg、png 等方式展现。URI 确定资源,而具体表现形式由 HTTP 请求头中的
Accept和Content-Type字段指定。 - 状态转化(State Transfer):HTTP 协议是无状态的,状态保存在服务器端。客户端要通知服务器改变数据与状态,只能通过 HTTP 协议中四个表示操作方式的动词:
GET(获取资源)、POST(新建资源,也可用于更新)、PUT(更新资源)、DELETE(删除资源)。
综合起来,RESTful 架构可以概括为三条:
- 每一个 URI 代表一种资源;
- 客户端和服务器之间传递这种资源的某种表现层;
- 客户端通过四个 HTTP 动词对服务器端资源进行操作,实现「表现层状态转化」。
3.2 RESTful 的两大核心原则
原则一:无状态交互。 客户端与服务器之间的交互在请求之间是无状态的,即每个请求都必须包含理解该请求所必需的信息。服务器在请求之间的任何时间点重启,客户端不会得到通知;请求可以由任何可用服务器回答,因此非常适合云计算等环境。也因为无状态,客户端可以缓存数据以改进性能。
原则二:系统分层。 组件无法了解除与它直接交互的层次以外的组件。通过把系统知识限制在单个层,可限制整个系统的复杂性,促进底层的独立性。
当 REST 架构约束条件作为一个整体应用时,可以生成能扩展到大量客户端的应用程序,降低交互延迟;统一界面简化了整个系统架构,改进了子系统之间交互的可见性,简化了客户端和服务器端的实现,并让 REST 应用更容易扩展。
3.3 Go 实现 RESTful
Go 没有为 REST 提供直接支持,但 RESTful 基于 HTTP 协议实现,因此可以利用 net/http 包自行实现——本质是根据不同 method 对相应资源做不同处理。原文将当前实现的 REST 应用按 method 覆盖程度分为几个 level(见 8.3 REST 中「REST 的 level 分级」图),并非所有应用都需要完整实现全部 RESTful 规则。
实际部署中常遇到两个限制:
- HTML 标准只能通过链接和表单支持
GET和POST,在没有 Ajax 支持的浏览器中无法发出PUT或DELETE; - 有些防火墙会拦截 HTTP
PUT和DELETE请求。
绕过办法是在 POST 中增加隐藏字段 _method 来模拟 PUT、DELETE 等方式,由服务器端负责转换还原。
在 Go 中完全按 RESTful 实现并不难,下面的例子使用第三方库 github.com/julienschmidt/httprouter(该库实现了自定义路由和方便的路由规则映射),为同一类资源(用户)的不同 method 注册不同处理函数:
package main
import (
"fmt"
"log"
"net/http"
"github.com/julienschmidt/httprouter"
)
func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
fmt.Fprint(w, "Welcome!\n")
}
func Hello(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
fmt.Fprintf(w, "hello, %s!\n", ps.ByName("name"))
}
func getuser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
uid := ps.ByName("uid")
fmt.Fprintf(w, "you are get user %s", uid)
}
func modifyuser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
uid := ps.ByName("uid")
fmt.Fprintf(w, "you are modify user %s", uid)
}
func deleteuser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
uid := ps.ByName("uid")
fmt.Fprintf(w, "you are delete user %s", uid)
}
func adduser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// uid := r.FormValue("uid")
uid := ps.ByName("uid")
fmt.Fprintf(w, "you are add user %s", uid)
}
func main() {
router := httprouter.New()
router.GET("/", Index)
router.GET("/hello/:name", Hello)
router.GET("/user/:uid", getuser)
router.POST("/adduser/:uid", adduser)
router.DELETE("/deluser/:uid", deleteuser)
router.PUT("/moduser/:uid", modifyuser)
log.Fatal(http.ListenAndServe(":8080", router))
}
关键点:httprouter 的路径参数通过 :name、:uid 形式声明,处理函数中用 ps.ByName("uid") 取出路径参数;同一个 /user 资源通过 GET、POST、PUT、DELETE 映射到不同逻辑。由此可见 REST 就是根据不同的 method 访问同一个资源时实现不同的逻辑处理。
REST 汲取了 WWW 的成功经验——无状态、以资源为中心、充分利用 HTTP 与 URI 协议、提供统一的接口定义,因而成为流行的 Web 服务设计方法。Go 目前对 REST 的支持仍较简单,通过实现自定义路由规则即可为不同 method 配置不同 handle。完整讲解见 8.3 REST。
四、RPC:函数调用模式的网络化
4.1 RPC 是什么
前几节介绍的 Socket 与 HTTP 采用「信息交换」模式:客户端发送一条信息到服务端,服务器返回响应信息,双方约定交互信息的格式。而很多独立应用采用另一种模式——类似常规函数调用。RPC(Remote Procedure Call Protocol,远程过程调用协议)正是把函数调用模式网络化:客户端像调用本地函数一样发起调用,参数被打包后通过网络传递到服务端,服务端解包、执行、把结果反馈给客户端。
RPC 假定某些传输协议(如 TCP 或 UDP)存在以携带信息数据,在 OSI 模型中跨越了传输层和应用层,使开发包括网络分布式多程序在内的应用程序更加容易。
RPC 工作流程大致有十步:
- 调用客户端句柄,执行传送参数;
- 调用本地系统内核发送网络消息;
- 消息传送到远程主机;
- 服务器句柄得到消息并取得参数;
- 执行远程过程;
- 执行的过程将结果返回服务器句柄;
- 服务器句柄返回结果,调用远程系统内核;
- 消息传回本地主机;
- 客户句柄由内核接收消息;
- 客户接收句柄返回的数据。
图 8.8 RPC 工作流程图
4.2 Go RPC 的独特性与可导出条件
Go 标准包已提供 RPC 支持,且支持三个级别:TCP、HTTP、JSONRPC。Go 的 RPC 与传统的 RPC 系统不同:它只支持 Go 开发的服务器与客户端之间的交互,因为内部采用 Gob 编码。
能被远程访问的 Go RPC 函数必须满足以下条件,否则会被忽略:
- 函数必须是导出的(首字母大写);
- 必须有两个导出类型的参数;
- 第一个参数是接收的参数,第二个参数是返回给客户端的参数,且第二个参数必须是指针类型;
- 函数还要有一个返回值
error。
正确格式示例:
func (t *T) MethodName(argType T1, replyType *T2) error
其中 T、T1、T2 类型必须能被 encoding/gob 包编解码。Go RPC 可以利用 HTTP 和 TCP 传递数据,利用 HTTP 的好处是可以直接复用 net/http 中的一些函数。
4.3 HTTP RPC 实战
服务端(注册 Arith 服务到 HTTP 协议):
package main
import (
"errors"
"fmt"
"net/http"
"net/rpc"
)
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
}
func main() {
arith := new(Arith)
rpc.Register(arith)
rpc.HandleHTTP()
err := http.ListenAndServe(":1234", nil)
if err != nil {
fmt.Println(err.Error())
}
}
通过 rpc.Register(arith) 注册服务,rpc.HandleHTTP 把服务注册到 HTTP 协议上,随后即可用 HTTP 方式传递数据。
客户端(rpc.DialHTTP 连接,client.Call 调用):
package main
import (
"fmt"
"log"
"net/rpc"
"os"
)
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "server")
os.Exit(1)
}
serverAddress := os.Args[1]
client, err := rpc.DialHTTP("tcp", serverAddress+":1234")
if err != nil {
log.Fatal("dialing:", err)
}
// Synchronous call
args := Args{17, 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\n", args.A, args.B, reply)
var quot Quotient
err = client.Call("Arith.Divide", args, ")
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d/%d=%d remainder %d\n", args.A, args.B, quot.Quo, quot.Rem)
}
分别编译服务端与客户端,先启动服务端,再运行客户端:
$ ./http_c localhost
Arith: 17*8=136
Arith: 17/8=2 remainder 1
client.Call 是客户端最重要的函数,共 3 个参数:第 1 个是要调用的函数名,第 2 个是要传递的参数,第 3 个是返回参数(注意是指针类型)。参数与返回值可以是自定义 struct 类型——在服务端作为函数参数类型,在客户端作为 client.Call 的第 2、3 个参数类型。
4.4 TCP RPC 实战
TCP 版服务端与 HTTP 版的核心区别:采用 TCP 协议,需要自己控制连接,客户端连接上来后把连接交给 rpc.ServeConn 处理:
package main
import (
"errors"
"fmt"
"net"
"net/rpc"
"os"
)
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
}
func main() {
arith := new(Arith)
rpc.Register(arith)
tcpAddr, err := net.ResolveTCPAddr("tcp", ":1234")
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
rpc.ServeConn(conn)
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
这段代码是阻塞型单用户程序;要实现多并发,可以用 goroutine 处理(Socket 小节已介绍)。
TCP 版客户端与 HTTP 版几乎一致,唯一区别是把 DialHTTP 换成 Dial("tcp", ...):
package main
import (
"fmt"
"log"
"net/rpc"
"os"
)
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "server:port")
os.Exit(1)
}
service := os.Args[1]
client, err := rpc.Dial("tcp", service)
if err != nil {
log.Fatal("dialing:", err)
}
// Synchronous call
args := Args{17, 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\n", args.A, args.B, reply)
var quot Quotient
err = client.Call("Arith.Divide", args, ")
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d/%d=%d remainder %d\n", args.A, args.B, quot.Quo, quot.Rem)
}
4.5 JSON RPC 实战
JSON RPC 的数据编码采用 JSON 而非 Gob,其他概念与上面完全一致。服务端示例(基于 TCP,目前 JSON RPC 还不支持 HTTP 方式):
package main
import (
"errors"
"fmt"
"net"
"net/rpc"
"net/rpc/jsonrpc"
"os"
)
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
}
func main() {
arith := new(Arith)
rpc.Register(arith)
tcpAddr, err := net.ResolveTCPAddr("tcp", ":1234")
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
jsonrpc.ServeConn(conn)
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
与 TCP RPC 服务端的差别仅在 rpc.ServeConn(conn) 换成了 jsonrpc.ServeConn(conn)。
JSON RPC 客户端,把 rpc.Dial 换成 jsonrpc.Dial:
package main
import (
"fmt"
"log"
"net/rpc/jsonrpc"
"os"
)
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "server:port")
log.Fatal(1)
}
service := os.Args[1]
client, err := jsonrpc.Dial("tcp", service)
if err != nil {
log.Fatal("dialing:", err)
}
// Synchronous call
args := Args{17, 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\n", args.A, args.B, reply)
var quot Quotient
err = client.Call("Arith.Divide", args, ")
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d/%d=%d remainder %d\n", args.A, args.B, quot.Quo, quot.Rem)
}
结论:Go 已对 RPC 提供良好支持,通过 HTTP、TCP、JSON 三种实现可以方便地开发分布式 Web 应用。目前 Go 尚未提供对 SOAP RPC 的支持,但有第三方开源实现可用。完整讲解见 8.4 RPC。
本章小结与延伸阅读
本章介绍了目前流行的几种主要网络应用开发方式,逐一回顾:
- Socket 编程是网络编程的基础,也是技术演进的基石。在网络向云方向快速进化的今天,开发者必须掌握;
- WebSocket 是日益流行的 HTML5 重要特性,服务器可以主动 push 消息,简化了传统 ajax 轮询的模式;
- REST 编写模式特别适合开发网络应用 API,随着移动应用快速发展而成为潮流;
- RPC 是 Go 实现的远程过程调用,支持 TCP、HTTP、JSON 三个级别。
对于这四种开发方式,Go 都已提供良好支持——net 包及其子包是所有网络编程工具的所在地。想深入了解实现细节,可以尝试阅读 net 包下的源码。
继续学习路径:
- 第 8 章入口:Web 服务
- 前一章总结:第七章总结
- 下一章主题:安全与加密
- 全书目录:《Go Web 编程》目录
【免费下载链接】build-web-application-with-golang
A golang ebook intro how to build a web with golang
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考





