分享一下百步梯生日邮件发送系统的设计与实现

文章目录

不,这不是一个大作业的标题。

Background

百步梯有一个专属的人员管理系统,里面存着组织内所有人自己填写的个人信息,包括生日数据。大概在蛮久之前,我就有在想能不能做个生日祝福之类的功能每天自动发送给当天生日的同学,然后我就写出来了(没有)在暑假的时候让部门的设计小姐姐去做个贺卡的底图,同时我去想了想具体实现方案并做了些 Demo,最后在七月底的时候用了两三天把它写完,然后测试 & 修修补补在 07.31 正式部署上百步梯的服务器。

然而尴尬的是当晚查日志发现那一天并没有人生日(눈‸눈),我以为是查询语句写得有问题但是手动查了一遍发现确实没有……不过第二天总算还是完全正常运行的,并且因为配置的是每月 1 号给所有部长发送本部门的当月生日信息提醒,虽然已经充分测试过,但是看着两类邮件总共几十封发出去都没有出现问题还是很爽的,没错我对自己的要求已经变得这么低了

因为写这个系统的时候还有别的事在忙,时间有限,肯定也还有蛮多写得不太好的地方,不过暂时不打算更新了,毕竟人员管理系统已经用了五年多,急需换代,如果我能在明年换届退休之前重写出来的话,打算把这个直接整合进去作为新系统的一部分(突然立 flag

下面来介绍一下这个功能的具体实现过程,部分踩过的坑以及棘手的问题就不详细讲了,它们的解决方案来源也都放在对应代码文件的开头部分,主要都来自 StackOverflow 和 PHP 官方文档,这篇文章侧重讲贺卡图片的生成和邮件发送方面的内容。

项目的 Repo 在 GitHub,里面有给还算看得过去的详细的简介和文档,以及可以在 Sample 文件夹里看到使用测试数据生成的贺卡图片样本,按照艺术字体是否支持、姓名文本是否带有参考线分为四个子文件夹。这篇文章里面所有的演示用代码片段都经过完整测试放在了 这里

首先需要考虑的问题

生日数据获取

作为一个额外添加的功能,加上考虑到人员管理系统项目的重写快提上日程了,当然是单独配置作为一个新项目部署了。换届以来经历各种操作之后,对它的数据库结构也已经是很清楚了,于是就给配了一个只有读取权限的用户,所有需要的数据查询和排序逻辑都是写的 Raw SQL,也不多,全部加起来才一百行左右的查询代码

这个里面比较骚的事情是数据库里存着一些测试用户,乱七八糟的用户名,为了不影响图片生成我得手动找到这些数据然后在查询语句里面过滤掉。后来还给自己加了点戏,仅给未毕业(按照 07-01 毕业来计算)的 BBTer 生成贺卡并发送邮件,于是查询代码稍微又长了一点点。

贺卡发送形式

邮件发送是不用说的,但是具体是用哪种方式来发送还确实是考虑了一会,如果只发个大家都一样的贺卡的话太没意思了而且也没什么技术含量,所以肯定要让名字出现在邮件内容里面。

我们知道邮件可以直接发纯文本,也可以在里面使用一部分 HTML 标签。最开始的时候我是想用 HTML 的排版方式来形成一个贺卡的样子,后来感觉这样的排版风险有点大,毕竟各个邮件客户端对 HTML 的支持程度是不一样的,到时候还得在用新 API 和保证兼容性之间权衡,而且碰上一些奇奇怪怪的排版问题的话,因为整个内容是在别人的客户端上,很可能没测试到就导致一批人的显示出现问题。

然后我就想了下能不能直接把名字做进图片里面,整个贺卡作为一张图片,再配合一小部分通用的样式来进行布局让整体看起来美观一点,找了找发现 PHP 有直接的这类库可以用,这回稳了.jpg

测试用例

生日贺卡里能体现唯一性的就是自己的名字了,所以它有着特殊的地位,处理的时候要仔细考虑各种可能出现的情况。最常见的就是两个字和三个字的名字了,但是毕竟我们得保证在其它情况下系统也能正常运行。查询了一下线上数据库,按长度正序倒序排列都看了一遍,发现情况也不是很多,二三四五个字的有,超过五个的也有,还有一长串的中间带个 · 的那种,既然涵盖了所有可能情况,测试用例都覆盖到的话应该就不会有什么问题。

另一个容易被忽视的问题就是,一些字体,尤其是特殊字体,支持的文字数量是有限的,碰到这种情况如果强行用不支持它的字体生成的话会导致那个位置一片空白(看具体处理机制也可能是一个带叉的方框),所以需要对这些不支持的情况做个 CSS 对字体的 fallback 系统类似的考虑。

下文中用“章保滑”模拟常见的三个字的姓名用于测试,毕竟太膜法的名字也不敢用来当测试数据对吧

写个 Demo

考虑好上面这些东西之后,确定核心的几个需求能实现就可以正式开工了,这时候设计稿也给到了我,可以写个 Demo 出来试试看:

header('Content-type: image/png');

$image = imagecreatefrompng('card.png');
$color = imagecolorallocate($image, 144, 139, 134);
$font = getcwd() . '/name.ttf';

$name = "章保滑";

imagettftext($image, 70, 0, 637, 165, $color, $font, $name);  
imagepng($image);  
imagedestroy($image);  

同文件夹内要准备好底图和字体,70 是字号,(637, 165) 是文字框的左下角坐标,(144, 139, 134) 是用 QQ 截图工具得到的文字颜色的 RGB 值。在浏览器访问这个 PHP 文件就能得到加上姓名的效果图了:

card-demo

哦哟不错哦(主要还是设计小姐姐优秀)。

字体支持问题

问题简述

接下来解决一下刚刚谈到的艺术字体不支持显示的姓名的问题,因为字体文件这个东西,可以简单认为是给每一个编码的文字都对应设计的一个在计算机上显示的方式,而每个文字是有它自己的唯一编码的,那我们很容易想到如果出现了一个编码的文字,而这个字体并没有设计它如何显示的话,会被怎样处理。再稍微详细一点的相关知识,可以参考 中文字体网页开发指南

高中的时候我时不时会主动帮忙排版一些东西,班上有同学的字是不太常用的那种,然后很多时候为了效果我都会用上特殊字体,所以如果是要用上全班名单的什么文件的话,为了保证整体的统一,可选的字体就那么几种了。当然有时候会先用上艺术字体然后给那个字单独调整字体和大小,让它看起来和旁边的字没有太明显区别就行了。

关于这个字体支持问题,还有个题外话,就是之前有人提议一个字体如果碰到不支持的文字就直接给显示成带叉的方块,这样可以给排版的人一个提醒,因为他发现如果默认使用字体回落的方式的话,会导致带有不常见字的句子同时被多种字体应用,排出来的会是一行千奇百怪高低不平的字,这让他感到非常难受。

在翻数据库的时候我也意外发现了一个 “堃” 字是不被这次用的艺术字体支持的,所以可以用它来做测试样例,尝试实现一个自动字体回落的功能。不过其实它还不是特别不常见,我见过的最不被特殊字体支持的字是 “隺” 字,没错就是我刚刚提到的高中同学的名字里的那个字

chat

在这里感谢这位同学的友情出场(

解决方案

那我当然是不可能自己想出来的,不过明显这么常见的问题搜一下就知道了。前面说过链接都在各个代码文件的开头所以这里就不给啦,主要的思路是提取出字体文件的相关信息,然后计算一个字符的码位(Code point),再从字体文件的信息里判断出这个码位是不是被它支持。

获取码位的代码是下面这样的,我根据评论区的一个提示稍微修改了一下让它能支持的范围广一点:

private function ord_utf8($c) {  
    $byte0 = ord(substr($c, 0));
    if ($byte0 < 0x80) return $byte0;

    $byte1 = ord(substr($c, 1));
    if ($byte0 < 0xE0) return (($byte0 & 0x1F) << 6) + ($byte1 & 0x3F);

    $byte2 = ord(substr($c, 2));
    return (($byte0 & 0x0F) << 12) + (($byte1 & 0x3F) << 6) + ($byte2 & 0x3F);
}

入参 $c 就是单个的字符了,还有因为名字里面不会出现 emoji 所以这里的 ord_utf8 是没有考虑四字节的 UTF-8 字符的情况的,字体一般也不会去单独给 emoji 做设计,主要是那个解决方案没写而我又自己编不出来

提取字体文件的信息的操作比较复杂,所以直接用的一个叫 Fontlib 的库。通过字体文件的信息判断一个字符是不是被这个字体支持的代码是这样的:

private function charInFont($char, $font) {  
    $subtable = null;
    foreach($font->getData('cmap', 'subtables') as $_subtable) {
        if ($_subtable['platformID'] == 3
            && $_subtable['platformSpecificID'] == 1) {
            $subtable = $_subtable;
            break;
        }
    }

    $ord = $this->ord_utf8($char);
    if (isset($subtable['glyphIndexArray'][$ord])) return true;
    return false;
}

秉承着作为一个辣鸡的“能用就行”的信念我就没去深究他是怎么做的了,毕竟看了也搞不懂啊

can-use-just-ok

入参 $char 是单个的字符,$font 由调用 charInFont 的数组遍历部分传入。剩下的问题就是要把一个姓名分割成其各个单字符组成的数组,这个毕竟是 UTF-8 编码的多字节字符组成的串,不能直接用切割一般字符串的方法,不过很快就找到个操作,再整合一下把字体转为所需的库的 Font 对象的操作以及遍历数组各字符进行判断的逻辑,用于判断整个姓名字符串是否被一个字体文件支持的代码就有了:

public function isStringValid($str, $font_path) {  
    $font = Font::load($font_path);
    if ($font instanceof Collection) {
        $font = $font->getFont(0);
    }

    $chars = preg_split('//u', $str, null, PREG_SPLIT_NO_EMPTY);
    foreach ($chars as $char) {
        if (!$this->charInFont($char, $font)) return false;
    }
    return true;
}

这里用到的两个第三方类分别是 Fontlib 库里面的 FontLib\FontFontLib\TrueType\Collection,两个传入参数分别是姓名和字体文件的路径。

单元测试

继续之前当然是要先测试一下这个东西写得有没有问题,只要 xjb 构造那么十几二十个样例姓名,然后遍历跑一遍就可以了,为了稳一点我们准备个比较通用的字体文件同样跑一遍然后对比一下,结果参考 Sample/CLITest_result.txt,可以看到是完全符合预期的,爽。

字体大小

因为给到用来填名字的就一个框,可是各种名字的长短不同,全部统一的字体大小肯定效果不好。我也没想到什么一劳永逸的解决办法,因为总的情况也不多,就一个个肉眼观察看起来最舒服的大小然后手动记录了 2333:

public function getSize($text) {  
    $fontSize = [
        87,   // == 2 characters                   => '李华'
        70,   // == 3 characters                   => '章保滑'
        63,   // == 4 characters                   => '章保滑华'
        52,   // == 5 characters                   => '恶魔喵喵喵'
        35,   // > 5 characters                    => '恶魔喵喵喵喵'
        43    // characters with interval          => '尼古拉斯·章保滑'
    ];

    return $fontSize[$this->parseIndex($text)];
}

(不要在意注释里奇奇怪怪的示例

当然如果是艺术字体不支持的名字导致要用备用字体的话,所有的大小就得重新调了,最后的字号总量是 6 * 2 = 12 个。parseIndex() 是获取一个名字应该用哪个字号的方法:

public function parseIndex($text) {  
    if (strpos($text, '·') !== false) return 5;    // characters with interval

    $len = $this->charLen($text);
    if ($len < 6) return $len - 2;                 // [2, 5] characters
    return 4;                                      // > 5 characters
}

如果名字带点的话直接返回最后一个字号,后面处理的时候会把点号换成换行符然后让点号前后的字分两行排。其它的就是直接返回长度了,长度大于 5 的就给个比较小的字号直接往左排起。charLen() 用的是和上面分割姓名为数组同样的实现方法:

public function charLen($text) {  
    return count(preg_split('//u', $text, null, PREG_SPLIT_NO_EMPTY));
}

这两个函数的测试结果同样在 Sample/CLITest_result.txt,稳的。

居中对齐

既然姓名是放在一个框里面的,而各个姓名的字号大小又不一样,如果都是往左开始排的话难免会有一些难看,所以就要想想能不能让姓名在这个给定范围的框里面居中对齐了。

最开始我用的是和字号一样的一个个手动调的方式记录下一个数组然后去分析当前姓名适用哪种情况,但是写起来感觉相当吔。后来发现有个 imagettfbbox 函数,能用和我们写姓名到图片上的 imagettftext 函数一样的参数得到这部分文字的边角坐标值,于是用这个得到的文字宽高值和允许放姓名的外框的值就可以计算出文字相对框边界的位置,让它水平和竖直都居中。

不过这个实现是有点问题的,后面“细节还是很重要”一节会详谈。

绘制参考线

接下来画几条参考线来看看文字是不是确实如我们所想的居中在外框里,并且内容和原有的文字在同一个高度。为了不占太多空间这里就把参数表里面的换行去掉了:

// Outer Rectangle
imagerectangle($image, TextBox::$RECT['left'], TextBox::$RECT['top'], TextBox::$RECT['right'], TextBox::$RECT['bottom'], $color);

// Inner Rectangle
imagerectangle($image, $bounding['upper_left_x'], $bounding['upper_left_y'], $bounding['lower_right_x'], bounding['lower_right_y'], $color);

// Text Height Lines
imageline($image, 000, $bounding['upper_left_y'], 950, $bounding['upper_left_y'], $color);  
imageline($image, 000, $bounding['lower_right_y'], 950, $bounding['lower_right_y'], $color);  

那个不太正确的计算文字边角坐标位置的代码就不放了,如果想要的话可以在 Commit 历史 里面拿到,不过要注意两边一个是算的左上和右下,另一个是左下和右上,记得修改一下对应的索引值。

加上参考线之后画出来的结果是这样的:

ref-line

哦豁,完蛋.jpg

细节还是很重要

追求卓越,幸福人生。

解决问题

参考线一画出来就发现文字和实际的位置差了一点点,虽然肉眼看可能不太会发现,但是这个细节既然发现了还是要去解决一下的,这个问题的解决算是我在这个系统的实现过程中耗时最多的一部分了。

在我搜索的过程中发现有人和我碰到过一样的问题,就在 imagettfbbox 函数官方文档下面的 讨论区 里,另外还有一个 Stack Overflow 的 提问 也是类似的情况。经过了解整理和各种测试验证,最后明白问题出现的原因是这样的:文字本身的定位并不是从它的左下角开始,而是有一条略高于底部线的基线 baseline,这个函数用的计算方式就是基于这个 baseline 的,但是绘制文字用的 imagetftext 却是用底部线作为起点的,这就导致了文字位置的微小偏移。比如我们测试一下:

print_r(imagettfbbox(70, 0, $font, $name));  
// Array ( [0] => 9 [1] => 5 [2] => 218 [3] => 5 [4] => 218 [5] => -65 [6] => 9 [7] => -65 )

给出的边角定位点坐标是:

$$ \begin{matrix} (9, 5) & (218, 5) \\ (9, -65) & (218, -65) \end{matrix} $$

可以看到边界并不是从零开始,而是往另一个方向延申了一段,也就是它计算得到的值并不是相对边界的距离而是相对基线的距离,实际上这个情况下 \(y = 0\) 就是刚刚说的基线了,水平方向的位移也是类似的问题。我们用个不带底图的方式再实验一下(baseline.php):

baseline-demo-1

baseline-demo-2

下方很明显被截掉了一小段,可以推测出现在的底部线就是它原来基线的位置,为了让问题更明显一点,我们换用等线字体尝试一下 ABCfpqnyg 这串字母会被绘制成什么样:

baseline-demo-3

英文字母四线三格的最后一格在底部被截断了,这就很明显是因为它的基线是倒数第二行线,所以基线下的内容都会因为被当作超出了边框范围。

解决方法其实就是把这个偏差值给加回来,imagettfbbox 给出的相对高度值还是没问题的,只是它的坐标往两边都有延申导致了绘制时有一边会被忽略掉,毕竟一般默认的绘制起始位置还是 \((0, 0)\) 的。应用到这个项目代码里面,还需要一些修改,因为并不是所有的文字都是完全居中对齐的,竖直方向上当然是要让中线和底图原有的文字在一个高度上,这样才不会导致看起来有高度差,但是水平方向上,如果文字多的话,应该是左边顶到边框往右边排,还有如果计算出来的文字宽度超过了给定的框的范围的话也应该用左对齐的方式而不是居中对齐。所有情况考虑进去之后,获取边界点左边的代码大概 50+ 行,所以还是不贴上来了,有兴趣的话可以自己看 TextBox.php

字体设计真的是一门艺术。关于基线和排版相关的内容,本来能讲的东西还有很多,但是因为我其实也算不上特别熟,加上文章好像也挺长了,就不献丑了,告辞。

再次检验

现在用新的边界点值重新生成一下带有参考线的贺卡看看:

prototype-with-ref-lines

这回真稳了(不是

现在的整体效果是这样的了:

prototype

所有其他长度的姓名也都有测试结果,文章开头就提到过

可以在 Sample 文件夹里看到使用测试数据生成的贺卡图片样本,按照艺术字体是否支持、姓名文本是否带有参考线分为四个子文件夹。

到这里,生日贺卡生成的问题就算是全部解决了,整理一下代码然后只对外开放一个输入姓名的接口,就可以准备和邮件发送模块对接了。

邮件发送模块

工具准备

首先要决定用什么工具来发送,自己写是不可能自己写的,这辈子都不可能自己写的,只能用用别人的东西做个 API Caller 假装是个程序员才能维持得了生活这样子,为了性能和稳定性,最好还是选择使用大厂的邮件服务作为第三方发送者,然后找个广泛被使用的邮件处理库比如 PHPMailer,东拼西凑就差不多能解决邮件发送的问题了。

要想不通过邮箱服务商提供的界面来发送邮件的话,我们需要从他们那里获取一些配置需要用到的信息,比如我是用 SMTP 来发送的,那么就需要对应的SMTP 服务器、SMTP 安全协议、SMTP 服务器端口号,以及发件人的用户名、密码、地址、名称,一般为了保证账号安全,密码不会直接让用原密码,而是要求单独生成一个授权码之类的用于项目中,这些具体的值服务商都会提供,这里就没必要讲了。

图片导入形式

最后一个需要决定的事情就是贺卡图片是通过附件添加还是给个链接处理还是直接嵌入正文。在大概高二的时候,偶然看到过知乎上的这样一个问题:

电子邮箱为了安全,默认不下载图片,为什么图片会有潜在的危险呢?

一般为了安全,以及不向发送者泄露自己的用户是否已经读取、何时读取、读取时的 IP 地址等一个网络请求正常都会附带的信息,邮箱服务商是要在用户主动要求下才会去下载外链图片的,不过现在像 Gmail 之类的邮箱都已经是直接先下载到自己的服务器上了,可以很好地解决安全和信息泄露等一系列问题。因为我不知道国内这些邮箱以及各种各样的客户端对这个事情的处理态度是什么,加上平时也见过尤其是 QQ 邮箱默认是不显示图片的,所以附件添加和链接处理的想法可以被否掉了。

我们以 base64 的编码方式嵌入一张图,它就作为整个邮件内容文本的一部分了,浏览器或者其他客户端会在显示 HTML 格式的邮件内容的时候把这段编码再解码成原图片。PHPMailer 对此做了很好的接口可以直接用:

$this->mailer->addEmbeddedImage($card, 'card');
// Then use `cid:card` as value of src in HTML code

这样添加好嵌入图片的编码之后,我们就可以在邮件正文的 HTML 里面用 <img src="cid:line"> 的方式放进这个编码图片了。写好整个的 HTML 代码之后,再给不支持 HTML 渲染的邮箱客户端写点简短的纯文本生日祝福(不支持显示图片的话我也没办法不是),记得设置好 isHTML(true),应该就没什么别的邮件方面的问题要额外考虑了:

// HTML code mail body
$this->mailer->Body = $body;
// Plain text as a fallback for clients that does not support HTML render
$this->mailer->AltBody = $altbody;
// Set the default format as HTML first
$this->mailer->isHTML(true);
另一些细节

考虑一下手机端和电脑端的布局情况,为了避免图片太宽不好看,设置个最大显示宽度让它自己缩放到合适大小,这个时候还需要让图片在水平方向上居中,直接一个 margin: auto 就 OK 了。再稍微调整一下几个细节的地方,加上常规的编码和兼容性设置,最后写出的 HTML 内容也就是 $body 的值是这样的:

<meta charset="UTF-8">  
<meta http-equiv="X-UA-Compatible" content="IE=Edge">  
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">  
<div style="margin: auto; max-width: 577px;">  
    <img style="width: 100%;" src="cid:card">
    <img style="width: 100%;" src="cid:line">
    <p>生日快乐 (づ ̄ ³ ̄)づ</p>
    <p style="text-align: right;">—— 百步梯</p><br>
</div>  

额外嵌入的 line 图片是一条分割线,因为大部分邮件客户端都是白色的底色,而贺卡的底图也是白色,这样的话文字和图片的区分就不明显,看起来略有些奇怪。这段 HTML 没有完全按照规范来写,样式都是直接内联的,也没区分 head 和 body,但是对这个贺卡邮件而言已经足够了。

测试过程中发现 Gmail 会把邮件中的图片文件名显示出来(本来 base64 编码后应该是没有文件名在里面的,估计是 PHPMailer 给嵌入的图片加了个 download 属性,用文件名作为它的值?没仔细去查),所以保存生成后的图片的时候要注意一下贺卡文件命名方式。

谈及测试我要顺便吐槽一下 QQ 邮箱的电脑网页端竟然连 base64 编码的图都默认不给显示,要加进白名单才行,emmmmm……这说安全也说不通,说是为了过滤广告就更不可信了,明明我在那里收到过的所有广告邮件的图都是不用点就显示出来的,真是吔。不过手机端还是有直接显示出来的,所以就懒得理这奇葩操作了。

最后补充一下在这个系统里面日志记录的重要性,邮件发送方是用第三方提供的服务,接受方也会是各种各样的服务商,图片的生成、保存、发送等过程都有出现意外的可能,很多因素都不在这个系统的控制范围内,比如对方邮箱可能设置的拦截阈值很低导致我们发出的邮件被拒收,所以留下日志方便出现问题的时候快速定位是很有必要的。

因为图片文件本身就会一直按日期保存下来,所以我在日志里记录的是每次从数据库取出的数据,以及邮件发送成功后往日志文件末尾直接添加一行发送记录,还有出错时先在日志文件写下出错信息再给配置中的管理组每人发送一封报错邮件。

部署上线

改好数据库等配置信息,给执行操作的用户在当前目录下的写权限,先跑一遍 CLITest 确定文字处理和图片生成都没有问题后,用个样例 SQL 来执行一次贺卡发送的功能,一切正常的话就能准备正式使用了。

为了方便,整合出了两个单独的 PHP 文件,直接运行或者在浏览器访问它们都可以,一个 remind.php 用于给所有部长发送当月生日信息提醒,另一个 index.php 用于给当天生日的同学生成、保存并发送生日贺卡。不过为了防止误操作以及基于安全的考虑,不建议部署到 Web 目录下提供给浏览器访问,实在必须这样做的话务必把这两个文件名改成很长的随机字符串。我的做法是放在 home 文件夹内,直接通过命令行执行 php filename.php 来完成操作。

接下来用 crontab 设置定时任务,按照我的想法是每个月的 1 号早上 10:00 发生日信息提醒,每天 11:00 生成和发送生日贺卡,所以内容就是这样了:

0 11 * * * cd path/to/directory && php index.php  
0 10 1 * * cd path/to/directory && php remind.php  

注意 crontab 的执行不是在当前项目文件夹里的,所以要么指定好绝对路径,要么用 cd 进入项目文件夹然后再运行,个人感觉后者看起来舒服一点。对 crontab 不太熟的话可以用一下 这个在线工具,能根据输入的前五个参数的值来计算接下来的几个匹配时间,用来检验一下写出来的是不是和实际想的一样,这样有问题也能尽早发现。

最后的话

这个系统正式上线到现在已经快有三个月了,还没有出现过任何问题,超爽的

虽然部署之前已经测试过了很多次覆盖率足够高的样例,也做了一旦出错就自动给我发报错邮件的功能,但是刚开始的几天还是偶尔感觉有些担心,毕竟项目在实际生产环境上的运行是永远比想象的要复杂很多的,特别是这种每天定时用完全不同的数据执行的任务。于是会时不时登上服务器看看日志,或者进邮箱看看已发送的邮件有没有问题,几天之后也没收到报错邮件,看了看图片文件夹列表和日志也都显示每天的运行是一切正常的,于是就差不多安心了。

填完坑的现在,也就是 10 月底,系统总共发出去的邮件有几百封了,收到一些同学的感谢回信,好像也是做为开发者的一种别样快乐呢(ಡωಡ)