Web程序
在需要实现实时通信的情况下,选择WebSocket
是很自然的事。那么,用什么工具搭建WebSocket
服务器最为合适呢?性能虽然很重要,但开发成本也不能忽略。这次将比较各种语言通过各自常见的手法搭建WebSocket
服务器的实际操作进行比较。
1.通信格式
服务器执行的任务很简单,只负责处理echo
和broadcast
两种信息即可。消息将使用JSON
进行编码,格式正如下面这条广播信息。
{"type":"broadcast","payload":{"foo": "bar"}}
2.主角介绍
首先,我们先看看各语言比较有特色的地方。
2.1.Clojure(代码核心部分50行)
Clojure
服务器使用HTTP Kit搭建,其中值得注意的是server.clj这个文件。
每当和一个客户端完成连接,就把对应的channel
存放到channels
的atom
中。这提高了并行处理的安全性,对Clojure
而言是整个服务的必要前提。
(defonce channels (atom #{}))
(defn connect! [channel]
(log/info "channel open")
(swap! channels conj channel))
(defn disconnect! [channel status]
(log/info "channel closed:" status)
(swap! channels disj channel))
广播处理十分的简单。
(defn broadcast [ch payload]
(doseq [channel @channels]
(send! channel (json/encode {:type "broadcast" :payload payload})))
(send! ch (json/encode {:type "broadcastResult" :payload payload}))) (send! ch (json/encode {:type "broadcastResult" :payload payload})))
2.2.C++(代码核心部分140行)
C++
的服务器使用了websocketpp,websocketpp
同时依赖于基础库boost,因此编程语法会显得比较冗长和复杂。
服务器程序的主类命名为server
,很明显要使用多线程,这在run
方法中实现。
void server::run(int threadCount) {
boost::thread_group tg;
for (int i = 0; i < threadCount; i++) {
tg.add_thread(new boost::thread(&websocketpp::server<websocketpp::config::asio>::run, &wspp_server));
}
tg.join_all();
}
程序运行时建立的连接保存在一个集合中,跟上面的Clojure
实现十分类似,但它的构造要复杂得多。
class server {
// ...
std::set<websocketpp::connection_hdl, std::owner_less<websocketpp::connection_hdl>> conns;
};
因为websocketpp::connection_hdl
实际上是一个std::shared_ptr
,智能指针的比较没有看起来的那么简单. 先看一下shared_ptr
/weak_ptr
的通常的内部实现:
它有两个指针, _Ptr
指向真正的对象, _Rep
指向所谓的control block
, 其中记录着shared_count
, weak_count
。 对于shared_ptr
来说, 有两种方式来比较大小:value-based
, owner-based
。也就是分别比较_Ptr
或者_Rep
的值.。而weak_ptr
则不能简单的比较_Ptr
, 因为weak_ptr
随时可能expire
, _Ptr
的值从而改变(清零)。 所以就只能比较_Rep
, 也就是control block
的地址.。一般的, 为了和weak_ptr
的实现一致,shared_ptr
也采用owner-based
的机制。
void server::on_open(websocketpp::connection_hdl hdl) {
boost::lock_guard<boost::shared_mutex> lock(conns_mutex);
conns.insert(hdl);
}
void server::on_close(websocketpp::connection_hdl hdl) {
boost::lock_guard<boost::shared_mutex> lock(conns_mutex);
conns.erase(hdl);
}
为了保证线程安全,在操作该集合前,都要生成一个boost::lock_guard
对象,用此对conns_mutex
进行上锁。当函数返回时,lock_guard
这个栈对象被自动销毁,自动解锁。
广播函数的核心部分则相对简单,但也略显冗长。
void server::broadcast(websocketpp::connection_hdl src_hdl, const Json::Value &src_msg) {
Json::Value dst_msg;
dst_msg["type"] = "broadcast";
dst_msg["payload"] = src_msg["payload"];
auto dst_msg_str = json_to_string(dst_msg);
boost::shared_lock_guard<boost::shared_mutex> lock(conns_mutex);
for (auto hdl : conns) {
wspp_server.send(hdl, dst_msg_str, websocketpp::frame::opcode::text);
}
Json::Value result_msg;
result_msg["type"] = "broadcastResult";
result_msg["payload"] = src_msg["payload"];
result_msg["listenCount"] = int(conns.size());
wspp_server.send(src_hdl, json_to_string(result_msg), websocketpp::frame::opcode::text);
}
2.3.Elixir(代码核心部分20行)
Elixir
服务器使用的是Phoenix框架,相关的代码在room_channel.ex。Phoenix
对channel
的封装更为彻底,无需手动维护一个集合,再加上传输默认使用JSON
,编码就变得更加简单了。
defmodule PhoenixSocket.RoomChannel do
use Phoenix.Channel
def join("room:lobby", _message, socket) do
{:ok, socket}
end
def handle_in("echo", message, socket) do
resp = %{body: message["body"], type: "echo"}
{:reply, {:ok, resp}, socket}
end
def handle_in("broadcast", message, socket) do
bcast = %{body: message["body"], type: "broadcast"}
broadcast! socket, "broadcast", bcast
resp = %{body: message["body"], type: "broadcastResult"}
{:reply, {:ok, resp}, socket}
end
end
上面使用pattern matching
取代大部分条件分支,它是Elixir
下写代码的一个很自然的模式:任务不断拆解,每个函数专注只干一件事,代码的简洁自不必说,其效率还有可能进一步优化。
2.4.Go(代码核心部分100行)
Go
服务器使用了net/http
和golang.org/x/net/websocket
库进行搭建,Websocket
的handler
在handler.go中定义。但他并没有对连接对象进行高层级的抽象,为了对连接对象进行管理需要自行创建一个map
,这点和C++
很相似。而且与C++
一样,为了保证并行处理的安全性,需要使用mutex
进行上锁操作。
func (h *benchHandler) Accept(ws *websocket.Conn) {
// ...
h.mutex.Lock()
h.conns[ws] = struct{}{}
h.mutex.Unlock()
// ...
}
广播的方法也不难理解,也跟C++
很像。
func (h *benchHandler) broadcast(ws *websocket.Conn, payload interface{}) error {
result := BroadcastResult{Type: "broadcastResult", Payload: payload}
h.mutex.RLock()
for c, _ := range h.conns {
if err := websocket.JSON.Send(c, &WsMsg{Type: "broadcast", Payload: payload}); err == nil {
result.ListenerCount += 1
}
}
h.mutex.RUnlock()
return websocket.JSON.Send(ws, &result)
}
算上导入外部库文件,异常处理,不使用无类型map
而是用强类型检查map
的代码,整个程序的代码量也只是100行
而已。
2.5.Javascript/NodeJS(代码核心部分30行)
通过Javascript
代码编写的服务器,使用了NodeJS和websockets/ws,所有代码都在index.js中。
websockets/ws
服务器会自动跟踪并管理与客户端之间的连接,下面的代码很简单,无需过多的说明。
var WebSocketServer = require('ws').Server;
var wss = new WebSocketServer({ port: 3334 });
function echo(ws, payload) {
ws.send(JSON.stringify({type: "echo", payload: payload}));
}
function broadcast(ws, payload) {
var msg = JSON.stringify({type: "broadcast", payload: payload});
wss.clients.forEach(function each(client) {
client.send(msg);
});
ws.send(JSON.stringify({type: "broadcastResult", payload: payload}));
}
wss.on('connection', function connection(ws) {
ws.on('message', function incoming(message) {
var msg = JSON.parse(message);
switch (msg.type) {
case "echo":
echo(ws, msg.payload);
break;
case "broadcast":
broadcast(ws, msg.payload);
break;
default:
console.log("unknown message type: %s", message);
}
});
});
2.6.Ruby/Rails(代码核心部分20行)
Rails 5引入了ActionCable
,这个库对于websocket
的抽象化跟Phoenix
很相似,能够自动完成与客户端的连接的管理、JSON
的解析和序列化工。
class BenchmarkChannel < ApplicationCable::Channel
def subscribed
Rails.logger.info "a client subscribed: #{id}"
stream_from id
stream_from "all"
end
def echo(data)
ActionCable.server.broadcast id, data
end
def broadcast(data)
ActionCable.server.broadcast "all", data
data["action"] = "broadcastResult"
ActionCable.server.broadcast id, data
end
end
3.比较基准
为了测试这些websocket
服务器的性能表现,选择搭建websocket-bench
作为测量工具。这款工具能够自动实现所设置的某个压力环境,然后判断服务器的实际表现性能。比如设置条件为在250毫秒
内至少把95%
的广播发送完成,然后施加10个
广播任务给服务器,然后观察服务器的实际运行结果。
以下是一个具体的实践例子
$ bin/websocket-bench broadcast ws://earth.local:3334/ws --concurrent 10 --sample-size 100 --step-size 1000 --limit-percentile 95 --limit-rtt 250ms
clients: 1000 95per-rtt: 47ms min-rtt: 9ms median-rtt: 20ms max-rtt: 66ms
clients: 2000 95per-rtt: 87ms min-rtt: 9ms median-rtt: 43ms max-rtt: 105ms
clients: 3000 95per-rtt: 121ms min-rtt: 21ms median-rtt: 58ms max-rtt: 201ms
clients: 4000 95per-rtt: 163ms min-rtt: 30ms median-rtt: 76ms max-rtt: 325ms
clients: 5000 95per-rtt: 184ms min-rtt: 37ms median-rtt: 95ms max-rtt: 298ms
上面的测试基准是,首先给服务器添加1000个
客户端连接,然后产生10个
线程分别发送100个
广播请求。接着每次增加1000个
客户端连接,直到超过5%
的请求处理往返时间超过250毫秒
为止。上面的例子,直到客户端连接数量到达5000
依然能满足条件。
下面的测试结果是在一台机器作为服务器,其他机器运行压测基准工具,所有机器都是Bare Metal
的i7 4790K 4GHz 16GB RAM
,操作系统是Ubuntu 16.04
,他们通过吉兆比特网络进行连接。测试条件是4个
并行请求线程,直到超过5%
的请求处理往返时间超过500毫秒
。对于每种语言都测试多次,最后取最优结果作为最终比较的结果。
下面是内存的使用量
显而易见的是,C++
表现性能是最优的,但是C++
服务器的代码编写最为复杂和冗长。其原因在于,它集低层级的内存管理、指针、内联汇编、多重继承、模板、lambda
、异常处理于一身。而且开发者还要面对复杂的编译器选项、makefile
文件的编写、长时间的编译过程、复杂难懂的错误信息。
同时,Clojure
作为一门抽象层次更高的编程语言,依然拥有不错的表现性能,本身又运行于JVM
,有丰富的类库作支持。同时它依存于JVM
,整个程序打包成一个jar文件
,部署的难度相当小。但它的语法属于Lisp
系,对于这个语言体系不熟悉的开发者来说,其使用难度可能要比C++
还要难上不少。
Elixir
表现效果排名第3,它运行在函数型编程先天高并行性的Erlang VM
平台,同时语法与Ruby
快上手的特点很相似。但目前知道这门语言的开发者还很少,作为一门新语言更新也比较频繁。
Go语言
和Elixir
的表现性能排名并列第三,但内存占用只是Clojure
和Elixir
的一半。代码编写虽然也挺长,但并不像C++
那么晦涩难懂,而是相当地明确。同时Go
能编译成静态二进制程序,部署起来也不是问题。
NodeJS
的性能显然不能跟前面的语言相提并论,这主要是受到它单线程的架构设置的局限。但如果跟其他单线程的架构相比,它已经算是佼佼者了。而且用它编写的程序代码量是最小的,JavaScrip
t对于Web
开发者来说是最熟悉不过的语言了。
对于这次的测试,Rails
显然是很不适应的,因为他只是一个对原有Rails
程序的补充,只适用于少数的客户端连接的场合,但这只是对于运行在 Ruby MRI
平台上的Rails websocket
而言。如果真有开发需求,那建议使用JRuby
,虽然比不上其他语言,但能保证性能是MRI
的数倍之上。