我为小说忙:(五)插件的上线工作
终于到收尾工作了,先给 TPNovel 的开发过程暂时画一个句号。
其实我挺希望真的就此全部完事了,不过很难否认的一点是,还没上生产之前确实不知道会出什么幺蛾子,以及会少什么实用功能。因此也只能说目前暂时收个尾,日后有需要再逐步改动吧。
依然是图文无关的一天1. 前言
从七月份初开始计划这个项目,到现在八月初,正好过去了一个月。
这一个月里当然不是天天写代码,中途也干点别的事情。不过即便如此,开发进度也是没落下的,而且老实说,难度比我想的还要大。原本以为自己面对的只是三张表,以及前端展示页面,后端CRUD管理页面罢了。然而真正动起手来,很自然就会发现缺这缺那,这里缺个小功能,那里缺个过滤器,什么地方又得分两种方法处理数据,补上这些代码还要跑测试,于是大部分时间就耗在这些不得不实现的零碎细节上了。
磨洋工也好,紧赶慢赶也好,这个插件终于算是写完了。
好耶。(棒读)
2. 补齐的小功能
当然,上线之前,得专门再抽点时间,过一遍这些小东西。
大多数细节就不说了,这里就记录一些比较明显的功能。
2.1 字数统计算法
字数统计,好像很简单,甚至可能是某些编程课里面的入门教学项目之一。比方说SQL,现在我们的小说文本都存入数据库了,想要知道字数,只需要运行CHAR_LENGTH(),就可以统计出来这个记录的字数,而且还自动帮你处理好多字节字符的问题,确实是很方便了。
然而,如果落实到我们的项目里,事情就变得微妙起来。算字数简单,问题是,什么时候算,算了之后放到哪里,以及还有个很坑人的点:字数减少怎么办。
首先你写完一章,肯定要算一次吧,算好之后放入数据库chapter表的wordCount字段,然后给book表的wordCount字段也加上,没什么好说的。然后到编辑,肯定也要再算一次并更新,毕竟字数可能有加有减,但book表的字段怎么办?如果不想触发全表扫描去计算各章字数的SUM(),此时就要你手动计算和原来字数的差值了。完了之后,删除章,肯定要扣减字数,删除卷,是不是也要呢?再接着,如果是在后台通过手动指定现有cid的方式来添加某一章,那又要怎么处理呢?
诸位看官莫要见笑,笔者确实没有太多的实操经验,只能边干边学了。所以这几天,折在字数计算上的时间占比很大,需要考虑到几个不同的场景,以及是否可能与原来的逻辑有冲突(比如说,数据库出错怎么办)。限于篇幅,这里依然是不放全部代码了,只讲两个比较关键的例子。
先说编辑。当插件识别到要编辑的时候,字数计算会交给tpnUtils里的一个专用方法:
// 已经去掉了Type Hinting,但还是很长
public static function updateChapterLength($db, $bookID, $chapterID) {}这个函数要做的事情,说起来也很简单:
- 获取Typecho的内容表里,当前章节的新字数
- 获取章节表里,当前记录的字数
- 更新章节表的字数为新字数
- 计算与当前记录的差值,去更新书本总字数
这里计算差值再加减,而不是直接对章节表里符合条件的记录再求一次和,主要还是为了避免在高频操作(添加章,编辑章,甚至是删除章)的情况下对全表进行扫描,多少提高一些性能(虽然我也不知道到底提高多少就是了)。当然另一方面,对于低频操作,比如说删除整卷,为了减少复杂度,规避可能存在的一些小bug,跑一次全表扫描可能是性价比更高的选择。
第二个关键例子,就是同为低频操作的字数校正。因为对上面的那个计算差值的方法还是有些不放心,搞不好什么时候就出错了,因此特地还设计了一个字数校正的接口,在有需要的时候手动触发。基本上就是分两步操作:
- 读取并计算每一章的字数,更新章表的计数器
- 对章表的计数器,按书分组,进行求和
既然是校正,最好别出错,那就写成事务比较好:
BEGIN;
UPDATE typecho_tpnovel_chapters AS c
JOIN typecho_contents AS ct
ON ct.cid = c.cid
SET c.wordCount = CHAR_LENGTH(ct.`text`)
WHERE c.bookID = ?; --- where 字段可选,这样就可以单本书校正
UPDATE typecho_tpnovel_books AS b
JOIN (
SELECT c.bookID, SUM(c.wordCount) AS wcs
FROM typecho_tpnovel_chapters AS c
GROUP BY c.bookID
WHERE c.bookID = ?
) AS cs
ON b.cid = cs.bookID
SET b.wordCount = cs.wcs;
COMMIT;注意这里有个坑:Typecho内置ORM,如果用的是Mysqli,其默认Query方法在底层的实现是Mysqli::query(),是不支持多语句执行的,此时必须拆分成几次调用。
执行完上面的事务后,字数就正确了。
2.2 数据完整性扫描
名字听着很高大上,但实际上只是一个简单的扫描并报告的函数而已。
public static function checkAllDatas(Db $db, mixed $data) {}目前这个函数就做了两件事:
- 是否存在孤立章节(引用不存在的卷)
- 是否存在孤立卷(引用不存在的书)
本质上都是类似这样的SQL:
SELECT EXISTS (
SELECT 1
FROM {$table}
WHERE cid = {$id}
) AS e原本预期当中,这个函数还能干很多事情,例如是否存在不合理的sort(负数,0,相同值,间隙过小),是否有缺失关键字段,是否有循环引用,等等。然而考虑了一下开发难度和耗时,算了,还是先不干了,等上线再说。
2.3 专用RSS
作为一个独立站点,RSS显然是必不可少的,供有需要的读者取用。
从技术上来说,这个不难,到这里去抄一份示例文件,按照规范来改,就能弄出来一个生成器。至于数据源,目前的计划仅仅只是获取最新更新的小说章节,还没有加上分书分卷的功能(这部分等有需求了再补上),所以SQL也写得很简单,就是获取最新更新的10个章节而已。
-- 取前215,是因为后面要去掉 <!--markdown--> 标签
SELECT
b.`name`, ct.title, LEFT(ct.`text`, 215) as `text`,
ct.slug, ct.created
FROM typecho_contents AS ct
JOIN typecho_tpnovel_chapters AS c
ON ct.cid = c.cid
JOIN typecho_tpnovel_books AS b
ON b.cid = c.bookID
ORDER BY ct.created
LIMIT 10一份合规RSS 2.0文件,本质上也是一份合规的XML文件,理论上可以用XML相关模块来生成,但这里笔者偷懒了,直接用的拼接字符串(拼接前对数据进行清理,防止XML逃逸,CDATA逃逸等)的方式来生成字符串:
<?php
$rss .= '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$rss .= '<rss version="2.0" .....>'.PHP_EOL;
// 中间省略
$rss .= '</rss>'.PHP_EOL;
?>最后不要忘了先检查XML合规性,再检查RSS 2.0合规性。检查器有很多,此处不再赘述。
检查合格2.4 博客页面小标签
因为系统依附在独立页面上,因此平常访客是看不到有什么变化的,因此在页面上找了个空闲的地方,放了一张小卡片,或者说,Widget。
侧边栏往下的地方小卡片和每日推荐一样,都是在sticky属性的div之下的,因此可以长时间驻留在视窗内,与原来的博客主题契合也比较完整。当然缺点就是,在移动端,这个卡片会被挤到页面最下方,曝光率就不够了。这个以后再想办法吧,反正现在的每日推荐就是这样子的,凑合着用呗。
关于取数据,这个也相对来说比较直观,SQL就不放了,反正就是取最新的章节信息就好。
2.5 Typecho 内核补丁
严格来说,这一步与插件本体无关,只是更好地进行编辑操作罢了。
写这一段的时候,笔者的心情很微妙,主要是想起来一件事:Bill Paul,也就是当年给RTL8139网卡写FreeBSD驱动的老哥,在驱动源码开头的注释里,把这个芯片骂了个狗血喷头,原因就是这块芯片发送端极烂,接收端更烂,DMA形同虚设,等等,于是成了江湖笑谈。然而从另一个角度看,8139是当年螃蟹把百兆以太网打入低价市场的一个重要节点,因为价格低,数不清的主板板载了8139,所以客观上也确实起到了降低百兆以太网成本的作用。
现在,面对Typecho的代码,笔者的心情其实也差不多。一方面吧,Typecho确实帮这个站一直走到今天,也确实很好用;但另一方面,有时候就令人抓狂,主要是为什么有些功能会参差不齐。就,整个开发下来,给我的感觉是透露着一种诡异感,包括上次分析的方法挂接方式也是,我不知道开发人员当时是怎么考虑的,但最后呈现出来的就是,一些功能好像特意没有实现,需要想办法去规避,或者像本节一样,动手改内核。
比方说,后台对于『独立页面』(也就是TPNovel的文章载体)的管理页,和文章管理页相比,就存在两个明显问题:
- 独立页面有『页面顺序』字段,也确实管用,但有时候,其默认顺序都是0,这就导致后台出现『后发布的文章排在新发布文章之下』的问题,而不是按主键ID/创建时间降序排列。
- 缺少分页功能。
尤其是第二个问题,考虑到小说可能上百章,不分页会很难受。查看代码发现,manage-pages.php分页按钮的那部分代码直接消失了。如果仅仅是从隔壁的文章管理页拷过来装上,一运行就会各种报错,缺这缺那的,因此还得对核心代码进行修补,才能实现分页功能。
首先,转到var/Widget/Contents/Page/Admin.php,这里控制独立页面在后台如何显示。重点来看initTreeRows(),也就是从数据库读取页面列表的方法。此处需要添加如下代码:
// 方法开头,添加此行,初始化分页代码
$this->initPage();
// 原来的代码
$select = $this->select(/* ...省略... */)
-> where(/* ...... */)
// 分号前添加此行,打开按cid倒排功能
// 解决第一个问题
-> order('table.contents.cid', Db::SORT_DESC);
// 然后添加这两行,计算总数,并选页
$this->countTotal($select);
$select = $select -> page($this->currentPage, $this->parameter->pageSize);
// 这是原来的逻辑
return $this->db->fetchAll($select);
然后打开admin/manage-pages.php,在128行附近,也就是form即将结束前,添加如下代码,显示分页控件:
<?php if ($pages->have()): ?>
<br>
<ul class="typecho-pager">
<?php $pages->pageNav(); ?>
</ul>
<?php endif; ?>对核心修改之后,独立页面的换页功能应该可以正常使用了。
2.6 代码审计
为了确保安全,搞定之后,又写了份spec,让DSv4F(最新版0731)跑了个审计。
写得HTML还像模像样有没有没审出来的,不知道,反正审出来风险都修了,主要就是一些输出的地方未安全转义,空值处理,硬编码表名,还有多字节字符的切断问题。顺便,还有审出来代码里数组键有typo的,也修了。这要是让我自己来审,估计又得审几天,看来适当使用LLM还是很不错的。
3. 代码部署
丑媳妇也得见公婆,不管怎么样都好,先部署再说。
部署本身不是难事,主要是分几个步骤:
- 给Typecho内核打补丁
- 上传插件本体和那三个前端页面(及其资源)
- 新建一个空白独立页面,slug设置为
shelf(书架页) - 测试各项功能
- 上传侧边栏卡片
第四点和四五点并没有反过来,主要是考虑到先测试基本功能,测了没问题再开放给访客使用,而那个侧边栏卡片是主要入口之一,当然是先准备好再放了。
另外,因为插件是自用,因此后台连配置页面都没有,要改什么配置就直接改代码开头的常量表。可能以后插件功能复杂了,会考虑加上配置页面吧。不过上传之前,已经把插件该配置的地方都配好了,因此并不需要太多调整的地方。
4. 写在最后
插件安装完成,之前打入的部分章节也已经装入(就是笔者高中时候写的纸质版,承诺了许久要电子化的《触碰:晨晓之源》)来作为测试,这就算是正式上线了,可以到此处查看效果。
考虑到电子化要点时间,再加上目前正在写的《如果瓷杯从未冷透》也要花时间,以后,小说就和博客一起慢慢更新吧。
感谢您的阅读。
(完)