惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

大猫的无限游戏
大猫的无限游戏
H
Help Net Security
The Cloudflare Blog
Y
Y Combinator Blog
A
Arctic Wolf
Cyberwarzone
Cyberwarzone
G
Google Developers Blog
Recent Announcements
Recent Announcements
S
SegmentFault 最新的问题
Microsoft Security Blog
Microsoft Security Blog
WordPress大学
WordPress大学
博客园 - Franky
罗磊的独立博客
Martin Fowler
Martin Fowler
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
博客园 - 三生石上(FineUI控件)
N
News and Events Feed by Topic
F
Fortinet All Blogs
N
News | PayPal Newsroom
J
Java Code Geeks
www.infosecurity-magazine.com
www.infosecurity-magazine.com
博客园 - 【当耐特】
M
MIT News - Artificial intelligence
Google Online Security Blog
Google Online Security Blog
Recorded Future
Recorded Future
博客园 - 聂微东
S
Securelist
C
CERT Recently Published Vulnerability Notes
小众软件
小众软件
Cisco Talos Blog
Cisco Talos Blog
S
Security Affairs
NISL@THU
NISL@THU
A
About on SuperTechFans
PCI Perspectives
PCI Perspectives
N
News and Events Feed by Topic
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Jina AI
Jina AI
Microsoft Azure Blog
Microsoft Azure Blog
AWS News Blog
AWS News Blog
GbyAI
GbyAI
C
Cyber Attacks, Cyber Crime and Cyber Security
V
Vulnerabilities – Threatpost
D
Docker
P
Proofpoint News Feed
W
WeLiveSecurity
Help Net Security
Help Net Security
The GitHub Blog
The GitHub Blog
The Last Watchdog
The Last Watchdog
The Hacker News
The Hacker News
博客园 - 叶小钗

枫林灯语

我为小说忙:(二)插件的几个前端页面 - 枫林灯语 无用命途:只有 32v 的家用电? - 枫林灯语 好像...是放暑假了吧? 我为小说忙:(一)TPNovel 的规划 - 枫林灯语 无用命途:酒精,酒精膏? - 枫林灯语 JustWrite:只要写,就行了 - 枫林灯语 每日牢骚:便宜宾馆历险记 - 枫林灯语 生活碎碎念:镜花水月里的 Cosplay(续) - 枫林灯语 博客装修记:专题页导航 - 枫林灯语 Typecho 内置的反垃圾评论功能,尚能饭否? - 枫林灯语 分析 Nginx 访问日志,在里面挖点宝 - 枫林灯语 生活碎碎念:镜花水月里的 Cosplay - 枫林灯语 网站缓存好是好,权衡利弊更重要 - 枫林灯语 生活碎碎念:小城大梦(续) - 枫林灯语 谨慎处理 img 统计像素的几个问题 - 枫林灯语 别让 AI 拿走你的缩影 - 枫林灯语 泰国 245MHz:谨慎购买『红色外壳对讲机』 - 枫林灯语 430MHz 频段:网红对讲机是如何给你埋陷阱的 - 枫林灯语 巧借天工来降温:水蒸发制冷篇 - 枫林灯语 请尽量让你的域名在你手上 - 枫林灯语 生活碎碎念:小城大梦 - 枫林灯语 各色 AI 极地大冲击 - 枫林灯语 巧借天工来降温:通风冷却的那些事 - 枫林灯语 生活碎碎念:如何绘制清明拜山图 - 枫林灯语 IPv8:也许是一场迟来的梦 - 枫林灯语 别了,房间里的老空调 - 枫林灯语 房间里那台不给力的空调 - 枫林灯语 2026 年的第一场空调 - 枫林灯语 生活碎碎念:考试已毕,下大雨,木头窗户,弹珠区 - 枫林灯语 406MHz 共用对讲机 确实不收频占费 - 枫林灯语 换宽带记(下):继续的续费 - 枫林灯语 换宽带记(上):优惠套餐的困境 - 枫林灯语 请正确设置 HTML 的『页面语言』属性 - 枫林灯语 高频信标:海盗们的无线电『灯塔』 - 枫林灯语 生活碎碎念:三重因素交织的戒断反应 - 枫林灯语 谜之 450 MHz:用途到底是什么? - 枫林灯语
我为小说忙:(三)插件(读取部分) - 枫林灯语
墨枫梧桐 · 2026-07-19 · via 枫林灯语

我为小说忙:(三)插件(读取部分)

分类:编程 字数:11400 阅读:0

上次完成了前端页面部分的功能,除了部分细节(例如暂时不实现书评区)之外,前端页面已经接近完工,所以可以暂时先放到一边去了。

由于笔者实在是不擅长处理美工与CSS相关的事宜,因此还是赶紧进入后端代码的开发吧,在这里只需要和字符与数据打交道,这就到舒适区了。

图文无关,但Bro正在用C++当睡前故事图文无关,但Bro正在用C++当睡前故事

1. 前言

严格来说,TPNovel 这个项目,插件代码才是重头戏。最核心的点在于,插件需要自行实现一套类似于翻译层的东西,将用户的『上一章』、『下一章』等操作,转为对一堆近乎于无序的页面进行切换,同时还要计算各种元数据。

换句话说,从上一篇文章的实验可知,Typecho的独立页面其实并不在乎自己要显示什么,他只是负责把文本保存好并扔出来而已,至于具体怎么解释这些文本,就靠主题、插件、样式的功劳了。这种类似Headless CMS的东西,对开发来说,一方面确实是自由了,但另一方面,也意味着开发者需要想办法补上一些原本很稀松平常的逻辑,才能让他发挥出比较好的功效。

上面说了一些不明所以的话,不管了,直接开始讲正题。本章标题是『读取部分』,主要是想先处理『查数据库并展示』这部分的代码,毕竟可以先用别的工具手写一点数据进去,来把展示部分做好。至于如何往数据库插入数据,下一篇再说。

2. 如何动态扩展?

按上一篇的规矩,这里先不急写代码,先问一个问题。

我们都知道,Typecho插件被实现为一个类,类里面就有方法。如果想在主题文件里主动调用插件里的方法,除了手动实例化类之外,其实还可以把方法挂接到Widget\Archive上(官方称之为『Widget 动态扩展』)。这样做的好处是:Widget提供一个抽象层,让插件内部的方法名等东西不必硬编码于主题内(实现了类似接口的抽象),当然更重要的是,Typecho核心会把$this(也就是Widget_Archive)作为第一个参数传入,不必再自己手动获取,自己的参数就依次往后排。转到var/Typecho/Widget.php的第428行附近,可以看到里面写得很清楚了:

// $this排第一,其他依次排最后
call($method, $this, $args);

官方文档中里提到了两种挂接方式:属性扩展、方法扩展。你可能会想,对我们的插件来说,可以采用方法扩展的方式来挂接,比如说初始化,可能会写出如下代码:

// 在插件中
// 使用动态方法扩展,来挂接插件内的init方法
// init方法会返回所需要的初始化结果
Typecho_Plugin::factory('Widget\Archive')
    -> callTpnInit = ['TPNovel_Plugin', 'init'];

// 在主题中,调用方法并取返回值
$ctx = $this -> tpnInit();
echo($ctx['something']); // 可以吗?

看起来一切都很美好,但是,果真如此么?

如果你真的这么写,会发现$ctx是空的,里面什么都没有。

问题出在核心处理方法扩展的方式上。上面第一段代码并没有给齐,如果给齐了的话,很容易就能发现问题所在:

self::pluginHandle()
    ->trigger($plugged)
    ->call($method, $this, $args);

很显然,在最后一步call之后,整个语句也就执行完毕,返回值直接被丢弃,没有保存,也没有直接return。实际上,在官方文档里面给的示例写法,也不是返回值,而是在方法内直接用echo命令输出结果:

注意这里的echo语句注意这里的echo语句

我不知道官方当时实现方法挂接的时候出了什么问题,但总之就是,必须二选一:

  • 想获取返回值,用属性扩展
  • 想主动传入参数,用方法扩展

属性扩展,按照官方示例,在插件内依然要实现为方法,最后返回所需要的值。当然,此时只有一个$this会被系统作为参数传入了(就算不贴核心代码,也能看出来,根本没地方可以写入参数)。这对于TPNovel的场景来说不成问题,因为所需要的参数也只是当前页面的cid,这在$this内可以轻松获取,但对于其他的插件开发者来说,必须要注意一下这个问题。也许官方是想尽量让数据解耦,插件内与主题内的数据不要混在一起?笔者不是核心开发人员,也懒得去翻GitHub提交历史了,所以,谁知道呢。

3. 动手码字

总之,上一节里搞明白了两种扩展方法的不同之处。比较了一下两种方法之后,最后还是决定,在这里使用属性扩展的方式工作。具体来说,是要针对三个页面,设置三个初始化方法,来返回页面所需的各类数据,然后就按需取用即可。大概可以表达为如下:

<!-- 在主题中,比如说,阅读页面 -->
<!-- 就执行阅读页的初始化代码,拿到Context -->
<?php $tpnCtx = $this -> tpnReader; ?>

<!-- 然后在页面需要的地方输出 -->
<span><?= $tpnCtx['title']; ?></span>

插件需要一个基本框架,这在之前的博文里已经讲过,此处就不再重复了。

3.0 建表

一般来讲,笔者很少会写 x.0 的分节号,但这次不太行,必须插进来讲。

第一篇文章末尾提到,我们的目标是不额外建表。然而实际分析发现,这条路虽然节省了设计数据库的功夫,但却把压力转移到了程序设计上。究其原因,Typecho存储自定义字段的方式,是在数据库中建立EAV表,主要是为了符合他作为『单一内容的附加属性』的目的:

  • 可以动态增减字段
  • 极少出现跨实体查询
  • 几乎没有复杂查询

但坏处就是,一个实体(也就是EAV里的E)的所有属性被硬生生拆成了多行,这对于需要联查或排序等复杂操作显得很不友好,很容易在SQL语句里出现大量的self JOIN操作,既不美观,也存在影响性能的可能性(比如触发全表扫描)。对于小说来说,一个实体所需要的字段相对较为固定,完全可以用原生的关系型数据库来处理,所以单独建一些表,其实更有利于插件代码的编写。

// 代码都放入 activate() 里面
$db = Db::get();
$prefix = $db -> getPrefix();
$db -> query(
    "CREATE TABLE IF NOT EXISTS `{$prefix}tpnovel_books` (
        `id` INT unsigned NOT NULL AUTO_INCREMENT,
        `cid` INT unsigned NOT NULL COMMENT '对应content表的id',
        `name` VARCHAR(255) NOT NULL,
        `cover` VARCHAR(255) NOT NULL DEFAULT '',
        `tags` VARCHAR(100) NOT NULL DEFAULT '',
        `status` VARCHAR(20) NOT NULL DEFAULT '0',
        `sort` INT unsigned NOT NULL,
        `wordCount` INT unsigned NOT NULL DEFAULT 0,
        `chapterCount` INT unsigned NOT NULL DEFAULT 0,
        PRIMARY KEY (`id`),
        INDEX (`cid`),
        INDEX (`sort`)
    )
    COMMENT = 'TPNovel - 记录书本信息'
    ENGINE = InnoDB
    DEFAULT CHARSET = utf8mb4
    COLLATE = utf8mb4_unicode_ci;
");
$db -> query(
    "CREATE TABLE IF NOT EXISTS `{$prefix}tpnovel_chapters` (
        `id` INT unsigned NOT NULL AUTO_INCREMENT,
        `cid` INT unsigned NOT NULL COMMENT '对应content表的id',
        `bookID` INT unsigned NOT NULL COMMENT '填写目录页的cid',
        `volumeID` INT unsigned NOT NULL COMMENT 'volume表的id',
        `sort` INT unsigned NOT NULL,
        `wordCount` INT unsigned NOT NULL,
        PRIMARY KEY (`id`),
        INDEX (`cid`),
        INDEX (`bookID`),
        INDEX (`volumeID`),
        INDEX (`sort`)
    )
    COMMENT = 'TPNovel - 记录章节信息'
    ENGINE = InnoDB
    DEFAULT CHARSET = utf8mb4;"
);
$db -> query(
    "CREATE TABLE IF NOT EXISTS `{$prefix}tpnovel_volumes` (
        `id` INT unsigned NOT NULL AUTO_INCREMENT,
        `bookID` INT unsigned NOT NULL COMMENT '填写目录页的cid',
        `sort` INT unsigned NOT NULL,
        `title` TEXT NOT NULL,
        PRIMARY KEY (`id`),
        INDEX (`bookID`)
    )
    COMMENT = 'TPNovel - 记录卷信息'
    ENGINE = InnoDB
    DEFAULT CHARSET = utf8mb4;"
);

注意到三张表都有一个sort字段,这个下文会讲。

存储数字基本上都用无符号整形了,最大到4294967295,也就是42.9亿多一点。毕竟,里面可能的最大参数也只是字数,考虑到网文界的巅峰,著名的《宇宙巨校闪级生》也才1.7亿字,因此不必担心放不下的问题。

当然,此时单独建的表就不在Typecho的管理范围内了,需要自己写一些编辑面板或插件Hook之类的东西来填入数据,这个到后面再说吧,现在先把展示部分做出来,可以先手动往里面插一些数据。

info:自定义字段的坑

这里略微偏个题。在探索元数据存储方式的过程中,偶然发现了Typecho自定义字段的一个问题。现在已经确定是额外建表来放置一部分内容了,下面的记录对本文来说也就不再重要。不过考虑到可能有朋友需要,因此还是列出来吧。

Typecho的自定义字段,除了常见的几个类型之外,还有一个特殊的类型叫『JSON 结构』,你可能会想,用这个存储JSON数据是不是比较好呢?很可惜,并不是,而且答案正好相反:至少在Typecho 1.3.0,不要使用这个类型。

一个简单的实验:选择这个类型后,手写存入一些json数据并保存。

看起来很规整看起来很规整

重新加载编辑器,就会看到这样的情景:

可读性瞬间变为0可读性瞬间变为0

可以看到,原本的JSON数据已经被破坏了,变成了一个『字符串文本』(开头的引号是存储在字符串内部的),也就是说,需要拿这个内容过两次json_decode(),才能拿到原始数据。如果你觉得这还不够惨,没问题,在不对页面做任何修改的情况下,直接按发布键,随后重新加载编辑器再看看呢?

开发人员真的有测试过这个功能吗?开发人员真的有测试过这个功能吗?

实测再多保存几次,手头的JSON解析器就要开始报错了。

这里的问题,具体来说,发生在var/Widget/Contents/EditTrait.phpsetField()内,大约是107行的位置有如下代码:

// 如果选择了JSON类型
// 那么,就在update到数据库之前
// 不管原来是什么
// 都先过一遍 json_encode()
if ($type === 'json') {
    $value = json_encode($value);
}

我不知道写这段代码的作者的想法如何,或者说是在什么情况下写出来这段代码的。也许他原意是想通过先解码再编码(之类的)方式,来确保数据是规整的?还是一样,笔者没有去查阅提交记录什么的了,而且目前也用不上,因此这里暂时就成为一个谜吧。

3.1 目录页

这里先讲目录页,因为不必写复杂SQL查询即可直接获取元数据,正好适合作为起点。

public static function initReaderPage(Widget_Archive $archive) :array {
    // codes goes here...
}

3.1.1 基础数据

照着目录页的样子,有啥取啥:

$bookID = $archive -> cid;
$book = $db -> fetchRow($db -> query(
    $db -> select('name, cover, tags, status, wordCount, chapterCount')
    -> from('table.tpnovel_books')
    -> where('cid = ?', $bookID)
));
// tag需要单独拆一下
$tags = explode(',',
    htmlspecialchars($book['tags'])
);

3.2.2 卷-章 数据

首页上还有卷-章显示部分,这里一起读了。

『卷-章』应该合起来理解,指的是这一个树形结构。我们都知道,小说的卷-章结构应该还算是比较重要的部分,至少笔者是这样:点进去一本新小说,看一眼卷标题与章标题,就能大概知道最近在讲什么,这比简介还好使(简介存在谜语人的情况,如上一篇博文里的图片所示)。虽然笔者当时在写小说的时候并没有刻意地分卷,但章还是有的,因此干脆一起做了,方便以后扩展。

光这么说的话,这一部分好像很简单,然而仔细考虑,就会发现几个问题:

  • 卷-章参数存储在哪?
  • 每一章怎么排序?
  • 写到后面,如何插章?

第一个问题,现在是很明确了,卷有卷表,章有章表,到时候联查就行。但第二和第三个问题,比较耐人寻味。众所周知,一本小说通常会有若干卷,每一卷可能有不同标题,例如说『第一卷 巨大的阴谋』,『番外 漫无止境的八月』。这里着重提一句番外,因为番外可能出现在任何地方,比如说写完两卷后面出一个番外,也有可能整版书五六卷全写完才有一个番外,所以必须有一定的插入编辑能力,以及重排列能力。

所以,在设计数据库的时候,多加了一个sort字段,在任何需要排序的地方,都通过这个sort来进行,而非通过发布时间或主键ID来排序,这样方便后续调整,不必动主键或其他业务字段。另外,sort的取值并非是1、2、3....,而是10、20、30....这样有间隔的。如果有读者使用过BASIC编程语言的话,应该知道这里的目的何在了:留出空间来供插入编辑使用。当然,如果空间全部用完了,用脚本做一次重编号,就又有新空间了。

所以,读取卷-章信息,直接查数据库就好。先查卷信息,接着查询每一卷下面有什么章节。有的朋友可能会首先想到,结合上面取卷语句,用JOIN联查可否?从技术上当然是没有问题的,但问题是,这时候查出来的记录里,卷标题会重复很多次:

这里示例三次这里示例三次

对于小说的场景来说,章的数量和卷的数量比显然并非一比一,而是前者远多于后者,多次传输的卷标题除了占用带宽之外,并没有太大作用。因此,此处将操作拆成三步,第一步查卷,第二步查章,第三步组合起来,这样能减少传输开销。

$db = Db::get();
// 卷信息
$volumes = $db -> fetchAll($db -> query(
    $db -> select('id, title')
    -> from('table.tpnovel_volumes')
    -> where('bookID = ?', $bookID)
    -> order('sort', Db::SORT_ASC)
));

// 章信息
// 需要联查 contents 表获取章节标题
$chapters = $db -> fetchAll($db -> query(
    $db -> select('c.cid, c.volumeID, ct.title, ct.slug')
    -> from('table.tpnovel_chapters AS c')
    -> join('table.contents AS ct', 'ct.cid = c.cid')
    -> where('c.bookID = ?', $bookID)
    -> order('c.volumeID', Db::SORT_ASC)
    -> order('c.sort', Db::SORT_ASC)
));

// 组合卷-章信息
$vc = [];
foreach($volumes as $v) {
    $vc[$v['id']]['title'] = $v['title'];
}
foreach($chapters as $c) {
    $vc[$c['volumeID']]['items'][] = $c;
}

// 获取首章地址,便于生成『开始阅读』按钮
$firstChapter = '';
if(isset($chapters[0])) {
    $firstChapter = $chapters[0]['slug'] ?? null;
}

最后就可以返回值了。最终返回为一个数组,在主题中按需输出数组内容就可以了。

插入功能也生效,那个番外就是后来插入的插入功能也生效,那个番外就是后来插入的

3.2 阅读页

阅读页,也就拿一下书名,目录页地址,上下章地址,字数而已...

...吗?

从上面我们知道,Typecho内置了一个简单的ORM,可以组合SQL语句。然而,对于我们的需求来说,这套简单ORM就无法胜任了,必须回退到手写SQL的形式,也就是直接传入query()中。

举个例子,获取上一章的地址。由于排序系统使用了sort来排,又因为此值显然只会在同一本书的同一卷内有意义,完了之后还得去Typecho的内容表里面查slug,也就是短标识符。因为笔者的数据库学得不好(以前只玩过简单的CRUD,在大专上课老师也不讲太多),一开始用的是子查询,后来把语句发给ChatGPT之后,他帮我用JOIN重写了,说是性能更好(现代数据库对JOIN有优化),最后得到这一堆东西:

SELECT t.slug
FROM typecho_tpnovel_chapters AS p
-- 先self JOIN一次,取比自己sort小的记录
JOIN typecho_tpnovel_chapters AS c
    ON c.bookID = p.bookID
    AND c.volumeID = p.volumeID
    AND c.sort < p.sort
-- 然后JOIN上contents表,取对应的slug
JOIN typecho_contents AS t
    ON t.cid = c.cid
WHERE p.cid = ?
-- 最后精确筛选
ORDER BY c.sort DESC
LIMIT 1;

就,怎么说呢,确实从中学到了不少东西,而且如果有看不懂的地方,继续发给他(或者发给别的LLM),他会真的告诉你怎么看这个查询,怎么想象一个虚拟表出来,估计比老师在课堂上干巴巴讲『这是连接查询,这是左连接....』要记得更牢吧。

话题回来。这种复杂的查询肯定是要放入query()的了,此时因为Typecho内置ORM没有在query函数提供安全的传参方式,因此在传参时需要特别注意小心SQL注入的问题。所幸,这里我们需要传入的参数只是id而已,转成整型只保留数字,自然就没有了SQL注入的风险:

$db = Db::get();
$chapterID = (int)($archive -> cid);
$prev = $db -> fetchRow(
    $db -> query("....{$chapterID}....")
);

这是查询上一章。对于下一章,只需要把小于号改成大于号,末尾排序改成升序,就可以了。剩下的,比如说取目录页,取书名,取字数(从数据库读,不是实时计算),基本上就是一些简单的零碎SQL查询,用内置ORM就行。最后也是一样,返回为数组。

3.3 书架页

由于前面已经把该踩的坑都踩完了,因此书架页实现起来就很轻松了,写SQL就行。

$list = $db -> fetchAll($db -> query(
    $db -> select('c.slug, b.name, b.cover, b.tags, b.status')
    -> from('table.tpnovel_books AS b')
    -> join('table.contents AS c', 'c.cid = b.cid')
));

// tag单独拆一下
foreach($list as $k => $v) {
    $list[$k]['tags'] = explode(',',
        htmlspecialchars($v['tags'])
    );
}

最后也是按需返回字段,就好。

成品大概这个样子成品大概这个样子

3.4 主题数据读取

插件生成数据后,在主题内直接读取并使用就好:

<?php
    // 这里以目录页的状态读取为例
    $tpnCtx = $this -> tpnToCInit;
?>
<span class="info-item">
    <span class="info-label">状态</span>
    <?php if($tpnCtx['status'] == '0'): ?>
        <span class="info-value status-ongoing">连载中</span>
    <?php elseif($tpnCtx['status'] == '1'): ?>
        <span class="info-value status-finished">已完结</span>
    <?php else: ?>
        <span class="info-value status-ongoing">
            <?= htmlspecialchars($tpnCtx['status']) ?>
        </span>
    <?php endif; ?>
</span>

4. 写在最后

数据读取基本上就做完了,下一篇再讲数据写入的问题。

上面的代码基本上都是手搓,或者说,『古法编程』出来的。效率低是低了点,但好歹也学到点东西吧。反正这不是正经工作,快一点慢一点都没事。

(完)