之前因为搞一些个人文档和电子书用到了gitbook。由于gitbook项目不再维护还对比了一些替代产品,发现还是gitbook最好用。然而一些小瑕疵让人皱眉,于是改了改源码并以问题为线索对整个应用作了个初步分析。
已有产品的诸多问题
现在有很多线上的个人文档管理工具了,非常方便,但是有以下问题:
- 数据安全问题 这个问题主要是针对国内厂商,以他们的毫无底线,必定需要手机号注册并且毫无疑问会在后台分析个人数据。但是国外厂商又有着翻墙问题,让人无奈,比如Notion,线上版gitbook。
- 迁移成本大 很多个人文档的数据无法导出或不支持导入,如果某一天需要把个人的数据换一个商家保存,会非常困难。
- 无差异对比 对于更改了的文档人们需要知道都更改了哪些东西,改错的内容怎么恢复,这几乎是一个刚性需求,但好多商家都没有实现,比如语雀;或者有的商家需要收费,比如我来。
- 其他问题 在国内环境下,还有个问题日益严重,那就是对内容发布的审𨖚查𨖚。这是个极其蛋𨖚疼𨖚的事情,现在有巨量的不能提及的词语库,条目内容还不为人所知,汉字的复杂组合使得任何文章都可以被匹配到,作者想改还不知道改哪个地方!这对很多内容创作的人来说简直就是阉割一般。所以与其在平台上像扒光衣服一样被指指点点,不如直接全部自己保存!
如此一来,像gitbook这样的工具就非常满足中文创作者的需要了。首先两大功能都是通用标准,几乎任何平台都可以支持。这两大功能就是形成文章主体的Markdown标记,以及组织文章形成版本管理的git。然后也是因为git的分布式特性,可以在任何代码托管平台保存自己文章的原始内容,只要支持git就可以无缝迁移。只要自己手中有数据,内容发布的选择性很大,github, gitlab这样的平台都可一键集成gitbook;如果无法翻墙,现在自己搭一个小型的静态站点是非常容易的。关键所有的数据都为自己所有,不会因为意外所有数据都没了。
总之,即使gitbook这个工具本身问题多多,但这种工具的形态其实非常值得了解和学习。再去开发一个类似gitbook的工具也首先需要了解已有工具的特性和优势。
开发环境搭建
gitbook的运行测试环境搭起来非常方便,npm install -g gitbook-cli成功运行以后添加以下文件
$NODEJS/lib/node_modules/gitbook-cli/.vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/bin/gitbook.js",
"args": ["serve", "/your/test/gitbook/directory"]
}
]
}
然后用VSCode打开文件夹$NODEJS/lib/node_modules/gitbook-cli就能调试运行了,这比以往任何代码工程都简单!
整体阶段
还是以gitbook serve命令为例,在之前分析的基础之上,列出应用起始的大致调用序列如下(缩进表示函数内调用的函数):
manager.ensureAndLoad(bookRoot, program.gitbook)
local.load('3.2.3');
require('~/.gitbook/versions/3.2.3') => 3.2.3/lib/index.js
commands.exec(gitbook.commands)
cmd.exec() => exec: function() // serve.js:136
lrServer = tinylr({});
serve.generateBook()
var book = getBook(args, kwargs); Immutable.Record
var book = Book.createForFS(fs);
book.setLogLevel(logLevel);
Parse.parseBook(book)
resultBook = resultBook.set('config', config);
Output.generate(Generator, resultBook, {root: outputFolder})
server.start(outputFolder, port);
watch(book.getRoot())
看起来异常简单。作为一项工程来说,能够达到简单其实非常不容易,对于初入工程的人来说能够“望文生义”;所谓“望文生义”就是各种函数变量的命名和实际作用是一致的,只要参与过大型工程的人想必是非常明白的。local.js中的ensureAndLoad显然主要是校验本地一些库,gitbook自己去下载了一些软件包并且对不同版本作了判断。起初这一点不太好理解,特别是对js不太熟悉的人来说,lib里的代码并不一定是全部逻辑,有可能在其它路径加载了运行代码,这种操作非常灵活,当然也非常隐蔽。不过这些都是外围的功能,不是整个工具的核心。
lrServer = tinylr({});一句是启动了一个liveReload的服务,用来监听本地文件更改以便触发一些动作,这里需要了解tinylr的作用。gitbook的livereload好像不能使用,不知是不是使用方式不对,但现在没有更深入这个问题。每个工程都牵扯了大量的外部库,区分哪些是调用代码,哪些是从0开始的流程代码,对于了解一个工程是非常必要的。
接着就是serve.js中最重要的方法generateBook,看调用流程,总共3个大步骤:
- 获取书籍信息
getBook - 解析书籍信息
Parse.parseBook - 输出书籍
Output.generate - 在指定目录启动静态网页服务
js语言中没有类,自定义类需要用到一些看起来很别扭的操作,这只是语言的特性,不必过于纠缠。另外表示书籍变量的命名,有时叫book,有时又叫gitbook,不容易对应变量的指代,对于习惯强类型编程语言的人来说是一种痛苦。getBook这个方法实际就是从参数与目录结构中的文件构建book对象,这个对象里包含的其实是书的信息不是书本身的内容,起名BookInfo更合适。启动静态网页服务这种功能比较通用,一般或者是框架的内置功能,或者借助已有类库,不用太仔细看。所以最关键的只有2个调用Parse.parseBook和Output.generate,对应两个模块解析与输出。
书本信息解析
直接列出解析过程的调用序列:
Parse.parseBook(book)
parseIgnore
parseConfig
parseLanguages
parseBookContent
parseReadme
parseStructureFile
lookupStructureFile
parseFile
parser.parseReadme // parser.js:29
parseReadme(html) // gitbook-html/readme.js:10
parseSummary
parseGlossary
从功能维度考虑过程是很清晰的,一个目录下会有各种文件,管理这个目录必然先要判断需要处理文件的类型,显然我们需要处理md文件,还有表示书籍信息的book.json文件,等等。而目录下本身还有表示版本和存储信息的git相关文件和目录,所以parseIgnore这个方法无论叫什么名字,都是明确处理对象的操作。所以紧接其后的Config,Languages,都是好理解的,这里的细节可以忽略。如果我们需要自己实现一个类似gitbook的工具,应该不是难事,需要什么添加什么,只是功能的堆加。最重要的自然是parseBookContent,从方法中调用的方法可以看出,主要是处理README, SUMMARY, GLOSSARY三个文件的。这三个文件将来本身也需要生成html文件,但特殊之处在于这三个文件还在一定程度上包含了书籍的结构信息。所以这个方法名称也是有点迷惑人,其实它是处理书本的结构信息,因而解析了一些文本,而不是处理书本的全部内容。深入方法调用最终调用到了3.2.3/lib/models/parser.js,以parseReadme为例,在这里又引用了gitbook-markdown库里的readme.js:
function parseReadme(html) {
var $ = dom.parse(html);
return {
title: $('h1:first-child').text().trim(),
description: $('div.paragraph,p').first().text().trim()
};
}
也就是说,这里已经先将md文本转成了html文本,又从html文本中提取了标题信息和简介信息。这里有个疑问,把md文本转成html是否有必要,没有办法从markdown里直接解析出对应数据吗?似乎现在的markdown解析库都是直接转成html,不管哪种语言。$('h1:first-child')这种写法太js了,一般对应其它语言的Document.querySelector('h1:first-child')方法,应用css语法找到目标节点。
这里还有一个实现问题:为了提取书本信息提前解析了三个MD文件,是否可以利用现有html直接生成最终目标文件,后续输出最终文件岂不是冗余了?答案是否定的,输出最终文件应当属于Output模块,这不仅是工程中模块划分的需要,还涉及到上下文的问题,另外生成最终文件可能还需要加载其它模块,向外部通知,在这里做这些操作都是不合适的。
之前文章需要了解的解析GLOSSARY的过程也是类似:
function parseGlossary(html) {
var $ = dom.parse(html);
var entries = [];
$('h2').each(function() {
var $heading = $(this);
var $next = $heading.next()
var $p = $next.is('p')? $next.first() : $next.find('p').first();
var entry = {};
entry.name = $heading.text();
entry.description = $p.text();
entries.push(entry);
});
return entries;
}
在GLOSSARY文件中找出所有的<h2>标签,对每一个h2,找到它毗邻的<p>标签,分别作为name和description的属性值,保存在entries数组中,这个细节在前面的文章中描述过了。
获取到足够关于书本的信息就可以开始生成最终文件了。
书本内容生成
生成过程其实是相对复杂的,因为相当于把前面获取到的各种数据在这一阶段集中统一处理,整体的阶段可又分为:
-
准备阶段
-
初始化阶段
-
生成阶段
-
生成结束
对应调用序列如下:
Output.generate(Generator, resultBook, {root: outputFolder})
generateBook() // generateBook.js:155
processOutput()
preparePlugins
preparePages
prepareAssets
callHook.config
callHook.init
generator.onInit(output)
generateAssets
generatePages
isMultilingual
callHook.finish:before
generator.onFinish
callHook.finish
1. 准备阶段
这一阶段主要干三件事:preparePlugins,preparePages,prepareAssets。这是好理解的,系统处理的各种时机需要通知各个外部插件,所以先要收集好外部插件;书本除了文本还可能有图片,样式等各种数据,这些都需要集中处理一下;这里引入的Page从含义上看也不难理解,每一个md文件应当对应每一个Page。
preparePages
Parse.parsePagesList
Parse.parseFilePage
var page = Page.createForFile(file)
parsePage(book, page)
parsePageFromString(page, content)
output.set('pages', pages)
可以看出单个文件中的内容被用于构建Page对象(3.2.3/lib/parse/parsePageFromString.js):
/**
* Parse a page, its content and the YAMl header
*
* @param {Page} page
* @return {Page}
*/
function parsePageFromString(page, content) {
// Parse page YAML
var parsed = fm(content);
return page.merge({
content: parsed.body,
attributes: Immutable.fromJS(parsed.attributes),
dir: direction(parsed.body)
});
}
最终调用非常简单,也就是说一个Page对象持有了对应文件的全部内容,用于后续的输出操作。
2. 初始化阶段
这一阶段需要给外部插件一个通知时机,同时应用自身的生成器也需要做一些工作,具体的细节忽略。
callHook.config
callHook.init
generator.onInit(output)
3. 生成阶段
生成阶段包含两部分,生成资源与生成所有页面。gitbook会把非md的文件直接拷贝到生成目录里作为静态资源,这部分与之前的parseIgnore有些关联,但基本逻辑问题不大,所以忽略,除非有特定问题需要追踪。关键看如何生成单个页面的细节:
generateAssets
generatePages
generatePage
createTemplateEngine
callPageHook('page:before', output, resultPage)
parser.preparePage(currentPage.getContent())
page.prepare(content)
preparePage //gitbook-markdown/page.js
annotate
engine()=annotateEngine
Templating.render(engine, );
parser.parsePage(content)
Templating.postRender(engine, output)
callPageHook('page', output, currentPage
generator.onPage(out, resultPage)
Modifiers.modifyHTML
Templating.renderFile( // render the theme
这里有一个preparePage的操作,看了下源码及方法的注释,原来是为了避免nunjucks这个库对code标签下的内容进行处理,因此用模板语言在code外围加了个套子{% raw %}{% endraw %}:Add templating "raw" to code blocks to avoid nunjucks processing their content.(3.2.3/node_modules/gitbook-markdown/lib/page.js)。然后为了使用模板语言又引入了TemplateEngine这坨东西,最后又用Templating.render将模板去除。这委实有点难受,难道是因为2016年的这些老库还不够强大,所以搞了这坨弯弯绕吗?annotateEngine对应的方法在kramed/lib/annotate/engine.js,而kramed是一个markdown的解析和编译器,看了一下,已经6年没有更新了。
这一阶段的重点毫无疑问是parser.parsePage(output/generatePage.js:55),但其实却异常简单:
Parser.prototype.parsePage = function(content) {
var page = this.get('page');
return Promise(page(content));
};
/**
Parse content of a page
@param {String} html
@return {Object}
*/
function parsePage(html) {
return {
content: html
};
}
也就是说把解析后的html作为content返回给了外部。然后在这个时机做页面生成的通知操作。
接着到了这一阶段的末尾generator.onPage,显然生成后的内容不是立即写入磁盘,还有一些其他操作,这里遇到了之前文章提到的Modifiers.modifyHTML,也就是说对匹配词汇表是在生成html之后了。此外还有一个重要操作,对生成的网页文件应用主题:Templating.renderFile。主题(Theme)是另外一个比较庞杂的内容,暂且搁置,但这一部分不能算作是外围功能,因此涉及到交互和体验,而且考虑到为了扩展应用,支持其他人能够参与其中,主题的模板和开发其实是比较重要的。
4. 生成结束
结束阶段在理解上没有什么重点,先是针对多语言进行了判断,然后进行外部通知,主要看generator的工作(3.2.3/lib/output/website/onFinish.js)。
generator.onFinish()
Templating.renderFile(
writeFile
fs.writeFile(filePath, content);
显然在onFinish阶段,真正地写入了文件。
一些结论
内存占用
在generatePages那个方法里针对每一个page调用了generatePage(out, page),而这时的page对象已经持有了content, 而content赋值的时机早在准备阶段就执行了。
我们知道早在准备阶段page对象就已经加载了md文件的全部内容,parsePageFromString方法就是专门做这件事,然后在生成阶段的generatePages那个方法里针对每一个page进行了遍历。也就是说,在这时其实已经把书本中全部的markdown原始文本加载到了内存中。
一篇文章的内容可能是非常巨大的,加载原始内容可能会非常占用内存,是否有必要提前把所有内容加载呢? 目前看好像没有必要,在针对输出单个文件的方法generatePage中再去加载md内容似乎也可以。猜测可能是插件的原因,需要插件能够提前获得时机去修改内容,但是已经有一个callPageHook('page:before')时机了,可以在这时候进行修改的,这个问题没有再深入了。如果我们自己重新实现,显然可以有更好的方式。
对象组织关系
整体来看,gitbook中的Book对象其实是书的空壳,叫BookInfo更合适,但Page则是通过parsePage持有了具体内容信息。按照通常面向对象的方法论,Book应该持有多个Page对象,然而实际Book和Page没有关系,而是都被Output对象持有:var book = output.getBook();还有output.set('pages', pages);
多语言
关于多语言我有2点想法。
代码组织
通过代码明显感到,多语言的支持带着明显的修补痕迹,随处可见的分支判断对主流程的理解带来困扰。多语言的支持归根结义其实是支持多本书籍的支持。同一个仓库下,可以有多本书,每本书的语言不同,内容不同,但目录结构和段落结构是相同的。因此其不管现状如何,如果要重新实现,则应当表示如下:
class BookManager {
final books = <Book>[];
}
class Book {
final String language;
}
这种表示结构其实是最适合多语言书籍的。
内容展示
多语言的多本书按现在gitbook的实现是相互分隔的,但实际的使用过程中会发现,将他们“合”起来展示会更方便更有价值。一般一种书有多个语言都是因为有不同语言的翻译版本,并不是再创作,因此普通人看到一句话可能想了解这句话的原文是什么,这时候如果我们的应用能方便的展示原文或原语言就十分必要。这一点flutter.cn给人以启发:点击一个中文段落,相应的英文段落就可以在中文段落下方展开显示出来,再次点击中文段落则英文段落收起,目前还不知道flutter.cn用的是哪种工具把同一段落的不同语言组织起来的。但是如果对于长段落的文章,这个小功能就不那么方便了,普通的电子书可能会有非常长的段落,如果只是在原段落下追加一个原语言的长段落,用户的体验效果并不会有提升。而且这个功能只是针对双语,如果有三种语言,应该如何展示呢?
一种比较好的体验就是行对行的方式,就像手里同时拿着多支笔书写,文字布局,折行逻辑都保持对应语言的方式不变,唯一变的只是单个语言折行后需要增大竖直方向的起始位置。一旦实现,这对翻译作者而言,将是非常方便的功能。当然,实现起来非常困难,这需要重新实现文本绘制的逻辑,甚至可能需要自定义编辑器。
如果有一种好的多语言展示方式,那一定会是个很棒的功能!
插件系统
gitbook比较强大的一点就是它的插件系统,插件系统不仅帮助应用扩展了功能,而且实现了一种开发生态。然而插件系统的实现与开发语言密切相关,因为要加载可执行代码,一般插件系统的实现都严重依赖开发语言的虚拟执行环境或runtime环境,js自不必说,java的插件系统也异常丰富。但在客户端领域插件系统实现起来很麻烦,安卓需要实现自己的DexClassLoader,加载的对象必须编译依赖app的开发环境,工程配置和代码实现都十分的烦琐;而iOS则根本禁止任何可执行代码的加载。C/C++, Rust, Go这种系统编程语言貌似可以通过动态链接库来实现插件,但是数据依赖是个问题,虽然可以引用头文件,但实现起来很麻烦,所以插件系统更适于一些脚本语言开发。
总之,不是所有系统和开发语言都能实现插件系统,比如最近比较火的Dart语言,语言本身也支持反射与代码动态加载,但基于这种语言的Flutter框架则无法使用这一特性。如果要重新实现gitbook,语言的选择不得不慎重考虑。