- URL: https://www.laruence.com/en/2020/04/01/5726.html
- Please include attribution when republishing.
Yar supports two kinds of Transporter: HTTP and TCP. The HTTP one is based on CURL; in PHP, Yar defaults to using the HTTP Transporter — this should be familiar to everyone. The TCP-based one is probably used less.
In fact, six years ago I also wrote a C Yar server framework called Yar-c, the code is at Yar-C at Github. It provides service startup, worker process management, the Yar packaging protocol, and so on. At the time we used this framework to implement high-performance services like the Weibo whitelist, for the PHP side to call via the Yar Client.
It's just that Yar C requires the Handle to be written in C, which might be a little unfamiliar to quite a few PHPers. So today we'll try writing a TCP Server in PHP, to introduce how to handle the Yar RPC protocol. This example can be conveniently combined with async PHP frameworks like Swoole to build a high-performance Yar TCP Server. Along the way, it'll give you a look at Yar's RPC communication protocol, and incidentally some socket programming.
Today we'll still use the "whitelist" service as the example. We provide an interface that accepts requests from RPC clients; the parameter is a user ID, and it returns a bool indicating whether the user is on the whitelist:
function query(int $id) : bool;
First, we create a file yar_server. To make it directly executable, we write the following at the top of the file:
#!/bin/env php7
<?php
class WhiteList {
}
Then, we use chmod a+x to add execute permission to this file.
The first step is to handle the service startup arguments. We accept a parameter S indicating the IP and port to listen on; the value's format is host:port. We use PHP's getopt function to handle the command-line arguments:
class WhiteList {
protected $host;
public function __construct() {
$options = getOpt("S:");
if (!isset($options["S"])) {
$this->usage();
}
}
protected function usage() {
exit("Usage: yar_server -S hostname:portn");
}
}
This way, when the user starts yar_server without specifying the S parameter, we exit and print the Usage. We also need another configuration: pointing to a word-list file, where each line in the word list is a user ID on the whitelist. We use F for this:
class WhiteList {
protected $host;
protected $dicts;
public function __construct() {
$options = getOpt("S:F:");
if (!isset($options["S"]) || !isset($options["F"])) {
$this->usage();
}
$this->host = $options["S"];
$this->dicts = $options["F"];
}
protected function usage() {
exit("Usage: yar_server -F path_to_dict -S hostname:portn");
}
}
OK, now the startup argument handling is done. Of course, for simplicity, I've omitted validity checks on the input parameters.
Next, we need to complete two functions. The first reads the word-list file specified by -F, loading all user IDs into an array. Since our service is long-running, we don't need to worry about performance; it only processes this word-list file during the startup phase:
protected function loadDict() {
$this->ids = array();
$fp = fopen($this->dicts, "r");
while (!feof($fp)) {
$line = trim(fgets($fp));
if ($line) {
$this->ids[$line] = true;
}
}
fclose($fp);
echo "Loading dict successfully, ", count($this->ids), " loadedn";
return $this;
}
Since the user IDs are integers, we use them as Hashtable keys, so that later lookups using isset will be very efficient. Note that since file handling isn't the focus of today's post, we've omitted checks for file existence, readability, and validity.
OK, here comes the key part. We need to start an IPv4 TCP Socket service, listening at the address specified by $host. To make the Socket API easy to understand, we don't use PHP's Stream family of functions, but instead use the Socket-family API that PHP wraps directly. First we use socket_create to create a Socket socket:
protected function listen() {
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if ($socket == false) {
throw new Exception("socket_create() failed: reason: " . socket_strerror(socket_last_error()));
}
}
Then, we need to use socket_bind to bind this Socket to the address we want to listen on, and use socket_listen to listen for requests:
protected function listen() {
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if ($socket == false) {
throw new Exception("socket_create() failed: reason: " . socket_strerror(socket_last_error()));
}
list($hostname, $port) = explode(":", $this->host);
if (socket_bind($socket, $hostname, $port) == false) {
throw new Exception("socket_bind() failed: reason: " . socket_strerror(socket_last_error()));
}
if (socket_listen($socket, 64) === false) {
throw new Exception("socket_listen() failed: reason: " . socket_strerror(socket_last_error()));
}
echo "Starting Yar_Server at {$this->host}nPresss Ctrl + C to quitn";
$this->socket = $socket;
return $this;
}
OK, if everything's fine, we can now use socket_accept to listen for requests. The default socket is in blocking mode; if there's no request, the process will keep blocking and waiting. For a high-performance service, it's best to adopt a non-blocking + select or epoll model to handle multiple requests simultaneously. But our example is mainly to introduce Yar's protocol, so we still use the simple blocking mode.
Next, we write the actual RPC-handling part. First we accept a request via accept, then read the request's content, parsing the Yar RPC Header info in the request header. The Yar RPC protocol header is defined as follows:
typedef struct _yar_header {
uint32_t id; // transaction id
uint16_t version; // protocl version
uint32_t magic_num; // default is: 0x80DFEC60
uint32_t reserved;
unsigned char provider[32]; // reqeust from who
unsigned char token[32]; // request token, used for authentication
uint32_t body_len; // request body len
}
Among these, magic_num is a special value used to verify the validity of a request; legal Yar RPC requests all set this value to 0x80DFEC60 (I'd really like to tell you why this value is this, but I genuinely don't remember why I used this number back then). This header is 82 bytes. Some folks might ask, wait, doesn't this Struct look like it shouldn't be 82? That's because when the header is declared it uses pack mode, i.e. no alignment, so it really is 82 bytes.
pack("H*", "80DFEC60");
provider is a string that indicates the client's name. For example, for the Yar extension's Yar_Client it's "Yar PHP Cient-x.x.x".
token was originally designed for API key verification, but ended up not being used, because most are intranet applications where there are many ways to guarantee the legitimacy of the request source.
id is a unique request id, this is for troubleshooting request issues. version defaults to 0 or 1; since I haven't upgraded the protocol header, we don't need to worry about it for now. reserved can be used to pass some request parameters, e.g. the client can indicate whether to keep the connection.
body_len is what we need to care about; this field indicates how large the request body of this request is in total (not including the Yar protocol header).
All of these numbers are transmitted in network byte order. We use PHP's unpack function for handling binary streams to parse the binary stream read in:
protected function parseHeader($header) {
return
unpack("Nid/nversion/Nmagic_num/Nreserved/A32provider/A32token/Nbody_len", $header);
}
This function returns an array corresponding to the header struct described above.
Correspondingly, we also need to use pack to implement the method for generating a Yar Header:
const YAR_MAGIC_NUM = 0x80DFEC60;
protected function genHeader($id, $len) {
$bin = pack("NnNNA32A32N", $id, 0, self::YAR_MAGIC_NUM, 0, "Yar PHP TCP Server", "", $len);
return $bin;
}
As mentioned earlier, before accepting a request we need to verify the request's validity:
protected function validRequest($header) {
if ($header["magic_num"] != self::YAR_MAGIC_NUM) {
return false;
}
return true;
}
So the overall logical framework for request handling is roughly:
protected function accept() {
while (($conn = socket_accept($this->socket))) {
$buf = socket_read($conn, self::HEADER_SIZE, PHP_BINARY_READ);
if ($buf === false) {
socket_shutdown($conn);
continue;
}
if (!$this->validHeader($header = $this->parseHeader($buf))) {
$output = $this->response(1, "illegal Yar RPC request");
goto response;
}
$buf = socket_read($conn, $header["body_len"], PHP_BINARY_READ);
if ($buf === false) {
$output = $this->response(1, "insufficient request body");
goto response;
}
if (!$this->validPackager($buf)) {
$output = $this->response(1, "unsupported packager");
goto response;
}
$buf = substr($buf, 8); /* skip the 8 bytes of packager info */
$request = $this->parseRequest($buf);
if ($request == false) {
$this->response(1, "malformed request body");
goto response;
}
$status = $this->handle($request, $ret);
$output = $this->response($status, $ret);
response:
socket_write($conn, $output, strlen($output));
socket_shutdown($conn); /* close writing */
}
}
Now the overall framework is complete; we just need to finish the handle and response methods. handle calls the specified method based on the m in the user's request.
protected function handle($request, &$ret) {
if ($request["m"] == "query") {
$ret = $this->query(...$request["p"]);
} else {
$ret = "unsupported method '" . $request["m"]. "'";
return 1;
}
return 0;
}
Now let's implement the query method itself. This is simple — just check whether the id is in the whitelist array:
protected function query($id) {
return isset($this->ids[$id]);
}
OK, next we need to complete the response method. This method packs a return body that conforms to the Yar protocol, including the 82-byte header, 8 bytes of packager info, and the serialized response body. We need to choose whether to set the r or e field in the response body based on the status:
protected function response($status, $ret) {
$body = array();
$body["i"] = 0;
$body["s"] = $status;
if ($status == 0) {
$body["r"] = $ret;
} else {
$body["e"] = $ret;
}
$packed = serialize($body);
$header = $this->genHeader(0, strlen($packed) + 8);
return $header . str_pad("PHP", 8, "