一直想有一个自己技术博客,激励自己每天去学习,终于终于下定决心做了(距写这篇博客好像有2个多月了)。既然是程序员,就决定自己做一个简单的网站,不求外观漂亮,但求基本功能。其实最开始规划的功能还是挺全的,包括编辑、评论、评分及mailing list功能等。考虑的方案最开始是纯php后台,文章编辑和存储都用php读写文件的方式。但是考虑mailing list用Perl做,就有两套代码,所以考虑php + Perl的方式,php做用户交互后台,Perl做数据后台,Perl做后台还有一个好处,可以缓存一些数据到内存,提高性能。于是花了2个星期学习了Perl脚本。Perl脚本非常优雅,学起来有点难度。后来考虑到复杂性,把评论和mailing list给去掉了。
主要包括数据文件存储、数据内存索引、交互接口等几个部分。
共有3种存储类型:已删除、草稿和文章。建立3个目录存储,在db目录下,分别为deleted/draft/paper。每篇文章又存储3种类型的数据:索引、附件和文章。存储在对应的文件中,索引文件为id.index,文章为id.paper,附件为id.attach_id.attach。附件存储附件的二进制内容,文章存储文章的html格式内容。索引存储文章的相关元数据,用键值对表示,在Perl中可以很方便的用Hash数据结果存储,如下:
cat /test
email xxx@163.com
abstract xxx
modtime 1494939529
title xxx
paper_pid a001rp
author xxx
这里简单介绍一下,索引驻留在内存,设计的数据结构如下:
1 2 3 4 | my @draft_list ; my %cat_index ; my @cats ; my @allpapers ; |
每一篇草稿都是一个Hash变量,存储2.1中表示的键值对。草稿Hash变量和索引文件保持同步。草稿的哈希引用存储到@draft_list中,用于索引所有的草稿,草稿id从0开始连续编号。
@allpapers数组存储所有已发布文章的Hash变量的引用。数组的下标即是文章的id。同时文章有一个 pseudo id,用户看到的是这个id,其格式如:a004j0。前4位为id值,a004的id为4,后两位为随机盐值,在发布文章的时候生成。
@cats存储所有的分类,按照分类排序,每发布一篇文章,都将该文章的分类更新到@cats数组中。
%cat_index哈希变量用于按分类索引文章,每个键表示一个分类,每个值是该分类的所有文章的数组的索引。
Perl脚本启动过程中也会从文件中,加载文章到上述数据结构。
采用unix socket的方式和php交互数据。Perl后台ildd循环接收web server的请求并响应数据给它,如下:
域套接字的类型选择stream还是datagram,前者对消息的长度没有限制,但是需要解析消息边界。后者的缺点是Linux内核对长度有限制,但是这个长度已经很大了,有几十上百KB,关于datagram的长度可以参考下述链接:
What is the max size of AF_UNIX datagram message that can be sent in linux?
最终考虑选择datagram,对于超长数据的传输可以采用及其它机制,如php将文章存储到临时文件,将文件名传输到ildd进行处理。
请求消息的格式为:cmd \n params1 \n params2 ...。cmd表示请求命令类型,params为键值对类型的参数,键值之间用空格分开,命令类型和各参数之间用\n分开,所以这里要求参数的值中不能有\n字符。
响应消息的格式为:code desc \n params1 \n params2 \n \n params3 \n ... 。code表示响应结果,如200表示ok,300表示错误。响应的数据也是键值对形式,可以响应多组数据,用\n\n分开。如查询某一分类的文章时,可能响应多组数据。
Perl后台以守护进程的方式运行,和通常的守护进程处理方式类似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | sub daemonize { $subpid = fork ; die "can't fork: $!\n" if not defined $subpid ; if ( $subpid ) { if ( defined $pidfile ) { if ( open my $fd , ">" , $pidfile ) { print $fd $subpid ; close ( $fd ); } } exit 0; } POSIX::setsid(); open STDIN , '<' , '/dev/null' ; if ( defined $logfile ) { open STDOUT, '>>' , $logfile ; open STDERR, '>>' , $logfile ; } else { open STDOUT , '>' , '/dev/null' ; open STDERR , '>' , '/dev/null' ; } } |
相关参数为-d,以守护进程方式运行。--pidfile file,将进程id存储到file。--logfile file输出log信息到file。
添加init脚本,并添加到rc.d中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | ILDD_DIR= "/run/ildd" ILDD_CLIENT_DIR= "/run/ildd/client" PHP_TMP_DIR= "/run/php" DRAFT_TMP_DIR= "${PHP_TMP_DIR}/draft" STAT_TMP_DIR= "${PHP_TMP_DIR}/stat" DAEMON= /work/insidelinuxdev .net /perl/ildd .pl PIDFILE=${ILDD_DIR} /ildd .pid LOGFILE=${ILDD_DIR} /ildd .log # check bin exists and executable [ -x $DAEMON ] || { echo $DAEMON is not executable exit 1 } prep_env () { if [ ! -d $ILDD_DIR ] ; then echo mkdir $ILDD_DIR mkdir $ILDD_DIR fi if [ ! -d $ILDD_CLIENT_DIR ] ; then echo mkdir $ILDD_CLIENT_DIR mkdir $ILDD_CLIENT_DIR chown -R www-data:www-data $ILDD_CLIENT_DIR fi if [ ! -d $DRAFT_TMP_DIR ] ; then mkdir $DRAFT_TMP_DIR chown -R www-data:www-data $DRAFT_TMP_DIR fi if [ ! -d $STAT_TMP_DIR ] ; then mkdir $STAT_TMP_DIR chown -R www-data:www-data $STAT_TMP_DIR fi } do_start () { if [ -f $PIDFILE ] ; then echo "error: ildd is running" >&2 exit 1; fi # mk dirs prep_env # start daemon $DAEMON -d --pid $PIDFILE --log $LOGFILE } do_stop() { if [ -f $PIDFILE ] ; then kill -3 ` cat $PIDFILE ` rm $PIDFILE else echo "error: ildd is not running" >&2 exit 1; fi } case "$1" in start) do_start ;; stop) do_stop ;; restart) do_stop sleep 1 do_start ;; *) echo "Usage: /etc/init.d/ildd {start|stop}" >&2 exit 3 ;; esac |
上述init脚本有点缺陷,pidfile是在该脚本删除的,同时没有检测进程的运行状态,对于restart操作,stop之后1s再执行start。编写脚本,用于安装上述启动脚本,如下:
web请求的数据,通过php转发ildd,同时读取ildd的响应,发给web client。php相当于一个中转和封装中心。
封装一个ildc类,用于建立连接、发送请求,读取响应等。建立Unix套接字连接接口如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public function connect() { $sock = socket_create(AF_UNIX, SOCK_DGRAM, 0); if ( $sock == false) { return false; } $uniqid = $_SERVER [ 'UNIQUE_ID' ]; if ( $uniqid == "" ) { socket_close( $sock ); return false; } $local_path = ILD_CLIENT_PATH_PREFIX. $uniqid ; if (!socket_bind( $sock , $local_path )) { socket_close( $sock ); return false; } $this ->sock = $sock ; $this ->local_path = $local_path ; return true; } |
这里使用了$_SERVER['UNIQUE_ID'],来确保不同请求的unix socket addr不会冲突,需要开启Aapche2的unique_id模块来支持。发送请求、接收响应并解析数据接口如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | public function req( $cmd , & $res_arr ) { $len = strlen ( $cmd ); if ( $len <= 0) return false; $ret = socket_sendto( $this ->sock, $cmd , strlen ( $cmd ), 0, ILD_SERVER_PATH); if ( $ret <= 0) return false; $res = socket_recvfrom( $this ->sock, $buf , ILD_MSG_LEN, 0, $path ); if ( $res === false) return false; $res_arr = ildc::idlc_explode( $buf ); if ( count ( $res_arr ) < 1) { return array (); } $res = explode ( " " , $res_arr [0], 2); array_shift ( $res_arr ); return $res ; } |
还有其它一些辅助函数,这里不介绍。
cgi.php用于封装所有的命令,如读取分类列表,编辑文章等。它调用ildc类与ildd交互数据。cgi接口同时支持POST和GET两种请求。对于编辑文章等使用POST请求,下载附件等使用GET请求。首先有两个命令数组,表示支持哪些命令,携带哪些参数,参数是强制还是可选,如支持的GET命令如下:
1 2 3 4 5 | $get_cmds = array ( "draft_down_attach" => array ( "draft_id" , "_attach_id" ), "draft_get_paper" => array ( "draft_id" ), "paper_get_attach" => array ( "paper_pid" , "_attach_id" ), ); |
还有一个权限数组,哪些操作需要权限,如编辑相关的操作,均需要管理员权限。阅读文章等操作,则没有权限要求。
1 2 3 4 5 6 7 8 9 10 11 12 13 | $rights = array ( "draft_get" => 1, "draft_save" => 1, "draft_del" => 1, "draft_up_attach" => 1, "draft_del_attach" => 1, "draft_down_attach" => 1, "draft_get_paper" => 1, "publish" => 1, "paper_del" => 1, "paper_edit" => 1, ); |
执行ildc接口,并响应数据给web client。响应,对于POST请求,响应json格式。对于GET请求,响应plain text格式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | $ildc = new ildc(); if (! $ildc ->connect()) { res_func(600, "can't connect to ildd" , null); exit ; } $res = $ildc -> $op ( $ild_data , $res_data ); if ( $res === false || count ( $res ) < 1){ res_func(601, "interval error" , null); } else { res_func( $res [0], $res [1], $res_data ); } $ildc ->close(); |
重定向用于实现伪静态,重定向规则如下
1 2 3 4 5 6 7 8 9 | <IfModule mod_rewrite.c> RewriteEngine on RewriteCond %{HTTP_HOST} ^www.insidelinuxdev.net [NC] RewriteRule ^/?(.*)$ http://insidelinuxdev.net/$1 [L,R=301] RewriteRule ^/?article/([a-z0-9]+).html$ /read.php?paper_pid=$1 [NC] RewriteRule ^/?cat((?:/[^/]+)*)/([0-9]+).html$ /list.php?cat=$1&p=$2 [NC] RewriteRule ^/?attachment/([a-z0-9]+)/([0-9]+)/?$ /cgi.php?op=paper_get_attach&paper_pid=$1&_attach_id=$2 </IfModule> |
实现下述URL:
www.insidelinuxdev.net重定向到insidelinuxdev.net
文章页面:/article/a004jq.html
分类页面:/cat/project/1.html
附件页面:/attachment/a004jq/0
包括登录页面、编辑页面、首页、文章列表、文章阅读页面等。编辑器原先使用wangEditor,后来切换为ueditor。图片以base64存储到html中,对于文章阅读页面,所有的资源都嵌入到该页面,即为单一页面,方便用户离线存储,同时排版也考虑便于打印。
由于以数组的方式组合所有文章、草稿、分类索引,以id读取数组元素,所以读复杂度为O(1),读包括文章按分类分页,读取文章。发布新文章,插入到文章的结尾,复杂度也为O(1)。但是编辑已发布文章,需要删除旧文章,再插入(按文章原id排序),这里涉及移位,所以复杂度为O(n)。
使用页面js脚本循环发布文章,性能大概在30篇/s,这里性能限制恐怕是页面请求交互过程,考虑到发布文章是离散事件,所以30篇/s完全能接受。
使用ab测试,结果在3000 Requests per second左右。ildd占11%的cpu资源,其余负载在众多apache进程。
分别测试第1页,第20页和最后一页。结果如下,看以看出并发差不多,也证明其复杂度为O(1)。
page | 1 | 20 | 46 |
Requests per second | 2144.45 | 2010.34 | 2036.65 |
可以看出,ildd的负载为22.7%,比首页重。分页时,需要检索10篇文章,读取文章的标题、摘要等信息,所以负载会重一些。
由于文章存储到硬盘,没有被ildd缓存,所以涉及磁盘I/O,性能必然下降。测试结果为1708.59 Requests per second。性能有些许下降,测试的文章为40KB,是不是特别大。而且内核也有I/O缓存。所以下降不是特别多。ildd 15%,ksoftirqd/1 7.6%,其余为apache进程。由于涉及磁盘I/O,所以软中断明显吃掉一些CPU。
注意:单实例ab测试,可能并不能反映真实的并发能力。