十年(上)

上周末是参加工作十周年,回南京与同一年进入公司的老同事们聚了聚,很开心,也有颇多感想。

对于我在Trend Micro的第一份工作,一直以来,我是心存感激的。从学校走向社会,从稚嫩变得慢慢成熟,回头去看,有很多人、很多事都值得感激。

总结一下过去的十年,顺便熬一锅心灵鸡汤。

2006年

离开学校,走进公司。趋势科技应聘经历里提到的那位在一面时给了我很多自信的面试官成了我的第一位Manager。放到现在,也许我不会说事无巨细、无微不至的Manager是一位足够Professional的Manager,但是作为职场新人,这样的Manager给了我足够的帮助。他对我的信任、给我提供的机会和帮助,都为我的成长提供了一个良好的环境。后来,我做过新人的Mentor、也带过小项目,践行了很多从他身上学到的东西。

公司提供的培训,帮助我理解了Development Process、Design Documents、Technical Writing、Internationalize & Localization、User Centered Design、Writing Secure Code相关的知识,并在短时间内都在工作中得到了实践,这些经验一直受用至今。虽然互联网公司并不适合照搬传统软件公司的开发流程,但是很多方法思路依然值得借鉴。

技术方面,虽然1999年我就装过玩过Linux,但一直到工作前对Linux都还处于基本不懂的状态。工作的前半年,我学习了Linux下的各种开发工具,从无到有独立完成了产品中一个GTK程序的开发,学会了修改了内核模块的代码和内核调试的基本工具。后面五年中用到的各种知识和技能中的一大半都是在这半年中积累起来的。学东西最快的途径就是看书加实践,学而不思则罔,学而不实践则很难真正把书上的东西变成自己的。

ServerProtect for Linux 3.0

我参与开发的第一个产品ServerProtect for Linux 3.0(图片来自网络)

公司让大家考了托业(TOEIC),并开始上英语课,还推行了一段时间英文会议。于是原先只出现在邮件交流中的各种“洋泾浜”英语,开始出现在日常的会议中,挂在了每个人的嘴边。这些并不完美的英语,却真正的帮助到了我,从这以后,基本上我就摆脱了“哑巴英语”的困境 。

英语课上,老师推荐了一部电影《Dead Poet Society》,在这部电影里,我看到了自己的影子,这部电影让我改变了很多。以Carpe diem的理由,我开始努力摆脱曾经自己给自己定下的条条框框,去尝试更多的“不可能”。

2007年

年初,我的Mentor也许是因为偷懒,让我帮他去上原本属于他的Effective Presentation培训。没有想到,短短一天培训中学到的一些基础的演讲技能、对“紧张”的正确认识以及对演讲前充分准备的重要性的认识,就帮助我从一个不敢在公开场合发言的人,变成了一个不太惧怕公开演讲的人。培训导师通过理论和实践帮助我建立了自信。

为了开源产品中的内核模块,我深入理解了GNU工程及自由软件哲学,研究了各种自由软件/开源许可证,并开始积极参与到自由软件社区中。

为了实践Customer Insight,生平第一次出差、第一次坐飞机。我一直以为那时自己做的产品其实没有太多实用价值,当看到自己的产品在客户的环境中真正发挥着作用时,自豪感优然而生。

因为“Carpe diem”,这一年,我的行为举止发生了很多变化。发展了不少的小众爱好:暴走火车地铁无线电,并因此结交了几位挚友。自己去参加CSDN的技术大会,开始关注Web 2.0。通过开发“豆饭”,开始与互联网开发结下不解之缘。通过参与UCDChina的活动,与本地互联网公司的设计师们建立了良好的关系。我的社交圈子在这一年中,扩大了很多,并且很多时候都是我自己主动出击,这在以前,我是绝对做不到的。

2008年

由于参与开发的产品已经在前一年完成并发布,工作角色从产品开发转为产品维护。这段日子是我的工作经历中最为灰暗的日子。首先,产品维护需要在有限的时间内解决客户的问题,我在学会了“沟通技巧”和“做事方法”的同时,放弃了对技术精益求精的追求。为了降低Resolution Time,我学会了忽悠,而不是去寻找Root Cause。其次,新的Manager强调学习做事方法高于技术追求,他让我在各种挫折中自己去学会在职场中生存下来的技能,但是各种挫折不断地打击着我刚刚积累起来的一点点自信心。

然而,这种种不如意,却只是成长的代价。我这一年的技术积累没有前两年那么多,但依靠那些被逼迫着学到的职场技能,加上各种机缘巧合,反而倒奠定了我在团队中的地位。然而“捷径”只是加速达到某些目标的一种手段,真正起作用的,依然是踏踏实实的每一步。

公司开始强调文化建设,难听的说法叫“洗脑”。也许是那个时候还比较Simple和Naive,这次文化建设的培训让我受益非浅。直到现在,我依然认同那时被灌输的思想:你需要让你个人变得更加优秀,才能发挥出更大的价值。

参加了公司的Home Building Project,这是公司与一个公益组织Gawad Kalinga合作进行的公益项目,去菲律宾盖房子(这个翻译其实不对,这是Home Building,不是House Building,盖房子只是一种行为,目的是帮助那里的人们拥有更美好的家)。第一次走出国门、第一次与来自不同国家和地区的同事合作、第一次与这么多小朋友近距离接触、第一次与生活在贫穷与自然灾害面前的人们面对面交流。过去了这么多年,当初的震撼已经慢慢的淡去,但留在内心深处的东西,也许会影响一个人的一生。

Home Building Project的合影

Home Building Project的合影

那时公司组织各种活动很多,加上那段时间我自己也参与了不少线下的活动,从中自己积累和总结了很多组织线下活动的技能。3月底自告奋勇为当时的Unix-Center网站成立一周年组织了南京地区的线下活动,活动虽然规模不大,但很成功,甚至还吸引了上海、无锡等周边城市的自由软件爱好者。这次活动也为后来成立Nanjing Linux User Group埋下了种子。

Unix-Center周年庆

Unix-Center周年庆

Synology PhotoStation性能优化

我折腾过不少家用NAS方案,包括最早的WD My Book World Edition,后来的Joggler,再到后来的Raspberry Pi,这些方案除了性能存在一些问题以外,最大的缺点就是易用性存在问题,不但非“专业”人士用起来存在困难,就连我自己也对土法泡制的照片管理功能感到不满。直到两年前入了群晖(Synology)的家用NAS DS214play,事情才变得安逸起来。

平心而论,Synology的系统虽然功能强大,体验也还不错,但细节上做得其实真是挺糙的。最近就发现了它的照片管理软件PhotoStation出现了严重的性能问题,在照片库里只存了8万余张照片的情况下,打开首页要花费的时间已经超过了20秒,每打开一个文件夹都需要等待10秒以上,几乎不可用了。

以下分析以DSM 6.0为例,PhotoStation版本为6.4-3166。PhotoStation的安装路径为/volume1/@appstore/PhotoStation。

Synology的DSM系统,后台使用PostgreSQL数据库,前端是PHP页面。简单推理一下就可以知道,PhotoStation的性能瓶颈主要是在对照片索引数据库的访问上。性能调优的第一步就是先要找到哪些SQL查询占用了太多的时间。打开PostgreSQL记录SQL查询的开关,并查看所有SQL执行情况:

$ sudo su postgres
$ vi ~/postgresql.conf
log_statement = 'all'
$ psql photo postgres
# SELECT pg_reload_conf();
# \q
$ exit
$ sudo tail -f /var/log/postgresql.log

通过查看SQL执行记录,很容易发现几个明显的慢查询:

1.

SELECT COUNT(*) as total FROM photo_image; 
SELECT COUNT(*) as total FROM video;

2.

SELECT COUNT(logid), MAX(logid) FROM photo_log;

3.

SELECT * FROM (
    SELECT path AS filename, timetaken AS takendate, 
        create_time AS createdate, 'photo' AS type
    FROM photo_image
    WHERE path LIKE '/volume1/photo/%'
        AND path NOT LIKE '/volume1/photo/%/%' AND disabled = 'f'
    UNION
    SELECT path AS filename, mdate AS takendate,
        date AS createdate, 'video' AS type
    FROM video
    WHERE path LIKE '/volume1/photo/%'
        AND path NOT LIKE '/volume1/photo/%/%'
        AND disabled = 'f'
    ) AS totalCount; 

4.

SELECT path, resolutionx, resolutiony, version FROM (
    SELECT path, resolutionx, resolutiony, version,
        create_time, privilege_shareid, disabled
    FROM photo_image WHERE privilege_shareid IN (
        SELECT shareid FROM photo_share WHERE ref_shareid = (
            SELECT shareid FROM photo_share WHERE sharename = '2016'))
    AND disabled = 'f'
    UNION ALL
    SELECT path, resolutionx, resolutiony, 0 AS version, 
        date AS create_time, privilege_shareid, disabled
    FROM video WHERE privilege_shareid IN (
        SELECT shareid FROM photo_share WHERE ref_shareid = (
            SELECT shareid FROM photo_share WHERE sharename = '2016'))
    AND disabled = 'f') temp
ORDER BY create_time DESC LIMIT 1; 

优化的思路很简单,由于PhotoStation在正常情况下访问数据库所需要的读性能是远远大于写性能的,所以就通过牺牲写性能来逐一击破上面这些慢查询:

1. 程序的目的就是想知道系统中有多少张照片和多少个视频(而且其实并不需要精确知道,差不多就行),可惜对于PostgreSQL来说,由于它采用MVCC来解决并发问题,SELECT COUNT(*)是一个需要进行全表扫描的慢操作。解决方案就是用另外用一张表来存这两个表的总记录条数,并在原表上添加触发器来更新记录数。

CREATE TABLE photo_count (table_oid Oid PRIMARY KEY, count int);
ALTER TABLE photo_count OWNER TO "PhotoStation";
CREATE FUNCTION count_increment() RETURNS TRIGGER AS $_$
BEGIN
  UPDATE photo_count SET count = count + 1 WHERE table_oid = TG_RELID;
  RETURN NEW;
END $_$ LANGUAGE 'plpgsql';
CREATE FUNCTION count_decrement() RETURNS TRIGGER AS $_$
BEGIN
  UPDATE photo_count SET count = count - 1  WHERE table_oid = TG_RELID;
  RETURN NEW;
END $_$ LANGUAGE 'plpgsql';
CREATE TRIGGER photo_image_increment_trig AFTER INSERT ON photo_image 
  FOR EACH ROW EXECUTE PROCEDURE count_increment();
CREATE TRIGGER photo_image_decrement_trig AFTER DELETE ON photo_image 
  FOR EACH ROW EXECUTE PROCEDURE count_decrement();
CREATE TRIGGER video_increment_trig AFTER INSERT ON video 
  FOR EACH ROW EXECUTE PROCEDURE count_increment();
CREATE TRIGGER video_decrement_trig AFTER DELETE ON video 
  FOR EACH ROW EXECUTE PROCEDURE count_decrement();
INSERT INTO photo_count VALUES 
  ('photo_image'::regclass, (SELECT COUNT(*) FROM photo_count));
INSERT INTO photo_count VALUES 
  ('video'::regclass, (SELECT COUNT(*) FROM video));

然后在PHP程序中修改需要统计表数记录数的逻辑,在这里可以看到似乎同一个Session中只会查一次,但即使就是这一次,也已经慢得让人不开心了:

diff --git a/photo/include/file.php b/photo/include/file.php
index 541c5cb..7caa5de 100755
--- a/photo/include/file.php
+++ b/photo/include/file.php
@@ -536,8 +536,11 @@ class File {
 		if ($key && isset($_SESSION[SYNOPHOTO_ADMIN_USER][$key])) {
 			return $_SESSION[SYNOPHOTO_ADMIN_USER][$key];
 		}
-		$query = "SELECT count(*) as total FROM $table";
-
+		if ('photo_image' == $table || 'video' == $table) {
+			$query = "SELECT count as total FROM photo_count where table_oid='$table'::regclass";
+		} else {
+		    $query = "SELECT count(*) as total FROM $table";
+		}
 		$result = PHOTO_DB_Query($query);
 		if (!$result) {
 			// db query fail, won't update session value

2. 其实可以用跟前一个问题类似的方法去解决。但是这个其实只是一个没太多用处的操作日志表,所以我用更为简单粗暴的方法去解决这个问题:减少在数据库中保留日志的条数。直接修改PHP程序:

diff --git a/photo/include/log.php b/photo/include/log.php
index 1c982af..56385db 100644
--- a/photo/include/log.php
+++ b/photo/include/log.php
@@ -1,8 +1,8 @@
 <?php
 
 class PhotoLog {
-	const LIMIT = 100000;
-	const PURGECOUNT = 10000;
+	const LIMIT = 1000;
+	const PURGECOUNT = 100;
 	public static $SupportFormat = array("html", "csv");
 
 	public static function Debug($msg)

3. 第三个问题主要体现在对照片路径的处理上,为了选出位于某个路径下(不含子目录)的照片,程序采用了path LIKE ‘/path/%’ AND path NOT LIKE ‘/path/%/%’这样的查询条件。其实PostgreSQL在一定程度上是可以利用path字段上的索引来很好的优化这个查询的,但是实际运行中发现(通过在PostgreSQL的客户端中用explain和explain analyze分析查询)在某些情况下索引会失效,造成非常差的查询性能。解决方案还是用写性能来换读性能,先在表上加一个dirname字段并建立索引,按path算好文件所在的目录名写入dirname,然后把查询条件改为对dirname的查询,避免使用通配符和LIKE运算即可:

ALTER TABLE photo_image ADD COLUMN dirname TEXT NOT NULL DEFAULT '';
UPDATE photo_image 
    SET dirname = LEFT(path,LENGTH(path)-STRPOS(REVERSE(path),'/')+1);
ALTER TABLE video ADD COLUMN dirname TEXT NOT NULL DEFAULT '';
UPDATE video SET dirname = LEFT(path,LENGTH(path)-STRPOS(REVERSE(path),'/')+1);
CREATE INDEX dirname_index ON photo_image USING btree(dirname);
CREATE INDEX dirname_index ON video USING btree(dirname);
CREATE OR REPLACE FUNCTION set_dirname()
 RETURNS trigger
 LANGUAGE plpgsql
AS $function$
BEGIN
  NEW.dirname := LEFT(NEW.path,LENGTH(NEW.path)-STRPOS(REVERSE(NEW.path),'/')+1);
  RETURN NEW;
END $function$
CREATE TRIGGER set_dirname_trigger 
BEFORE INSERT OR UPDATE ON photo_image 
FOR EACH ROW 
  EXECUTE PROCEDURE set_dirname();
CREATE TRIGGER set_dirname_trigger 
BEFORE INSERT OR UPDATE ON video 
FOR EACH ROW 
  EXECUTE PROCEDURE set_dirname();

同时修改PHP程序:

diff --git a/photo/include/photo/synophoto_csPhotoDB.php b/photo/include/photo/synophoto_csPhotoDB.php
index ac8f932..43e58ee 100755
--- a/photo/include/photo/synophoto_csPhotoDB.php
+++ b/photo/include/photo/synophoto_csPhotoDB.php
@@ -1607,10 +1607,8 @@ class csSYNOPhotoDB {
 		} else {
 			$albumRealPath = self::EscapeLikeParam(SYNOPHOTO_SERVICE_REAL_DIR_PATH."{$albumName}/");
 		}
-		$cond = "path LIKE ? {$this->escapeStr} AND path NOT LIKE ? {$this->escapeStr} AND disabled = 'f' ";
-		array_push($pathSqlParam, "{$albumRealPath}%");
-		array_push($pathSqlParam, "{$albumRealPath}%/%");
-
+		$cond = "dirname = ? {$this->escapeStr} AND disabled = 'f' ";
+		array_push($pathSqlParam, "{$albumRealPath}");
 		if (!$removePhoto) {
 			$photoQuery = "SELECT path as filename, timetaken as takendate, create_time as createdate, 'photo' as type
 FROM photo_image WHERE $cond";
 			$sqlParam = array_merge($sqlParam, $pathSqlParam);

4. 这个查询只是为了查询一个目录及其所有子目录中最新一个照片或视频,用其缩略图来作为目录的封面图片。群晖的工程师自己也知道这个查询很慢,所以还在程序中加了个逻辑,当照片视频数量大于200000时,放弃按日期排序,直接随机选一张。然而,这个查询实际上是可以简单优化的,明明不需要把所有的照片视频UNION到一起后再找出最新的一个,可以直接分别找出最新的照片和最新的视频,然后再到这两个中去取一个相对更新的就可以了。直接修改PHP代码实现:

diff --git a/photo/include/photo/synophoto_csPhotoAlbum.php b/photo/include/photo/synophoto_csPhotoAlbum.php
index ca128f0..f0e57e7 100755
--- a/photo/include/photo/synophoto_csPhotoAlbum.php
+++ b/photo/include/photo/synophoto_csPhotoAlbum.php
@@ -145,9 +145,11 @@ class csSYNOPhotoAlbum {
 		$cond .= " AND disabled = 'f'";
 
 		$table = "(" .
-				"SELECT path, resolutionx, resolutiony, version, create_time, privilege_shareid, disabled FROM pho
to_image WHERE $cond " .
+				"(SELECT path, resolutionx, resolutiony, version, create_time, privilege_shareid, disabled FRO
M photo_image WHERE $cond " .
+				"ORDER BY create_time DESC LIMIT 1)" .
 				"UNION ALL " .
-				"SELECT path, resolutionx, resolutiony, 0 as version, date as create_time, privilege_shareid, disa
bled FROM video WHERE $cond " .
+				"(SELECT path, resolutionx, resolutiony, 0 as version, date as create_time, privilege_shareid,
 disabled FROM video WHERE $cond " .
+				"ORDER BY create_time DESC LIMIT 1)" .
 		") temp";
 
 		// this may cost lots of time, so it won't sort by create_time if the total count exceeds the threshold (200,000)

做完以上优化,我的PhotoStation已经基本可以做到点进文件夹秒开了,至少我自己已经比较满意了。声明一下,其实我并不太懂数据库相关理论和技术,以上“优化”只能说是在我自己的实验中起到了优化的效果,也许其中一些并不太科学,希望这篇文章能起到抛砖引玉的作用。

重新安装PhotoStation或升级DSM系统会造成我们对程序所作的修改丢失,所以在修改完成后,务必做好备份。

另外,适时对PostgreSQL数据库进行VACUUM操作似乎可以起到提高访问性能的目的,尤其是在做过大量照片更新后。

一个网络访问故障的排查

故障描述:

一个Python程序,在我的Macbook Air上使用Gearman库访问Gearman服务器时始终报错:
Found no valid connections in list:
[<GearmanConnection 111.111.111.111:80 connected=True>]

问题排查过程:

  1. 我的另一台电脑的Linux系统下运行这个程序是正常的,所以证明Gearman服务器本身是好的,程序也是对的。
  2. 别人的Macbook Air/Macbook Pro上运行这个程序也是正常的,系统中的Python版本是一致的,证明这个程序在Mac OS X下并不存在兼容性问题。
  3. 111.111.111.111:80这个是一个从开发环境访问生产环境服务器的虚拟IP/端口,把Gearman服务安装到开发环境网络中222.222.222.222:4730,程序运行正常。
  4. 由于访问开发环境正常、访问生产环境异常,所以怀疑是网络原因。使用ping/traceroute命令访问111.111.111.111,都正常。用telnet 111.111.111.111 80尝试TCP连接,也正常。
  5. 在无线/有线之间切换网络物理连接方式,测试结果一样:本机与目标服务器之间网络通畅,TCP连接正常,但Gearman连接无法正常建立。并且同处于同一个网络中的其它电脑上运行一样程序都访问正常。
  6. 怀疑Gearman的Python库在某些特殊情况下有Bug,在里面加了很多print来打印日志,发现客户端在与服务器建立完连接后,在真正要通信时,连接又变成了断开状态。但由于不太了解Gearman库的实现细节,再要进一步Debug,存在一定困难,暂时放弃这个思路。
  7. 为了排查Gearman连接断开的原因,用Wireshark进行抓包。抓包的结果非常令人惊讶:在程序运行的整个过程中,没有抓到任何客户端与服务器之间通信的数据包。但是程序打印出来的日志却明明显示出连接是先建立再断开的。
  8. 至此,问题已经快要查清楚了:本机有个程序劫持了本机发起的网络通信,实施中间人攻击,造成Gearman连接异常。
  9. 用Python写socket程序尝试向不同目标发起各种TCP连接(后来意识到其实用telnet就可以了),同时用Wireshark抓包,发现本机发起的所有目标为80端口的连接都会被劫持。劫持的效果是如果通过80端口进行HTTP通信,劫持的程序就会充当一个代理服务器的功能,正常完成通信过程。但是一开始出问题的程序是在80端口跑Gearman的通信协议,所以这个劫持程序无法正确处理,造成了通信异常。
  10. 最后就是要找出是哪个程序实施了劫持,用了一个很土的方法:下载一个大文件,同时用netstat -na查出与服务器80端口通信的本地端口号(谁叫Mac OS X的netstat命令没有-p参数直接看是哪个进程的连接呢?),然后用sudo lsof -i :<local port>命令查出这个本地端口号的使用者。

真相大白:

Cisco AnyConnect Secure Mobility Client中自带了一个进程名为acwebsecagent的Web安全模块,这个安全模块不管VPN是否在使用,都会劫持本机所有的发往80端口的通信,具体它做了什么好事坏事就不得而知了。

找到了问题所在,网上搜一搜就能查到很多吐槽这个Web Security模块的贴子了,它会随着AnyConnect默认安装到你的电脑上(我的电脑系统是IT预装的,不然我装AnyConnect时肯定会手工把这个勾勾去掉)。解决问题的方法也很简单 ,一行命令卸载它:
sudo /opt/cisco/anyconnect/bin/websecurity_uninstall.sh

最好的简谱编辑排版软件JP-Word

本文是一则硬广告。

上大学时跟同学一起办过一份音乐小报,名为《白桦林》,虽然只办了一期就夭折了,不过在这期小报上试图排版朴树那首《白桦林》的简谱(附带吉他和弦)的经历让人难以忘怀。我那时坚信自己用Word排版复杂版面的水平已经到了出神入化的程度,但还是被这个简谱排版工作打败了。Word虽然是“所见即所得”,但是那排版好的谱子打印出来时,总是会有那么些对不齐或者走样。那时学校打印店打印一页A4纸的价格是1.5元,不舍得反复打印试验,最终还是通过剪刀胶水才把这个排版任务搞定的。

《白桦林》小报第三版。

《白桦林》小报第三版。由于Word版本和字体的原因,现在看到的跟当年排出来的效果有差别。

上大学时,经常玩一个叫HappyEO的电脑键盘模拟电子琴的软件——强势插入,再打个广告,HappyEO是用电脑键盘(也可以接MIDI键盘)模拟电子琴的最好的软件,没有之一。因为对这个软件的喜爱,我跟HappyEO的作者有着较多的交流,一路见证着HappyEO电子琴和后来的iDreamPiano模拟钢琴等软件的诞生。2003年时,他告诉我他做了一个简谱排版软件,叫JP-Word

实话说,1.0的JP-Word一点也不诱人,粗糙的界面、鸡肋的图片输出和打印功能、封闭专有的文件格式实在不能让我提起太大的兴趣。加上对于我来说,平时几乎没有简谱排版的需求,所以对这个软件并没有太高的热情。接下来两三年时间,JP-Word慢慢升级到了3.0,功能丰富了一些,但没有什么本质的变化。

一下过了十年,JP-Word的作者突然告诉我,JP-Word出了4.0的新版本。这个脱胎换骨的新版本,着实可以用“惊艳”来形容。

JP-Word 4.0界面预览

JP-Word 4.0界面预览

不像五线谱打谱有自由的Lilypond,还有Overture、Encore等强大的商业软件。市面上简谱音乐软件并不多,优秀打谱软件更是少之又少。之所以我敢在文章标题上把JP-Word说成是“最好的”,个人认为主要有以下几点:

  • 独创的的“切换音符时值组合”功能。常见音符的时值组合不用一个个手工调整,音符输入后按几下空格键就自动组合好了。
  • 强大的歌词编辑功能。支持歌词自动对位,谱打完,歌词一打上去就自动对齐到它该去的地方。
  • 支持矢量PDF的输出。而且排版结果不管是布局还是字体,都非常美观,放到专业环境下用也毫无压力。
  • 开放的JPW-ABC格式。一改以前用专有格式的方式,新版本用了开放的纯文本来描述乐谱,为二次开发提供了便利。

JP-Word简谱编辑软件不是完全免费的,免费的版本只提供了部分主要的功能集,但折腾党可以通过手工写JPW-ABC的方式实现几乎全部收费版本的功能。

缺点也是有的,比如目前不支持直接播放乐谱和MIDI导出。不过Thanks to开放的JPW-ABC格式,完全可以写个简单的脚本,把JPW-ABC文件转换为MIDI文件。我正在尝试写个简单Python脚本,把JPW-ABC转换为Lilypond格式,然后借助Lilypond,可以直接生成对应的五线谱和MIDI文件。有兴趣可以关注我的Github项目jpw2lilypond,不过目前上面还只有一个很垃圾的Prototype(对象建模建错了,虽然基本功能在,但已经没办法往下写了),而且现在每天的时间都不够用,开发进度一定不会很快。

JP-Word 4.0的功能介绍与使用说明:http://www.happyeo.com/intro_jpw.htm

下载JP-Word 4.0免费版的地址:http://www.happyeo.com/downloads_jpw.htm

注册JP-Word的方法:http://www.happyeo.com/register_jpw4.htm

实践个人网站迁移HTTPS与HTTP/2

赶个时髦,把自己的博客进行了全站HTTPS改造,并升级支持了HTTP/2,总结在此,当作备忘。

很惭愧,虽然曾经做过几年Web安全产品,其实我自己并没有非常深入的去理解和思考Web安全更多内在的东西,所以可能文中的部分描述并不完全准确。很多内容参考了Jerry Qu的博客上的内容,都以参考文献的方式列在文章中,我这里只写结论,技术细节可以参考他的原文。

动因

HTTPS改造的好处当然是更安全。虽然对于一个博客网站来说,“安全”似乎并不是一个非常重要的因素,但是以国内现实的情况来说,使用HTTPS提供网站服务有一个好处就是可以避免网络运营商篡改网页内容(比如插入弹出广告)——其实吧,HTTPS以后,Chrome浏览器地址栏显示的绿色小锁才是吸引我迁移的真正原因,挺好看的。

HTTP/2从协议层面消除了传统HTTP的一些不足和缺陷,对我来说,直接的好处就是可以大幅度提高网页载入的速度。有关HTTP/2的前世今生,可以参考以下文章[1]。

HTTPS改造

证书

HTTPS改造的一个基本要素就是证书,在传统上有很多认证机构(CA)可以收费签发证书,比如大名鼎鼎的Verisign。现在也有很多公司可以提供免费或者廉价的证书,比如有名的StartSSL,以及最近很火的Let’s Encrypt

我先是尝试了StartSSL的免费证书,但是它只能签发有效期一年的免费证书,每年都得手动去更新证书是一件很让人头痛的事情。所以后来选定了使用Let’s Encrypt,虽然Let’s Encrypt的证书有效期只有三个月,但是可以方便的通过脚本来实现自动更新。

使用Let’s Encrypt的证书有两种方式,一种是使用他的提供的工具脚本,另一种是使用ACME协议。我目前使用的是ACME协议方式,参考[2]。如果用Let’s Encrypt的工具,参考[3]。我个人比较喜欢ACME协议方式,因为很轻量级,Let’s Encrypt自己的工具太过“全家桶”了,不够简洁明了。

主要的步骤如下:

  1. 生成一个帐号私钥
    $ openssl genrsa 4096 > account.key
  2. 生成一个域名私钥
    $ openssl genrsa 4096 > domain.key
  3. 生成证书签名请求CSR文件,通常至少包含祼域名和带www主机名的两个域名。
    $ openssl req -new -sha256 -key domain.key -subj "/"
        -reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(printf
        "[SAN]\nsubjectAltName=DNS:yoursite.com,DNS:www.yoursite.com")) >
        domain.csr
  4. 在自己网站上配置一个可以从外部访问的目录,用来完成challenge。Let’s Encrypt会生成一个文件,你把它放在这个目录里,然后Let’s Encrypt如果能访问到这个文件,就证明了这个域名是属于你的。nginx配置类似于如下,配在80端口的Server里面:
    location ^~ /.well-known/acme-challenge/
    {
        alias /home/xxx/www/challenges/;
        try_files $uri =404;
    }
  5. 下载acme_tiny脚本,并运行,里面用到了帐号私钥(account.key)、域名私钥(domain.key)、CSR文件(domain.csr)和ACME challenge的路径,生成签发的证书(signed.crt)。
    $ wget https://raw.githubusercontent.com/diafygi/acme-tiny/master/acme_tiny.py
    $ python acme_tiny.py --account-key ./account.key --csr ./domain.csr
        --acme-dir /home/xxx/www/challenges/ > ./signed.crt
  6. 最后合并Let’s Encrypt的中间证书和我们自己的证书:
    $ wget -O - https://letsencrypt.org/certs/lets-encrypt-x1-cross-signed.pem
        > intermediate.pem
    $ cat signed.crt intermediate.pem > chained.pem

Web服务器

我使用nginx作为Web服务器,启用HTTPS服务,只需要在原来的HTTP服务上加几行配置就可以了:

listen 443 ssl;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:
  EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:
  EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_certificate /home/user/path/to/your/chained.pem;
ssl_certificate_key /home/user/path/to/your/domain.key;
ssl_session_timeout 5m;

其中需要用到你自己网站的私钥domain.key,还有Let’s Encrypt给你签发并合并了中间证书的证书文件chained.pem。

SSL协议版本与加密算法

我上面的配置用的是CloudFlare推荐的配置,详细的讨论可以参考[4]。我这么配基本上放弃了对Windows XP + IE6的支持,但可以让Qualys SSL Labs给出的评分提高到A级。

WordPress

我的博客是用WordPress构建的,WordPress在很多地方都会把带协议的网站URL给存下来,造成无法透明的把HTTP改成HTTPS。暴力解决方法就是去它的数据库做全文替换[5]。

先把WordPress设置->常规中的两个URL设置改为https,然后去WordPress的数据库对已有的文章进行全局字符串替换:

UPDATE `<wordpress_prefix>_posts` SET post_content=
    (REPLACE (post_content, 'http://[domain name]',
    'https://[domain name]'));

其它页面嵌入内容

如果在HTTPS的页面上嵌入了非HTTPS的内容,比如跨站的HTTP图片,浏览器上的绿色小锁就会变成灰色了。如果是跨站的CSS或者JavaScript,在现代浏览器上可能会直接被禁止加载[6]。

解决的方案就是把src的协议去掉,直接写成//domain.com/path/to/image这样的形式,可以兼容HTTP和HTTPS。

但是有些源站根本就不支持HTTPS,或者虽然提供了HTTPS服务,但证书不合法,这么做就行不通了。比如我的页面右侧的饭否的图片,虽然饭否有HTTPS服务,但证书过期了,直接嵌入就会有问题。

我的解决方案也是简单粗暴的,直接在nginx里为这些网站相关URL做一个反向代理。比如,为了解决饭否图片的问题,我在我的nginx里加了以下配置,然后把饭否图片的域名换成我自己的域名:

location /u {
    proxy_pass http://b.fanfou.com;
    proxy_set_header Host b.fanfou.com;
    proxy_redirect off;
}

自动重定向HTTP请求

至此,HTTPS改造已经准备好了,重启nginx后就可以用HTTPS协议来访问网站,检查是否工作正常。主要检查证书是不是正常,另外还有看看有没有混杂非HTTPS资源造成页面加载不正常的。Chrome的Developer Tools可以帮助你排查这些问题。

如果一切正常,就可以考虑自动重定向所有的HTTP请求了,301跳转通常是最理想的方式。在nginx的80端口http服务器配置中添加以下的内容:

location / {
    return 301 https://$server_name$request_uri;
}

其它要考虑的问题

SNI:如果在nginx用server_name实现了单主机的多虚拟站点,那就会出现一个IP地址上对应多个域名的情况,这时服务器和客户端都需要支持SNI,才能在HTTPS的情况下正常工作。较新版本的nginx版本服务器都是支持SNI的,但IE6之类的老旧浏览器不支持。所以如果放弃老旧浏览器支持的话,SNI不是个问题。否则就只能保证在同一个IP上只启用一个域名的HTTPS网站,才能确保客户访问无障碍。

HSTS:虽然启用了HTTPS,但是用户在访问时如果没有显式输入https协议,现在的浏览器默认还是会先选用http协议,再由服务器进行301/302跳转,这时就有可能被劫持。目前的解决方案是在自己的网站上输出HSTS头,并把自己的域名加入HSTS Preload List列表里[6]。我没有启用HSTS,因为一但启用没办法撤销,万一以后不能提供HTTPS服务了,想降级为HTTP都没有机会了。

其它:对于个人小网站来说,也许前面的讨论基本够用。但对稍大的网站来说,要考虑的问题还有更多。比如:CDN支持、各子域名证书的管理模式、SHA1不安全证书与老版本浏览器兼容性、大量非HTTPS外部资源的处理、特殊客户端不支持302引流等等。

HTTP/2改造

新版的nginx已经内置了对HTTP/2协议的支持,所以完成HTTPS改造后,启用HTTP/2支持是一件相对来说比较简单的事情。

在nginx里HTTPS的listen配置中加入http2即可,当然不要忘记重新reload nginx:

listen 443 ssl http2;

在Chrome中可以安装一个HTTP/2 and SPDY indicator插件,这时打开HTTP/2网站时,地址栏右侧就会出现一个蓝色的闪电标记,证明这个网站已经支持了HTTP/2协议。

新版的curl也可以用来帮助检查HTTP/2是否工作正常,在curl网站URL时,加上–http2参数即可。别忘了,必须是用HTTPS协议才能支持HTTP/2哦。

说得这么轻巧,事实上很多系统中的nginx都不是可以支持HTTP/2的新版本,所以还需要手动编译新版的nginx。

nginx -V可以列出当前nginx编译时使用的configure参数,可以作为重新编译时的参考,在它列出的参数的基础上加上–with-http_v2_module参数,就可以启用HTTP/2功能了。

参考文献

[1] 凯文叔叔的网志 – HTTP/2

[2] Let’s Encrypt,免费好用的 HTTPS 证书

[3] Let’s Encrypt SSL证书配置

[4] 关于启用 HTTPS 的一些经验分享(二)

[5] 迁移 WordPress 博客至 HTTPS

[6] 关于启用 HTTPS 的一些经验分享(一)