分类目录归档:php

使用pack/unpack打包/解包二进制数据

web开发中,通常使用的API接口协议都是基于HTTP(S)的文本协议,比如JSON或XML。这对于跨编程语言的通信已经没有问题了,但有时候会采用二进制的数据来通信,以便获得更高的性能,比如Apache Thrift、gRPC/Protocol Buffer。各个编程语言都有对应的pack/unpack函数,PHP可以使用pack/unpack函数对数据进行二进制编码/解码,它与Perl的pack函数比较类似。
PHP的pack函数至少需要两个参数,第一个参数为打包格式,第二个参数开始为打包的数据。每个格式化字符后面可以跟字节长度,对应打包一个数据;也可以用*自动匹配所有后面的数据,具体的格式化字符可以参考pack函数页面。来看下面的例子pack.php

<?php
declare(strict_types=1);

$data = array(
    array(
        'title' => 'C Programming',
        'author' => 'Nuha Ali',
        'subject' => 'C Programming Tutorial',
        'book_id' => 6495407
    ),
    array(
        'title' => 'Telecom Billing',
        'author' => 'Zara Ali',
        'subject' => 'Telecom Billing Tutorial',
        'book_id' => 6495700
    ),
    array(
        'title' => 'PHP Programinng',
        'author' => 'Channing Huang',
        'subject' => 'PHP Programing Tutorial',
        'book_id' => 6495701
    )
);
$fp = \fopen("example","wb");
foreach ($data as $row) {
    $bin = \pack('Z50Z50Z100i', ...\array_values($row));
    \fwrite($fp, $bin);
}
\fclose($fp);

这里有个books数组,每个book包含4个数据:title、author、subject、bookid,对应的格式化是Z50、Z50、Z100、i。Z50意思是打包成50的字节的二进制数据,剩余部分使用NULL填充,这个跟使用a50是类似的,如果要用空格填充则格式化字符应该是A50。Z100类似Z50,不过字节长度为100。i的意思是打包成有符号的整型对应C里面的signed integer,占4个字节。所以每个book应该占50+50+100+4=204字节,3个book总占用612字节。查看一下

[vagrant@localhost bin]$ ls -la | grep example
-rw-r--r--. 1 vagrant vagrant   612 Aug 14 02:22 example

使用xxd查看它的二进制内容,确定是使用NULL填充不足部分

[vagrant@localhost bin]$ xxd -b example 
0000000: 01000011 00100000 01010000 01110010 01101111 01100111  C Prog
0000006: 01110010 01100001 01101101 01101101 01101001 01101110  rammin
000000c: 01100111 00000000 00000000 00000000 00000000 00000000  g.....
0000012: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000018: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000001e: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000024: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000002a: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000030: 00000000 00000000 01001110 01110101 01101000 01100001  ..Nuha
0000036: 00100000 01000001 01101100 01101001 00000000 00000000   Ali..
000003c: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000042: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000048: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000004e: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000054: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000005a: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000060: 00000000 00000000 00000000 00000000 01000011 00100000  ....C 
0000066: 01010000 01110010 01101111 01100111 01110010 01100001  Progra
000006c: 01101101 01101101 01101001 01101110 01100111 00100000  mming 
0000072: 01010100 01110101 01110100 01101111 01110010 01101001  Tutori
0000078: 01100001 01101100 00000000 00000000 00000000 00000000  al....
000007e: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000084: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000008a: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000090: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000096: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000009c: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000a2: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000a8: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000ae: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000b4: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000ba: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000c0: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000c6: 00000000 00000000 10101111 00011100 01100011 00000000  ....c.
00000cc: 01010100 01100101 01101100 01100101 01100011 01101111  Teleco
00000d2: 01101101 00100000 01000010 01101001 01101100 01101100  m Bill
00000d8: 01101001 01101110 01100111 00000000 00000000 00000000  ing...
00000de: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000e4: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000ea: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000f0: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000f6: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00000fc: 00000000 00000000 01011010 01100001 01110010 01100001  ..Zara
0000102: 00100000 01000001 01101100 01101001 00000000 00000000   Ali..
0000108: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000010e: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000114: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000011a: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000120: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000126: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000012c: 00000000 00000000 00000000 00000000 01010100 01100101  ....Te
0000132: 01101100 01100101 01100011 01101111 01101101 00100000  lecom 
0000138: 01000010 01101001 01101100 01101100 01101001 01101110  Billin
000013e: 01100111 00100000 01010100 01110101 01110100 01101111  g Tuto
0000144: 01110010 01101001 01100001 01101100 00000000 00000000  rial..
000014a: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000150: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000156: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000015c: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000162: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000168: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000016e: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000174: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000017a: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000180: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000186: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000018c: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000192: 00000000 00000000 11010100 00011101 01100011 00000000  ....c.
0000198: 01010000 01001000 01010000 00100000 01010000 01110010  PHP Pr
000019e: 01101111 01100111 01110010 01100001 01101101 01101001  ogrami
00001a4: 01101110 01101110 01100111 00000000 00000000 00000000  nng...
00001aa: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001b0: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001b6: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001bc: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001c2: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001c8: 00000000 00000000 01000011 01101000 01100001 01101110  ..Chan
00001ce: 01101110 01101001 01101110 01100111 00100000 01001000  ning H
00001d4: 01110101 01100001 01101110 01100111 00000000 00000000  uang..
00001da: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001e0: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001e6: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001ec: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001f2: 00000000 00000000 00000000 00000000 00000000 00000000  ......
00001f8: 00000000 00000000 00000000 00000000 01010000 01001000  ....PH
00001fe: 01010000 00100000 01010000 01110010 01101111 01100111  P Prog
0000204: 01110010 01100001 01101101 01101001 01101110 01100111  raming
000020a: 00100000 01010100 01110101 01110100 01101111 01110010   Tutor
0000210: 01101001 01100001 01101100 00000000 00000000 00000000  ial...
0000216: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000021c: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000222: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000228: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000022e: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000234: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000023a: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000240: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000246: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000024c: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000252: 00000000 00000000 00000000 00000000 00000000 00000000  ......
0000258: 00000000 00000000 00000000 00000000 00000000 00000000  ......
000025e: 00000000 00000000 11010101 00011101 01100011 00000000  ....c.

PHP的unpack函数有三个参数,第一个为解包的格式化字符,第二个为打包过的数据,第三个为起始偏移量。解包后返回一个关联数组,可以在解包的格式化字符串后面跟对应的key。

<?php
declare(strict_types=1);

$fp = \fopen("example","rb");
while (!\feof($fp)) {
    $data = \fread($fp,204);
    if($data) {
        $arr = \unpack("Z50title/Z50author/Z100subject/ibook_id",$data);
        foreach ($arr as $key => $value) {
            echo $key. "->" . $value.PHP_EOL;
        }
        echo PHP_EOL;
    }
}
\fclose($fp);

这样就可以读取对应的二进制文件,并正确解析出来。
不同的编程语言都可以对二进制文件进行读写。比如C语言里面打包上面的数据

#include<stdio.h>
#include<stdlib.h>

struct book {
   char  title[50];
   char  author[50];
   char  subject[100];
   int   id;
};
 
 int main()
 {
     FILE *fp;
     struct book books[3] = {
         {"C Programming", "Nuha Ali", "C Programming Tutorial", 6495407},
         {"Telecom Billing", "Zara Ali", "Telecom Billing Tutorial", 6495700},
         {"PHP Programinng", "Channing Huang", "PHP Programing Tutorial", 6495701}
    };
    fp = fopen("newbook", "wb");
 
    if(fp == NULL)
    {
        printf("Error opening file\n");
        exit(1);
    }
    printf("sizeof books %10lu\n", sizeof(books));
    printf("sizeof book struct %10lu\n", sizeof(struct book));
    printf("sizeof book1 title %10lu\n", sizeof(books[0].title));
    printf("sizeof book1 author %10lu\n", sizeof(books[0].author));
    printf("sizeof book1 subject %10lu\n", sizeof(books[0].subject));
    printf("sizeof book1 id %10lu\n", sizeof(books[0].id));
    fwrite(books, sizeof(books), 1, fp);
    fclose(fp);
    return 0;
 }
 

编译运行一下

[vagrant@localhost bin]$ gcc -o pack pack.c
[vagrant@localhost bin]$ ./pack
sizeof books        612
sizeof book struct        204
sizeof book1 title         50
sizeof book1 author         50
sizeof book1 subject        100
sizeof book1 id          4
[vagrant@localhost bin]$ ls -lah | grep newbook
-rw-r--r--. 1 vagrant vagrant  612 Aug 14 02:50 newbook

可以看到生成的文件大小与上面的一样,使用xxd查看的内容也跟上面的一样,并且可以使用unpak.php解析。类似的也可以用C来解析PHP打包的二进制数据

#include<stdio.h>
#include<stdlib.h>

struct book {
   char  title[50];
   char  author[50];
   char  subject[100];
   int   id;
};
 
 int main()
 {
     int i;
     FILE *fp;
     struct book Book;
     fp = fopen("example", "rb");
 
    if(fp == NULL)
    {
        printf("Error opening file\n");
        exit(1);
    }
    for (i=0;i<3; i++)
    {
        fread(&Book,sizeof(struct book),1,fp);
        printf( "Book title : %s\n", Book.title);
        printf( "Book author : %s\n", Book.author);
        printf( "Book subject : %s\n", Book.subject);
        printf( "Book book_id : %d\n", Book.id);
    }
    fclose(fp);
    return 0;
 }
 

编译运行一下

[vagrant@localhost bin]$ gcc -o unpack unpack.c
[vagrant@localhost bin]$ ./unpack
Book title : C Programming
Book author : Nuha Ali
Book subject : C Programming Tutorial
Book book_id : 6495407
Book title : Telecom Billing
Book author : Zara Ali
Book subject : Telecom Billing Tutorial
Book book_id : 6495700
Book title : PHP Programinng
Book author : Channing Huang
Book subject : PHP Programing Tutorial
Book book_id : 6495701

这里使用C的struct定义打包解包数据,这里面有两个问题:字节对齐NULL填充。字节对齐会导致生成的二进制文件大小与结构体里面定义的不一样,比如将title改为40个字节,整个book预计占用194字节,实际占用196字节(4的倍数),编译器自动填充了2个字节到title后面。所以要注意数据结构体的设计,避免过多对齐浪费。C语言的字符串其实是字符数组加上NUL结束符(\0),像上面那样初始化字符串是编译器默认填充的是NUL,但如果声明时没有初始化而是在后面赋值,比如strcpy,则可能在NUL后面填充非NUL值,导致在PHP解析出这些不必要的字符,显示不准确,这也是为什么在PHP里面解析时我们使用Z而不是A解析符的原因,它在碰到NUL便返回,不管后面填充的内容了。可以使用memset手动填充不足部分为’\0’,这样子就可以保证剩余部分全为NULL了。
对于unsigned short,unsigned long,float,double还有大端序、小端序,字节顺序可以参考这里。通常网络设备传输的字节序为大端序,而大部分CPU(x86)处理的字节序为小端序。这里只要通信双方沟通统一使用大端序或小端序即可,不需要关心传输过程中的自动转换。比如在PHP里面发送一个数字(4字节)和8个字符(8字节)

<?php
declare(strict_types=1);

$host = "127.0.0.1";
$port = 9872;

$socket = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP)
  or die("Unable to create socket\n");

@\socket_connect($socket, $host, $port) or die("Connect error.\n");

if ($err = \socket_last_error($socket))
{

  \socket_close($socket);
  die(\socket_strerror($err) . "\n");
}

$binarydata = \pack("Na8", "256", "Channing");
echo \bin2hex($binarydata).PHP_EOL;
$len = \socket_write ($socket , $binarydata, strlen($binarydata));
\socket_close($socket);

PHP里面数字的格式化字符是N,即采用大端序32位编码。在GO里面接收,使用大端序解析位数字

package main

import (
	"fmt"
	"net"
	"encoding/binary"
)

const BUF_SIZE = 12

func handleConnection(conn net.Conn) {
	defer conn.Close()
	buf := make([]byte, BUF_SIZE)
	n, err := conn.Read(buf)

	if err != nil {
		fmt.Printf("err: %v\n", err)
		return
	}

	//fmt.Printf("\nreceive:%d bytes,data:%x, %s\n", n, buf[:4], buf[4:])
	fmt.Printf("\nreceive:%d bytes,data:%d, %s\n", n, binary.BigEndian.Uint32(buf[:4]), buf[4:])
}

func main() {
	ln, err := net.Listen("tcp", ":9872")

	if err != nil {
		fmt.Printf("error: %v\n", err)
		return
	}

	for {
		conn, err := ln.Accept()
		if err != nil {
			continue
		}
		go handleConnection(conn)
	}
}

运行recieve.go 和 send.php,可以正常收发

[root@localhost bin]$ php send.php
000001004368616e6e696e67


[root@localhost bin]$ go run receive.go 

receive:12 bytes,data:256, Channing

如果在PHP里面使用了不匹配的格式化符号,比如i或者n,GO里面将解析错误。这里也可以采用小端序编码和解析。
pack后的二进制数据可以使用bin2hex转换为16进制查看,使用chr转换单个字节为可读性字符。
通常我们读写的是文本文件,这里读写的是二进制文件,文本文件到二进制文件有个编码过程,比如ASCII、UTF-8。其实文本文件,图片文件,都是二进制文件,都可以使用xxd查看,只不过特定软件能够解析(解码)对应的二进制字节数据。使用xxd查看时,它只能现显示ASCII可打印字符,不能显示的用点号表示。

参考链接:
Apache Thrift – 可伸缩的跨语言服务开发框架
Google Protocol Buffer 的使用和原理
高效的数据压缩编码方式 Protobuf
PHP: 深入pack/unpack
C Programming Files I/O
Unpacking binary data in PHP
大端和小端(Big endian and Little endian)
PHP RPC开发之Thrift

短网址生成

有时候我们想把一个URL分享给别人,如果URL参数太多,或者太长了,生成的二维码就不好扫描,不方便分享。于是便有了缩短网址的需求,比如微博的t.cn,微信的url.cn,Twitter的t.co。甚至有Bitly.com这样的第三方短网址服务商,提供url访问统计/营销。
怎么样将一个长URL映射成短的URL?如果只是单纯的字符串压缩,很难达到,没办法预计URL有多长。将URL MD5后看起来可行,但是有点长,我们不需要那么多位。最简单的就是在数据库里面存储,一个ID对应一个URL。事实上世界上的URL仍然没有超过2的64次方,因此我们采用自增数字ID就可以了,比如数据库自增量或分布式ID生成器。使用自增数字ID的好处是它可以作为数据库主键,通过它来查找对应的URL就很快来。但是随着ID的增长,它也会变得很长,我们还可以将它缩短:将一个10进制的数字转换为62进制的字符串,最多只要11个字符就可以了。


如果能预计需要生成的网址数量,还可以再缩短,譬如世界上的网址数量大概50亿,62的7次方远大于这个数,因此7个字符足够了。
当访问短网址时,需要301/302重定向到原本的长网址。如果是301永久重定向,则搜索引擎会直接显示重定向后的地址。
PHP函数base_convert可以在2和36进制之间转换,对于62进制就不行,这里分享一个Base62 的转换类,可以将10进制数字转换位62进制

<?php

declare(strict_types=1);

namespace Dig\Conversion;


class Base
{
    const BASE_MIN = 2;
    const BASE_MAX = 62;

    public function __construct(int $base, string $aplphabet)
    {
        if (($base < self::BASE_MIN) || ($base > self::BASE_MAX)) {
            throw new \Exception('base convert only require '.self::BASE_MIN.' <= base <= '.self::BASE_MAX);
        }
        if (empty($aplphabet)) {
            throw new \Exception('cannot have empty aplphabet');
        }
        if ($base > \strlen($aplphabet)) {
            throw new \Exception('base convert only require base <= aplphabet length ');
        }
        $this->base = $base;
        $this->alphabet = $aplphabet;
    }

    public function encode(int $number): string
    {
        $number = (string) $number;
        $base = (string) $this->base;
        $reminder = \bcmod($number, $base);
        $quotient = \bcdiv($number, $base);
        $result = $this->alphabet[$reminder];

        while ($quotient) {
            $reminder = \bcmod($quotient, $base);
            $quotient = \bcdiv($quotient, $base);
            $result = $this->alphabet[$reminder] . $result;
        }
        return $result;
    }
    public function decode(string $number): int
    {
        $base = (string) $this->base;
        $length = \strlen($number);
        $result = (string) \strpos($this->alphabet, $number[0]);

        for ($i = 1; $i < $length; $i++) {
            $result = \bcadd(\bcmul($base, $result), (string) \strpos($this->alphabet, $number[$i]));
        }
        return (int)$result;
    }
}

class Base62 extends Base
{
    public function __construct()
    {
        parent::__construct(62, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
    }
}

这里使用a-z表示10-36,A-Z表示37-62。注意这里需要用到BCMath扩展,否则对于超过2的32次方的大数字数不准确。也可以使用GMP扩展的gmp_strval函数直接转换。注意gmp使用A-Z表示10-36,a-z表示37-62。
测试一下:

include __DIR__.'/../vendor/autoload.php';

use Dig\Conversion\Base62;


$base62 = new Base62();
$id = 56800235584;

for ($i = 1; $i < 100; $i++) {
    $encode = $base62->encode($id);
    $decode = $base62->decode($encode);
    echo $id.'->'.$encode.'->'.$decode.PHP_EOL;
    $id += $i;
}

这里的起始ID可以自定义或者在分布式ID生成器里面更改CUSTOM_EPOCH生成合适的值。
由于这个ID本身就是唯一,映射为固定长度的字符,也可以用来做唯一标识,比如匿名用户的ID,Ngrok三级域名等等。
这个类可以扩展成支持任意2-64进制的转换,更改映射的字符串就行了。
注意这里的Base62转换于PHP函数base64_encode/base64_decode没有任何关系,即使在这个类的基础上增加2个字符支持Base64编码也不一样。Base64是使用64个可打印字符对二进制数据的编码解码,它的编码表与这里定义的不一样。

参考链接:
如何设计一个短网址服务(TinyURL)?
converting a number base 10 to base 62 (a-zA-Z0-9)
Generating IDs like Youtube or Bit.ly using PHP
PHP Base-62 encoding
Shortening Strings (URLs) using Base 62 Encoding
Base 2, 8, 16, 62, N Conversion – PHP

基于Swoole和Zookeeper的分布式ID生成器

大部分业务都需要一个唯一标识ID,比如订单ID、消息ID,通常使用的ID就是数据库的自增ID,比如MySQL的AUTO_INCREMENT;有时候这个ID还需要在不同系统里面传递、保存,又要保证唯一性。单机MySQL在高并发请求下面又可能存在锁/性能问题,于是Flicker使用两台MySQL来生成ID,一台从0开始,一台从1开始,步长为2,这样两台生成的ID不会互相重复,这个方案也可以扩展成N台,自增步长为N即可。
作为一个分布式ID应当避免在不同节点同步ID信息,通常都是基于时间戳和机器信息来生成。比如MongoDB的ObjectId是提前生成的为12字节=4字节UNIX时间戳+3字节机器码+2字节进程ID+2字节计数序列。
如果不需要访问数据库即能生成ID,性能可以更高。比如UUID V1,基于时间戳和网卡,采用128位,可以生成范围非常广的ID,但是生成的16进制值的36位字符串不好排序。在MySQL里面可以通过调整机器码(MAC)和时间戳位置顺序,并采用binary来存储以提高性能
Twitter开源的Snowflake则生成64位数字ID,包括41时间戳,10位机器码/节点码,12位计数序列,另外1位保留。采用基于时间戳的数字ID的好处是这个ID可以当作主键,并且已经粗略按时间排好序,可以直接分页读取,省去在时间字段上建立索引。

分布式ID通常需要用到机器信息(节点ID或MAC),一个机器通常只运行一个服务进程,所以通常不采用Nginx/Apache + PHP。参考这里实现一个基于Swoole和Zookpeer的64位ID生成器。基于Swoole可以快速开发一个Web/Socket server,不同于Apache/Nginx,它的PHP进程启动后是常驻运行的,资源初始化后可以重复使用,使用Zookpeer来获取当前进程的节点ID,一旦PHP进程退出后便会销毁对应的节点ID。
首先是生成ID

<?php

declare(strict_types=1);

namespace Dig\Ticket;

use Dig\Ticket\Exception\IllegalTimeException;

class Number
{
    public const TOTAL_BIT = 64;
    public const EPOCH_BIT = 42;
    public const NODE_BIT = 10;
    public const SEQUENCE_BIT = 12;

    public const MAX_NODE_ID = 2 ** self::NODE_BIT - 1;
    public const MAX_SEQUENCE_NUMBER = 2 ** self::SEQUENCE_BIT - 1;
    public const CUSTOM_EPOCH = 1262332800000;

    private $lastTimestamp = 0;
    private $sequence = 0;

    private $nodeId = 0;

    public function __construct(int $nodeId)
    {
        $this->nodeId = $nodeId;
    }

    public function getNodeId(): int
    {
        return $this->nodeId;
    }

    public function getTimestamp(): int
    {
        return (int) (\microtime(true) * 1000) - self::CUSTOM_EPOCH;
    }

    public function generate(): int
    {
        $current = $this->getTimestamp();
        if ($current < $this->lastTimestamp) {
            throw new IllegalTimeException('current timestamp cannot less than before');
        }
        if ($current === $this->lastTimestamp) {
            $this->sequence = ($this->sequence + 1) & self::MAX_SEQUENCE_NUMBER;
            if (0 === $this->sequence) {
                $current = $this->_waitNextTimestamp($current);
            }
        } else {
            $this->sequence = 0;
        }
        $this->lastTimestamp = $current;
        $id = $current << (self::TOTAL_BIT - self::EPOCH_BIT);
        $id = $id | ($this->getNodeId() << (self::TOTAL_BIT - self::EPOCH_BIT - self::NODE_BIT));
        $id = $id | $this->sequence;

        return $id;
    }

    private function _waitNextTimestamp($current)
    {
        while ($current === $this->lastTimestamp) {
            $current = $this->getTimestamp();
        }

        return $current;
    }
}

这里只涉及到ID生成,包括时间戳,序列号获取,而节点ID由其他对象生成并传入。2的42次方减1等于4398046511103,大概就是2109年5月15日,可以使用(2^42-1)/(365*24*60*60*1000)≈139年,距离现在还有90年可以用,仍然是一个非常大的可使用范围。CUSTOM_EPOC是自定义的时间戳偏移量,以便选取合适的ID生成下限和上限。距离现在节点生成的接口定义

<?php

declare(strict_types=1);

namespace Dig\Ticket;

interface NodeInterface
{
    public const MAX_NODE_ID = 2 ** Number::NODE_BIT - 1;

    public function getId(): int;
}

这里定义了最大节点序号不能超过1023,可以依据自己的需求更改范围。节点ID的实现可以是基于网卡/进程ID/文件配置等等实现,但是不同机器或多进程之间需要不一样的ID或者需要锁保证上面的generate函数。

<?php

declare(strict_types=1);

namespace Dig\Zookeeper;

class Client extends \Zookeeper
{
    public function makePath(string $path, string $value = ''): bool
    {
        $arrPath = \explode('/', $path);
        if (!empty($arrPath)) {
            $arrPath = \array_filter($arrPath);
            $subpath = '';
            $flag = true;
            foreach ($arrPath as $p) {
                $subpath .= '/'.$p;
                if (!$this->exists($subpath)) {
                    if (!$this->makeNode($subpath, $value)) {
                        $flag = false;
                        break;
                    }
                }
            }

            return $flag;
        }

        return false;
    }

    public function makeNode(string $path, string $value, array $acls = [], int $flag = 0): bool
    {
        if (empty($acls)) {
            $acls = [
                [
                    'perms' => \Zookeeper::PERM_ALL,
                    'scheme' => 'world',
                    'id' => 'anyone',
                ],
            ];
        }
        if ($this->create($path, $value, $acls, $flag)) {
            return true;
        }

        return false;
    }

    public function deletePath(string $path): bool
    {
        $children = $this->getChildren($path);
        if (!empty($children)) {
            foreach ($children as $child) {
                $subpath = $path.'/'.$child;
                $this->deletePath($subpath);
            }
        }

        return $this->delete($path);
    }
}

这里使用Zookeeper实现

<?php

declare(strict_types=1);

namespace Dig\Ticket\Node;

use Dig\Ticket\Exception\UnavailableNodeIdException;
use Dig\Ticket\NodeInterface;
use Dig\Zookeeper\Client;

class Zookeeper implements NodeInterface
{
    private $zk;
    private $dsn;
    private $pool;
    private $basePath = '/dig/ticket';
    private $acls = [
        [
            'perms' => \Zookeeper::PERM_ALL,
            'scheme' => 'world',
            'id' => 'anyone',
        ],
    ];
    private $id;

    public function __construct(string $dsn, string $path = '/sim/ticket')
    {
        $this->dsn = $dsn;
        $this->pool = new \SplQueue();
        if (!empty($path)) {
            $this->basePath = $path;
        }
    }

    public function getZookeeper(): Client
    {
        if (null === $this->zk) {
            $this->zk = new Client($this->dsn);
        }

        return $this->zk;
    }

    public function getId(): int
    {
        if (null === $this->id) {
            if (!$this->getZookeeper()->exists($this->basePath)) {
                $this->getZookeeper()->makePath($this->basePath);
            }
            $i = 1;
            $length = \mb_strlen((string) self::MAX_NODE_ID);
            $nodeId = \sprintf('%0'.$length.'d', $i);
            $children = $this->getZookeeper()->getChildren($this->basePath);
            $children = empty($children) ? [] : $children;
            for (; $i <= self::MAX_NODE_ID; ++$i) {
                $nodeId = \sprintf('%0'.$length.'d', $i);
                if (!\in_array($nodeId, $children)) {
                    $path = $this->basePath.'/'.$nodeId;
                    if ($this->getZookeeper()->exists($path)) {
                        //throw new UnavailableNodeIdException('node already exist: '.$path);
                        continue;
                    }
                    try {
                        $this->getZookeeper()->makeNode($path, $nodeId, $this->acls, \Zookeeper::EPHEMERAL);
                        break;
                    } catch (\ZookeeperException $e) {
                        //throw new UnavailableNodeIdException('cannot create node in zookeeper: '.$e->getMessage());
                        continue;
                    }
                }
            }
            if (self::MAX_NODE_ID === $i) {
                throw new UnavailableNodeIdException('cannot create node in zookeeper: reach max node limit '.self::MAX_NODE_ID);
            }
            $this->id = $i;
        }

        return $this->id;
    }
}

这里遍历查询1-1023之间的节点是否都已在Zookeeper上注册,如果没有则注册,Zookeeper会保证只有一个客户端注册成功。注册的节点类型位Zookeeper::EPHEMERAL,在客户端退出时,该节点会被自动删除,方便其他机器/进程申请。在这篇文章里面我们也使用Zookeeper::EPHEMERAL配合Zookeeper::EPHEMERAL,生成序列号,用来确定进程的master/slave。
初始化并运行Swoole Web server,需要传入Zookeeper的连接字符串,可以使用docker快速部署

<?php
include __DIR__.'/../vendor/autoload.php';
use Dig\Ticket\Number;
use Dig\Ticket\Node\Zookeeper as ZookeeperNode;


/** 
 * swoole - zookeeper tick dispatch issue: https://github.com/php-zookeeper/php-zookeeper
*/
$host = getenv("ZOOKEEPER_CONNECTION");
$host = empty($host) ? "192.168.33.1:2181" : $host;
$node = new ZookeeperNode($host);

$http = new \Swoole\Http\Server("0.0.0.0", 9501);

$http->on("start", function ($server) {
    echo "Swoole http server is started at http://0.0.0.0:9501\n";
});

$http->on("WorkerStart", function ($server, $workerId) use($node) {
    // https://wiki.swoole.com/wiki/page/325.html
    // https://wiki.swoole.com/wiki/page/852.html
    // https://wiki.swoole.com/wiki/page/865.html
    // use lazy initial zk here, so that each worker can hold its own zk resource
    // if we only run swoole http server in 1 worker process (1 CPU), then no need to consider this
    $id = $node->getId();
    $server->nodeId = $id;
    $server->number = new Number($server->nodeId);
});

$http->on("request", function ($request, $response) use ($http) {
    $data = $http->number->generate();
    $response->end($data);
});

$http->start();

访问本机的9501端口即可以得到ID了,完整代码在这里。Swoole默认运行与CPU核数量相同的worker进程数,注意这里需要WorkerStart里初始化获取Node节点ID,如果只是运行一个Swoole worker进程,也可以在外面获取节点ID。可以将Swool\Htpp\Server替换成React\Http\Server或者Amp\Http\Server,它们在单个进程里面loop,每个进程分别持有自己的节点序号,可以保证生成的ID不冲突,性能方面Swoole > Amp > ReactPHP。
可以采用Swoole\Server + thrift/gRPC改造这些代码,提供RPC服务。
注意ID的生成是随时间递增的,依赖于时间戳,如果出现了时间回拨,将会抛出异常。一般解决方案包括:

  • 等待重试
  • 使用Int64原子自增量代替时间戳,跳过时间戳判断
  • 使用预留的节点ID
  • 关闭时钟同步
  • 使用备选自增量方案

生成的ID并不是严格递增的,只是千分一秒递增,对于微博、Twiter的Timeline够用;但也有好处,比如别人不能通过ID相减了解美团的订单量。

参考链接:
如何设计一个分布式ID生成器(Distributed ID Generator),并保证ID按时间粗略有序?
生成全局唯一ID的3个思路,来自一个资深架构师的总结
Distributed unique id generation
Unique ID generation in distributed systems
Optimised UUIDs in mysql
Storing UUID Values in MySQL Tables
Mysql 8.0: UUID support
How to store a 128 bit number in a single column in MySQL?
Generating unique IDs in a distributed environment at high scale
Leaf——美团点评分布式ID生成系统
分布式ID增强篇–优化时钟回拨问题
Ticket Servers: Distributed Unique Primary Keys on the Cheap
Sharding & IDs at Instagram

使用Composer和Docker创建Swoole项目

最近有一个小功能使用swoole扩展来开发,并没有使用框架,从零开始。这里采用类用似Symfony的目录结构:

.
├── bin
├── config
├── src
├── tests
├── vendor
├── phpunit.xml
├── composer.json
├── .gitignore
├── Dockerfile
├── LICENSE
├── phpunit.xml
└── README.md

项目代码放置在src下面,测试代码则在tests里面,config目录存放配置文件,bin文件夹则是一些命令行工具,vendor目录则是composer安装第三方依赖库的目录。composer.json是借鉴k911/swoole-bundle,内容如下

{
    "name": "llitllie/swoole-sample",
    "type": "library",
    "description": "Swoole Sample",
    "keywords": [
        "Swoole"
    ],
    "license": "MIT",
    "homepage": "https://github.com/llitllie/swoole-sample.git",
    "authors": [{
        "name": "llitllie",
        "email": "[email protected]",
        "homepage": "https://github.com/llitllie/swoole-sample.git"
    }],
    "require": {
        "php": "^7.2",
        "ext-swoole": "^4.3.4"
    },
    "require-dev": {
        "phpunit/phpunit": "^8",
        "phpstan/phpstan": "^0.11.8",
        "friendsofphp/php-cs-fixer": "^2.15",
        "swoole/ide-helper": "@dev"
    },
    "scripts": {
        "static-analyse-src": [
            "phpstan analyze src -l 7 --ansi"
        ],
        "cs-analyse": [
            "php-cs-fixer fix -v --dry-run --diff --stop-on-violation --ansi"
        ],
        "analyse": [
            "@static-analyse-src",
            "@cs-analyse"
        ],
        "test": [
            "@analyse",
            "@unit-tests"
        ],
        "unit-tests": [
            "phpunit tests --testdox --colors=always"
        ],
        "fix": "php-cs-fixer fix -v --ansi"
    },
    "suggest": {
        "ext-uv": "^0.2.4",
        "ext-ev": "^1.0.6"
    }
}

这里require里面指定了PHP版本>=7.2和Swoole扩展,composer安装时会自动检查。require-dev里面指定了单元测试工具phpunit、代码分析工具phpstan和代码风格检查php-cx-fixer。phpstan可以帮助分析代码,检测语法,在代码运行前发现问题。php-cx-fixer则可以帮忙格式化代码,保持代码统一风格。scripts里面定义了调用它们的命令,可以使用composer运行

$ ls -la vendor/bin/
total 4
drwxrwxr-x.  2 vagrant vagrant   69 Jul  1 07:24 .
drwxrwxr-x. 24 vagrant vagrant 4096 Jul  1 07:25 ..
lrwxrwxrwx.  1 vagrant vagrant   41 Jul  1 07:24 php-cs-fixer -> ../friendsofphp/php-cs-fixer/php-cs-fixer
lrwxrwxrwx.  1 vagrant vagrant   33 Jul  1 07:24 php-parse -> ../nikic/php-parser/bin/php-parse
lrwxrwxrwx.  1 vagrant vagrant   30 Jul  1 07:24 phpstan -> ../phpstan/phpstan/bin/phpstan
lrwxrwxrwx.  1 vagrant vagrant   26 Jul  1 07:24 phpunit -> ../phpunit/phpunit/phpunit
$ ./vendor/bin/phpunit --testdox tests

$ composer test
> phpstan analyze src -l 7 --ansi
 7/8 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░]  87%

 8/8 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%                                                                                
 [OK] No errors                                                                 
                                                                                

> php-cs-fixer fix -v --dry-run --diff --stop-on-violation --ansi
Loaded config default from "/home/ticket/.php_cs.dist".
Using cache file ".php_cs.cache".
SSSSSSSSSSS
Legend: ?-unknown, I-invalid file syntax, file ignored, S-Skipped, .-no changes, F-fixed, E-error

Checked all files in 0.070 seconds, 6.000 MB memory used
> phpunit tests --testdox --colors=always
PHPUnit 8.2.4 by Sebastian Bergmann and contributors.

Sim\Ticket\Node\Zookeeper
 ✓ Get id

Sim\Ticket\Number
 ✓ Load
 ✓ Get timestamp
 ✓ Get node id
 ✓ Generate
 ✓ Generate with zookeeper node

Sim\Zookeeper\Client
 ✓ Zookeeper
 ✓ Zookeeper extension

Time: 841 ms, Memory: 4.00 MB

OK (8 tests, 23 assertions)

phpuit.xml里面则是一些测试配置,可以在里面定义自动加载和变量

<?xml version="1.0" encoding="UTF-8"?>

<!-- https://phpunit.readthedocs.io/en/7.3/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/7.3/phpunit.xsd"
         colors="true"
         bootstrap="vendor/autoload.php"
>
    <filter>
        <whitelist processUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">src/</directory>
        </whitelist>
    </filter>
    <testsuites>
        <testsuite name="Unit tests suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <php>
        <includePath>.</includePath>
        <const name="SERVICE" value="192.168.33.1"/>
        <env name="SERVICE" value="192.168.33.1"/>
    </php>
</phpunit>

PHP Composer支持使用已有的代码作为模板,快速克隆创建一个新项目,方便重复使用。将代码保存到GitHub上面然后在packagist.org上面提交就可以了。使用composer create-project:

composer create-project llitllie/swoole-sample example dev-master

这里使用Docker来构建运行代码,Dockerfile内容如下

ARG PHP_TAG="7.2-cli-alpine3.9"

FROM php:$PHP_TAG

ENV COMPOSER_ALLOW_SUPERUSER 1

RUN set -ex \
  	&& apk update \
    && apk add --no-cache --virtual .build-deps curl gcc g++ make build-base autoconf \
    && apk add libstdc++ openssl-dev libffi-dev \
    && docker-php-ext-install sockets \
    && docker-php-source extract \
    && printf "yes\nyes\nno\nyes\nno\n" | pecl install swoole \
    && docker-php-ext-enable swoole \
    && docker-php-source delete \
    && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
    && apk del .build-deps \
    && rm -rf /tmp/* 

WORKDIR /usr/src/app
COPY . ./
ARG COMPOSER_ARGS="install"
RUN composer ${COMPOSER_ARGS} --prefer-dist --ignore-platform-reqs --no-progress --no-suggest --no-scripts --ansi

EXPOSE 9501
CMD ["php", "bin/server.php"]

这里声明了swoole将会监听9501端口,默认运行bin目录下面的serverphp文件。使用pecl安装swoole会要求回答几个问题,比如是否启用socket/http2/mysqlnd等等,这里使用printf输出答案。构建,然后运行

docker build  -t llitllie/swoole-project .
docker run --name swoole -d -p 9501:9501 llitllie/swoole-project
#docker run --name web -dit -p 9501:9501 --mount type=bind,source=/Users/vagrant/example,target=/opt/app llitllie/swoole-project /bin/sh

这样一个swoole项目模板算是好了,可以继续往composer文件里面添加需要的库或脚本;也可以继承在已经构建好的docker镜像基础上继续添加所需的软件,更改监听端口等等。
可以在Docker Cloud上面配置对应的git,并配置对应自动化构建规则,这样就可以方便的自动构建了。

Jupyter Notebook

对于编程初学者,如果有一个开箱即用的环境,比如web页面,就可以进行编程交互,那是极友好。有时候我们想在远程服务器上执行一些脚本,输出一些结果,比如科学计算;有时候又想在服务器上执行一些命令但又不能直接登录服务器,如果能够在web界面上操作或作为跳板机,那也是极友好的。Jupyter Notebook是基于IPython的一个基于web交互执行的在线环境,支持Python,也支持其他编程语言,比如Julia和R。所创建Notebook文档可以自动保存执行过的代码、结果,方便进行回放。
Jupyter Notebok的安装很方便,可以使用Anaconda来安装,或者手动安装。Python3下手动安装,

pip3 install jupyter
export PATH=$PATH:/usr/local/python3/bin

查看一下

[root@localhost local]# pip3 show jupyter
Name: jupyter
Version: 1.0.0
Summary: Jupyter metapackage. Install all the Jupyter components in one go.
Home-page: http://jupyter.org
Author: Jupyter Development Team
Author-email: [email protected]
License: BSD
Location: /usr/local/python3/lib/python3.7/site-packages
Requires: jupyter-console, notebook, ipywidgets, nbconvert, qtconsole, ipykernel
Required-by: 

如果直接运行jupyter notebook,那么会生成一个本地可以访问的带token的url,每次都不一样,不是很方便。设置密码,以便登录

[root@localhost opt]# jupyter notebook password
Enter password: 
Verify password: 
[NotebookPasswordApp] Wrote hashed password to /root/.jupyter/jupyter_notebook_config.json
[root@localhost bin]# cat /root/.jupyter/jupyter_notebook_config.json 
{
  "NotebookApp": {
    "password": "sha1:e04153005102:961b12eef91987a06b497f915fc3f18c62d8f714"
  }

由于是在虚拟机里面,我们并不需要Jupyter自动打开浏览器,但需要监听来自任意IP的请求,指定端口9030。这里使用root用户运行Jupyter,默认是不允许的:

[root@localhost opt]# jupyter notebook --no-browser --allow-root --ip 0.0.0.0 --port 9030
[I 02:13:44.320 NotebookApp] Serving notebooks from local directory: /opt
[I 02:13:44.320 NotebookApp] The Jupyter Notebook is running at:
[I 02:13:44.320 NotebookApp] http://(localhost.localdomain or 127.0.0.1):9030/
[I 02:13:44.320 NotebookApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).
[I 02:13:59.664 NotebookApp] 302 GET / (192.168.33.1) 1.22ms
[I 02:14:23.597 NotebookApp] Kernel started: 7ad63717-7a65-4dec-9d5a-9af654c28f75
[I 02:14:25.204 NotebookApp] Adapting to protocol v5.1 for kernel 7ad63717-7a65-4dec-9d5a-9af654c28f75
[I 02:14:37.350 NotebookApp] Starting buffering for 7ad63717-7a65-4dec-9d5a-9af654c28f75:ea68853b742c40f8bcf8745529ea95de
[I 02:14:43.735 NotebookApp] Kernel started: 5b569c8d-6936-4bd2-9674-0317c46948f6
[I 02:14:44.124 NotebookApp] Adapting to protocol v5.0 for kernel 5b569c8d-6936-4bd2-9674-0317c46948f6
[2019-06-03 02:14:43] kernel.DEBUG: Connection settings {"processId":6751,"connSettings":{"shell_port":39990,"iopub_port":48184,"stdin_port":40113,"control_port":43426,"hb_port":49075,"ip":"127.0.0.1","key":"d5f89bba-890ecf15e6b20718411170ad","transport":"tcp","signature_scheme":"hmac-sha256","kernel_name":"jupyter-php"},"connUris":{"stdin":"tcp://127.0.0.1:40113","control":"tcp://127.0.0.1:43426","hb":"tcp://127.0.0.1:49075","shell":"tcp://127.0.0.1:39990","iopub":"tcp://127.0.0.1:48184"}} []
[2019-06-03 02:14:44] KernelCore.DEBUG: Initialized sockets {"processId":6751} []

然后打开浏览器,访问http://192.168.33.70:9030,输入账号密码,就可以在web里面运行Python了

Jupyter默认带了SQL扩展,使用ipython-sql来执行,只需要安装对应的驱动,这里使用PyMySQL

python3 -m pip install PyMySQL

然后在Web里面执行就可以了

Jupyter还有其他扩展,参考这里
除了可以执行Python和SQL,Jupyter Notebook也可以支持其他语言,在这里列出了。通常执行方式是通过Bash执行,或者通过ZeroMQ来通信,参考这里实现。一个Jupyter的kernal将需要监听以下几个socket:

  • Shell:执行命令
  • IOPub:推送执行结果
  • Stdin:接收输入
  • Control:接收控制命令,比如关闭、终端
  • Heartbeat:心跳检测
  • 这个思路也可以用来做IOT设备的远程监控,交互执行。
    这里安装一下PHP 7这个kernel,作者甚至还提供了installer,但是首先要安装ZeroMQ以便与Jupyter服务通信

    yum install php-pecl-zmq
    wget https://litipk.github.io/Jupyter-PHP-Installer/dist/jupyter-php-installer.phar
    ./jupyter-php-installer.phar install
    

    查看安装文件

    [root@localhost opt]# ls -la /usr/local/share/jupyter/kernels/
    total 0
    drwxr-xr-x. 4 root root 34 May 10 06:10 .
    drwxr-xr-x. 3 root root 20 May  9 07:30 ..
    drwxr-xr-x. 2 root root 24 May  9 07:30 jupyter-php
    drwxr-xr-x. 2 root root 40 May 10 06:10 lgo
    
    [root@localhost opt]# cat /usr/local/share/jupyter/kernels/jupyter-php/kernel.json 
    {"argv":["php","\/opt\/jupyter-php\/pkgs\/vendor\/litipk\/jupyter-php\/src\/kernel.php","{connection_file}"],"display_name":"PHP","language":"php","env":{}}
    

    这个扩展使用了react/zmq来监听Jupyter请求,使用psysh来交互执行PHP代码。

    如果想要更改Jupyter的web模板,可以在以下目录找到

    [root@localhost vagrant]# ls -la /usr/local/python3/lib/python3.7/site-packages/notebook/templates
    total 92
    drwxr-xr-x.  2 root root  4096 May  9 06:33 .
    drwxr-xr-x. 19 root root  4096 May  9 06:33 ..
    -rw-r--r--.  1 root root   147 May  9 06:33 404.html
    -rw-r--r--.  1 root root   499 May  9 06:33 browser-open.html
    -rw-r--r--.  1 root root  4258 May  9 06:33 edit.html
    -rw-r--r--.  1 root root   856 May  9 06:33 error.html
    -rw-r--r--.  1 root root  4256 May  9 06:33 login.html
    -rw-r--r--.  1 root root  1179 May  9 06:33 logout.html
    -rw-r--r--.  1 root root 23162 May  9 06:33 notebook.html
    -rw-r--r--.  1 root root  6559 May  9 06:33 page.html
    -rw-r--r--.  1 root root  1089 May  9 06:33 terminal.html
    -rw-r--r--.  1 root root 12130 May  9 06:33 tree.html
    -rw-r--r--.  1 root root   544 May  9 06:33 view.html
    

    Jupyter Notebook Web前端采用WebSocket与服务器交互,服务器接收消息并转发给对应的kernel执行或控制,并将结果推送给前端。Jupyter Notebook也可以直接打开Terminal,在远程服务器上执行命令。注意这里的用户就是刚才运行jupyter的用户

    许多web terminal也都是采用WebSocket来做交互,比如xterm.jswebtty
    Juypter Notebook适合单用户(单机)使用,如果提供多用户使用(比如教学),可以使用Jupyter Hub,可以使用docker快捷部署。

    参考链接:
    Jupyter Notebook Extensions
    Jupyter – How do I decide which packages I need?
    PsySH——PHP交互式控制台
    Jupyter项目
    WebSocket 教程