websocket协议,php、node做一个服务器框架很多,很容易做一个websocket服务器,这里主要是从协议出发,来看看如何实现php、node一个客户端。其中websocket协议英文版完整版可见这里;中文版自行百度the websocket protocol也能找到。
协议的第一章介绍、第二章一致性要求,跳过可以自行查看。这里从第三章websocket Urls开始。
这章先介绍了两种方案,一直是ws,另一种是wss,也就是80端口、443端口,对应着http和https,协议认为443是安全的;然后这章又说了资源名也就是路径的组建规则,路径参数等。
先看php实现第三章的urls:
/**
* 解析websocket连接地址
* @param $wsUrl
* @throws BadUriException
*/
protected function parseUrl($wsUrl)
{
$UrlValue = parse_url($wsUrl);
if (!$UrlValue) {
throw new BadUrlException('不正确的ws Url格式', __LINE__);
}
if ($UrlValue['scheme'] != self::PROTOCOL_WS && $UrlValue['scheme'] != self::PROTOCOL_WSS) {
throw new BadUrlException('ws的Url必须是以ws://或wss://开头', __LINE__);
}
$this->scheme = $UrlValue['scheme'];
$this->host = $UrlValue['host'];
$this->port = isset($url_parts['port']) ? $url_parts['port'] : ($scheme === 'wss' ? 443 : 80);
$this->path = isset($url_parts['path']) ? $url_parts['path'] : '/';
$this->query = isset($url_parts['query']) ? $url_parts['query'] : '';
$this->fragment = isset($url_parts['fragment']) ? $url_parts['fragment'] : '';
$this->user = isset($url_parts['user']) ? $url_parts['user'] : '';
$this->pass = isset($url_parts['pass']) ? $url_parts['pass'] : '';
if ($UrlValue['query']) {
$this->path .= '?' . $UrlValue['query'];
}
if ($UrlValue['fragment']) {
$this->path .= '#' . $UrlValue['fragment'];
}
}
这个方法主要是通过php中的函数parse_url来先对websocket的url地址进行解析,来判断是否符合websocket的协议要求,并把相关的参数存放到类的变量中,已被后面连接发送等阶段使用。接下来看node对这部分的实现方法:
let parsedUrl;
if (address instanceof URL) {
parsedUrl = address;
websocket._url = address.href;
} else {
parsedUrl = new URL(address);
websocket._url = address;
}
const isUnixSocket = parsedUrl.protocol === 'ws+unix:';
if (!parsedUrl.host && (!isUnixSocket || !parsedUrl.pathname)) {
throw new Error(`Invalid URL: ${websocket.url}`);
}
const isSecure =
parsedUrl.protocol === 'wss:' || parsedUrl.protocol === 'https:';
const defaultPort = isSecure ? 443 : 80;
第四章连接握手。为了建立一个websocket连接,客户端需要连接并且发送一个定义的握手协议,客户端需要提供第三章的主机、端口、资源名、安全标记、协议、扩展列表或源字段。
先来看php代码:
/**
* Perform WebSocket 连接
*/
protected function connect(): void
{
// Open the socket. stream_socket_client: Open Internet or Unix domain socket connection
$this->socket = stream_socket_client(
($this->protocol == self::PROTOCOL_WSS ? 'ssl://' : 'tcp://') . $this->host . ':' . $this->port,
$errno,
$errstr,
$this->options['timeout'],
$this->flags,
$this->context
);
restore_error_handler();
if (!$this->isConnected()) {
$error = "Could not open socket to \"{$this->host}:{$this->port}\": {$errstr} ({$errno}) {$error}.";
throw new ConnectionException($error);
}
// Set timeout on the stream as well.
stream_set_timeout($this->socket, $this->options['timeout']);
}
// Once a connection to the server has been established (including a connection via a proxy or over a TLS-encrypted tunnel), the client MUST send an opening handshake to the server. The handshake consists of an HTTP Upgrade request, along with a list of required and optional header fields. 客户端发送一个握手的数据包给服务端,这个数据包由一个http升级请求构成,包含一系列必须和可选的header字段。握手的具体要求看文档即可。
/**
* Perform WebSocket 握手
*/
protected function handshake()
{
$this->secWebSocketKey = self::generateWsKey();//随机数The request MUST include a header field with the name |Sec-WebSocket-Key|. The value of this header field MUST be a nonce consisting of a randomly selected 16-byte value that has been base64-encoded . The nonce MUST be selected randomly for each connection.
$headers = [
'GET ' . $this->path . ' HTTP/1.1',
'Host: ' . $this->host . ':' . $this->port,
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Key: ' . $this->secWebSocketKey,
'Sec-WebSocket-Version: 13',
];
$htmlHeader = implode(self::HTTP_HEADER_SEPARATION_MARK, $headers) . self::HTTP_HEADER_END_MARK;
$this->writeToSock($htmlHeader);
$response = '';
$end = false;
do {
$str = fread($this->socket, 8192);
if (strlen($str) == 0) {
break;
}
$response .= $str;
$end = strpos($response, self::HTTP_HEADER_END_MARK);
} while ($end === false);
if ($end === false) {
throw new HandshakeException('握手失败:握手响应不是标准的http响应', __LINE__);
}
$resHeader = substr($response, 0, $end);
$headers = explode(self::HTTP_HEADER_SEPARATION_MARK, $resHeader);
if (strpos($headers[0], '101') === false) {
throw new HandshakeException('握手失败:服务器返回http状态码不是101', __LINE__);
}
for($i = 1; $i < count($headers); $i++) {
list($key, $val) = explode(':', $headers[$i]);
if (strtolower(trim($key)) == 'sec-websocket-accept') {
$accept = base64_encode(sha1($this->secWebSocketKey . self::UUID, true));
if (trim($val) != $accept) {
throw new HandshakeException('握手失败: sec-websocket-accept值校验失败', __LINE__);
}
$this->handshakePass = true;
break;
}
}
if (!$this->handshakePass) {
throw new HandshakeException('握手失败:缺少sec-websocket-accept http头', __LINE__);
}
}
接下来是node的:
const key = randomBytes(16).toString('base64');
const get = isSecure ? https.get : http.get;
let perMessageDeflate;
opts.createConnection = isSecure ? tlsConnect : netConnect;
opts.defaultPort = opts.defaultPort || defaultPort;
opts.port = parsedUrl.port || defaultPort;
opts.host = parsedUrl.hostname.startsWith('[')
? parsedUrl.hostname.slice(1, -1)
: parsedUrl.hostname;
opts.headers = {
'Sec-WebSocket-Version': opts.protocolVersion,
'Sec-WebSocket-Key': key,
Connection: 'Upgrade',
Upgrade: 'websocket',
...opts.headers
};
opts.path = parsedUrl.pathname + parsedUrl.search;
opts.timeout = opts.handshakeTimeout;
if (opts.perMessageDeflate) {
perMessageDeflate = new PerMessageDeflate(
opts.perMessageDeflate !== true ? opts.perMessageDeflate : {},
false,
opts.maxPayload
);
opts.headers['Sec-WebSocket-Extensions'] = format({
[PerMessageDeflate.extensionName]: perMessageDeflate.offer()
});
}
if (protocols) {
opts.headers['Sec-WebSocket-Protocol'] = protocols;
}
if (opts.origin) {
if (opts.protocolVersion < 13) {
opts.headers['Sec-WebSocket-Origin'] = opts.origin;
} else {
opts.headers.Origin = opts.origin;
}
}
if (parsedUrl.username || parsedUrl.password) {
opts.auth = `${parsedUrl.username}:${parsedUrl.password}`;
}
if (isUnixSocket) {
const parts = opts.path.split(':');
opts.socketPath = parts[0];
opts.path = parts[1];
}
let req = (websocket._req = get(opts)); //http或https.get,携带参数发出get请求。
第五章,主要是讲解数据帧协议。在websocket中,数据是通过一系列的数据帧来进行传输的,为了安全,客户端发送给服务器的所有帧必须添加掩码(Mask)。服务器发送的数据帧不允许添加掩码。数据帧是在握手之后和终端发送关闭帧之间的时间传输的。具体的帧格式看协议内容。
php代码如下:
/**
* @param int $opCode 帧类型
* @param string $playLoad 携带的数据
* @param bool $isMask 是否使用掩码
* @param int $status 关闭帧状态
* @return false|string
*/
protected function packFrame($opCode, $playLoad = '', $isMask = true, $status = 1000)
{
$firstByte = 0x80 | $opCode;
if ($isMask) {
$secondByte = 0x80;
} else {
$secondByte = 0x00;
}
$playLoadLen = strlen($playLoad);
if ($opCode == self::OPCODE_CLOSE) {
// 协议规定关闭帧必须使用掩码
$isMask = true;
$playLoad = pack('CC', (($status >> 8) & 0xff), $status & 0xff) . $playLoad;
$playLoadLen += 2;
}
if ($playLoadLen <= self::FRAME_LENGTH_LEVEL_1_MAX) {
$secondByte |= $playLoadLen;
$frame = pack('CC', $firstByte, $secondByte);
} elseif ($playLoadLen <= self::FRAME_LENGTH_LEVEL_2_MAX) {
$secondByte |= 126;
$frame = pack('CCn', $firstByte, $secondByte, $playLoadLen);
} else {
$secondByte |= 127;
$frame = pack('CCJ', $firstByte, $secondByte, $playLoadLen);
}
if ($isMask) {
$maskBytes = [mt_rand(1, 255), mt_rand(1, 255), mt_rand(1, 255), mt_rand(1, 255)];
$frame .= pack('CCCC', $maskBytes[0], $maskBytes[1], $maskBytes[2], $maskBytes[3]);
if ($playLoadLen > 0) {
for ($i = 0; $i < $playLoadLen; $i++) {
$playLoad[$i] = chr(ord($playLoad[$i]) ^ $maskBytes[$i % 4]);
}
}
}
$frame .= $playLoad;
return $frame;
}
node的代码如下:
/**
* Frames a piece of data according to the HyBi WebSocket protocol.
*
* @param {Buffer} data The data to frame
* @param {Object} options Options object
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @return {Buffer[]} The framed data as a list of `Buffer` instances
* @public
*/
static frame(data, options) {
const merge = options.mask && options.readOnly;
let offset = options.mask ? 6 : 2;
let payloadLength = data.length;
if (data.length >= 65536) {
offset += 8;
payloadLength = 127;
} else if (data.length > 125) {
offset += 2;
payloadLength = 126;
}
const target = Buffer.allocUnsafe(merge ? data.length + offset : offset);
target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
if (options.rsv1) target[0] |= 0x40;
target[1] = payloadLength;
if (payloadLength === 126) {
target.writeUInt16BE(data.length, 2);
} else if (payloadLength === 127) {
target.writeUInt32BE(0, 2);
target.writeUInt32BE(data.length, 6);
}
if (!options.mask) return [target, data];
randomFillSync(mask, 0, 4);
target[1] |= 0x80;
target[offset - 4] = mask[0];
target[offset - 3] = mask[1];
target[offset - 2] = mask[2];
target[offset - 1] = mask[3];
if (merge) {
applyMask(data, mask, target, offset, data.length);
return [target];
}
applyMask(data, mask, data, 0, data.length);
return [target, data];
}
先看到这里,改天再看。