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

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

由于笔者实在是不擅长处理美工与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. 写在最后

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

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

(完)


木头箱子脆脆,但是这样正好

如无特殊声明,本站内容遵循 CC BY-NC-SA 4.0 协议

转载请注明出处并保留作者信息,谢谢!

本站由 搬瓦工VPS 强力驱动

none
最后修改于:2026年07月19日 20:31

添加新评论

提醒:『评论回复邮件提醒』功能正在测试中
评论后,如果站长有回复,会有邮件通知