Go Web 服务四大核心开发方式实战:Socket、WebSocket、REST 与 RPC 完全指南

  • 文档
  • 教程

【免费下载链接】build-web-application-with-golang

A golang ebook intro how to build a web with golang

项目地址: https://gitcode.com/gh_mirrors/bu/build-web-application-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 WebSocketHTML5 WebSocket全双工通信,服务器主动 push,替代 ajax 轮询
8.3 RESTRESTful 架构以资源为中心、无状态的 API 设计风格
8.4 RPCGo 原生 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)
	}
}

注意两点设计细节:

  1. 服务启动后持续等待新客户端请求,Accept 成功后反馈当前时间;
  2. 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)
	}
}

三个关键点:

  1. conn.SetReadDeadline() 设置超时,客户端在设定时间内无请求时连接自动关闭,for 循环随之跳出;
  2. request 创建时指定 128 字节的最大长度,防止 flood attack;
  3. 每次处理完请求后必须重建 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 握手被发送到服务器端,随后双方通过套接口以事件句柄异步收发数据。

WebSocket 握手与全双工通信过程示意图

图 8.2 WebSocket 原理图

2.2 WebSocket 握手原理

WebSocket 协议本身颇为简单:第一次 handshake 通过后连接即建立成功,其后的通讯数据都以 \x00 开头、以 \xFF 结尾(在客户端是透明的,WebSocket 组件会自动「掐头去尾」)。

握手的核心流程如下:

  1. 浏览器发出 WebSocket 连接请求,服务器发出回应,连接建立成功——这个过程称为「握手」(handshaking);
  2. 请求中的 Sec-WebSocket-Key 是随机的,本质是经过 base64 编码的数据;
  3. 服务器端收到请求后,把该字符串连接上一个固定的字符串:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11

即形如 f7cb4ezEAl6C3wRaU6JORA== 的 Key 拼接上固定字符串,得到:

f7cb4ezEAl6C3wRaU6JORA==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
  1. 对该字符串先用 SHA-1 安全散列算法计算二进制值,再用 base64 编码,得到握手后的字符串:
rE91AJhfC+6JdVcVXOGJEADEJdQ=
  1. 将该值作为响应头 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 事件。客户端一共绑定四个事件:

  1. onopen:建立连接后触发;
  2. onmessage:收到消息后触发;
  3. onerror:发生错误时触发;
  4. 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 架构可以概括为三条:

  1. 每一个 URI 代表一种资源;
  2. 客户端和服务器之间传递这种资源的某种表现层;
  3. 客户端通过四个 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 工作流程大致有十步:

  1. 调用客户端句柄,执行传送参数;
  2. 调用本地系统内核发送网络消息;
  3. 消息传送到远程主机;
  4. 服务器句柄得到消息并取得参数;
  5. 执行远程过程;
  6. 执行的过程将结果返回服务器句柄;
  7. 服务器句柄返回结果,调用远程系统内核;
  8. 消息传回本地主机;
  9. 客户句柄由内核接收消息;
  10. 客户接收句柄返回的数据。

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, &quot)
	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, &quot)
	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, &quot)
	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。

本章小结与延伸阅读

本章介绍了目前流行的几种主要网络应用开发方式,逐一回顾:

  1. Socket 编程是网络编程的基础,也是技术演进的基石。在网络向云方向快速进化的今天,开发者必须掌握;
  2. WebSocket 是日益流行的 HTML5 重要特性,服务器可以主动 push 消息,简化了传统 ajax 轮询的模式;
  3. REST 编写模式特别适合开发网络应用 API,随着移动应用快速发展而成为潮流;
  4. RPC 是 Go 实现的远程过程调用,支持 TCP、HTTP、JSON 三个级别。

对于这四种开发方式,Go 都已提供良好支持——net 包及其子包是所有网络编程工具的所在地。想深入了解实现细节,可以尝试阅读 net 包下的源码。

继续学习路径:

  • 文档
  • 教程

【免费下载链接】build-web-application-with-golang

A golang ebook intro how to build a web with golang

项目地址: https://gitcode.com/gh_mirrors/bu/build-web-application-with-golang
点击查看 免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值