首先简单说一下之前使用印象笔记给我带来的思考。

印象笔记不支持多层级,那么就需要 001-层级1-002-层级2 这种将从属的树形结果,转换为同级别的平坦结构。
这带来的问题是什么?全都挤在一起,看着非常烦躁。

所以我觉得印象笔记的定位实际上是收集,而不是知识消化整理和输出。

几乎所有知识的逻辑组织结构,在当知识或素材规模达到一定量之后都将失去其最初的意义。
这一点在目录结构(或者说树形结构)膨胀起来后尤为明显。目录结构本质是对知识素材的人为分类,在初期由于其非常符合我们日常的分类习惯,这几乎是下意识的水到渠成。不过这首先需要面对内容的是排他的唯一分类,当你对内思考的越多就越是难以取舍。这时候除非在专有的领域或者经过专业的训练,否则知识是无法简单的的划归在某一个层级目录下。无论是知识录入还是知识获取,每次可能都有新的分类想法,结果是这些输入输出非常困难。那么这时候目录结构就只剩下一个作用了,那就是目录树的某一子树是一个相对完整的,成体系的知识结构,我们做复习与整理的时候想办法在这棵大树下找到子树,然后按照最初的目的使用知识。

其它的如标签模式,多维度管理模式,进而发展成类似知识图谱类的网状结构。这是对树形目录结构的另一种俯瞰视角,其实说白了这就是另一种归类模式。
目录结构的初衷是按照我们的思维方式做层级分类,当树状结构比较小的时候我们思维中是有个比较完整的知识结构的,但是当规模膨胀到一定程度后,我们能记得的实际是一棵棵的子树。所以知识的任何组织结构与归类方式,到最后都没有意义,当我们需要寻找知识点时只能通过搜索。无论是搜索标签,目录,甚至全文搜索,最终结果都是一样的。

这些组织结构的目的是为了更好的知识录入,以及更好的结构化,系统性的学习与再输出。当然了也可以学习专业的分类和信息管理方法,在经过艰苦的学习后我相信会有一个相对实用性更强和更合理的分类方法吧。不过对于我来说这个过程是日常使用中逐渐迭代与演化而来的,所以我在使用中可能要经常调整目录结构,调整分类和层级方式,进而让我更加顺滑的在思维中形成这一层级结构。

TL;DR

1. 版本管理

1.1 Git

输入与输出分离的文件管理最大的问题就是版本管理了,也就是说在不需要每次全部重新部署的前提下,只发布和重新渲染修改过的文件。
在基于Git 进行文件管理的场景下,对于文件内容变更和文件重命名的控制非常精准,毕竟这是经过各种生产环境考验的。但是针对大规模文件路径修改,Git 也是无能为力的。在一个长路径上修改某一段文件路径,在没有操作记录的前提下是无法确认文件本身的唯一性。最终会被识别成删除以往目录节点下的全部文件,并在一个新的目录节点下新建了全部文件。如果这样实现,那么针对输出的渲染系统就要大量删除文件,重新同步和重新渲染。所以这里需要一个针对文件的唯一性标识,它不随文件所在地,文件内容的变更而变化(这点在下一个章节说明)。

在这里使用 JGit 读取 Git 的文件变更记录。 JGit 实际上是 eclipse 平台的纯 Java Git 实现,可靠性有保障。这里有两种比较实用的应用场景:

  1. 文件已变更,未提交。也就是修改了本地文件,未经过任何 Git 操作。
  2. 文件未变更,已提交。这里的提交包括 commit 操作和 push 操作,没必要区分。
1
2
3
4
5
6
7
8
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.lib.Repository;

// 使用本地 .git 目录创建 Git 客户端
Git git = Git.open(new File(".git"))

// 如果要读取 commit 详细信息,这需要构建 Repository(当前场景不需要)
Repository repository = new FileRepositoryBuilder().setGitDir(new File(".git")

接下来使用第一种场景,读取未提交信息。可以直接使用 Git#diff().call() 实现

1
2
3
4
5
6
7
8
import org.eclipse.jgit.api.Git;

try (Git git = Git.open(getGitFolder())){
List<DiffEntry> diffs = git.diff().call();
// ... TODO
} catch (GitAPIException e) {
// ... TODO
}

第二种场景是读取前两次 commit 的文件树差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.eclipse.jgit.api.Git;

try (Git git = Git.open(getGitFolder())){
Repository repository = git.getRepository();
ObjectReader reader = repository.newObjectReader();
CanonicalTreeParser oldTreeIter = new CanonicalTreeParser();

ObjectId old = repository.resolve("master" + "^^{tree}");
ObjectId head = repository.resolve("master"+"^{tree}");
oldTreeIter.reset(reader, old);
CanonicalTreeParser newTreeIter = new CanonicalTreeParser();
newTreeIter.reset(reader, head);

try {
List<DiffEntry> diffs = git.diff()
.setNewTree(newTreeIter)
.setOldTree(oldTreeIter)
.call();
// ... TODO
}catch (GitAPIException e){
// ... TODO
}
}

最终都返回 org.eclipse.jgit.diff.DiffEntry 列表。DiffEntry 对象的内容就比较丰富了,包括变更前后有的文件路径, 变更类型等。在不考虑文件路径调整的前提下,通过文件路径就可以判断文件的新增、删除与修改,再调用 wiki.js 对应的接口即可。

1.2 文件头

YAML front matter 顾名思义是 Obsidian 可识别的一种文档级别的元数据存储方法。就是说在文件开头运行插入一段 YAML 格式的代码块,用于标识文档的元数据信息。Obsidian 自带三个原生 key 的支持:tagsaliases 以及 cssclass

  1. tags:与井号无空格跟上自定义标签名达到的效果一致,
  2. aliases:自定义的内部链接的显示名。因为默认文件名就是标题,这是额外的自定义展示标题
  3. cssclass:这个我就不关注了,对我没什么意义。

大致效果如下图:

许多插件标记文档的方法就是利用了这个 YAML 文件头,通过添加一些自定义的 key 针对性的对特定 MD 文档提供功能性支持。不过不同渲染系统对这个文件头的支持是不相同的,我记得 hexo 只需要和正文分割的 --- 即可。

可以想到文件头存在一个 YAML 这种扩展性很强的代码块,那么这其中就就在很大的想象空间了。就拿上面说到的文件唯一标识符的情况,那么目前就有两种解决方案了。

  1. 文件名时间戳:将 MD 文件名命名方式改为时间戳,文件头增加标题信息。能解决工程上的各种问题,但是对于日常编写文档,定位所需知识将非常困难。并且存在兼容性问题。
  2. 模板与时间戳:现有都不做变化,通过 Obsidian 的文件模板创建文件,并在文件头插入时间戳信息,时间戳暂定 linux timestamp 方式。此时间戳只给转换系统使用。

此功能后续在代码调试后找时间说明,当前暂且只做全量同步

2. 同步 wiki.js

将本地文件同步到 wiki.js 系统,需要先直接读取本地 Obsidian 仓库,进行路径和文件内容转换,随后将数据组装好调用 wiki.jsGraphQL 接口。
这里将本地文件读取和文件上传在代码结构上分隔开,本地读取和上传都存在两种可能。

  1. 本地文件读取:分为直接读取 Obsidian 仓库,以及读取 Git 本地仓库两种情况。
  2. 上传外部系统:上传外部系统前实际上还一个可选的文件渲染过程。在此之后将 MD 文件转换为通用的 Page 对象提交给同步系统,当前的外部同步系统就是 wiki.js 。我的最终目的实际上是在渲染前对纯文本文件分词后提交给 elasticSearchSolr 系统做各种高级搜索,再将文件直接渲染成 Html 静态文件扔给 Ngnix 代理。

2.1 本地文件转换

当前暂时只说明本地文件全量提交的场景。

如以下目录结构,要处理 index.md001.png备份还原.md 三类文件:

1
2
3
4
5
6
7
8
--- 程序设计/
+--- 数据库/
+--- 关系数据库/
+--- mysql/
+--- index.md
+--- assets/
+--- 001.png
+--- 备份还原.md
  1. index.md :index 文件是对当前大目录的全局说明,除执行通用的 MD 文件转换处理外,最主要的是文件名要修改为当前目录的名称。设计目的类似于网页路径下的默认文件。
  2. 001.png : 资源文件规定必须放置在引用文件所在目录下的 assets 目录下。并将所在目录去除 assets 路径段后转换为 ascii 字符,再按照路径段的格式上传至 OSS 文件服务(这里使用 MinIo)。
  3. MD 文件转换 : 读取文件头的 TAG 信息,读取 MD 格式的图片引用信息,如果是内部图片引用则将地址改为 OSS 地址。同时将内部 MD 文件引用的文件后缀名改掉。

整个过程还是比较清晰的,不过在调试过程中感觉还是略显繁琐。这其中对于 TAG 的处理让我隐隐感觉将来会出问题。当前使用单井号无空格加词组的方式,且规定必须放在文件头。但是我在写正则表达式的时候觉察到,文本的正文部分也有可能出现这种类似于 TAG 的格式。所以我将连续匹配改为行首匹配,即 #(?!#)\\S+ + -> ^#(?!#)\\S+ +

在写文章的时候是完全注意不到的,但是站在渲染和兼容性的角度上思考。这种井号方式说不定在使用中会识别成一号标题,并且其和正文是没有分割的,而且本身不支持空格。按照常理说这种东西就是属于元数据,应该独立于正文存在的。也正式由于这些问题的考量,我准备利用在文件头放置 YAML 代码块的方式进行后续优化。

2.2 wiki.js 接口调用

由于之前没见过使用 Apollo 写的 GraphQL 样例,所以看到 wiki.jsGraphQL 调试终端还在想文档在哪里,其实文档就在页面右侧的折叠 TAB 里面。

如上图所示,在试了一下几个查询接口后测试了一下添加文档(Pages)的 Mutation ,竟然流畅地一次性成功了。那么一切都好办了,我需要先写一个 GraphQL 客户端封装接口请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GraphqlRequestHeader requestHeader = new GraphqlRequestHeader();  
requestHeader.addHeader("Authorization", authorization);

DefaultGraphqlMutation graphqlMutation = new DefaultGraphqlMutation();
GraphqlMutationNode mutationNode = graphqlMutation.createChildNode("pages").createChild("create");
mutationNode.addParameter("title", "标题/要和path端点保持一直")
.addParameter("path", "路径/虚拟路径")
.addParameter("description", "描述信息")
.addParameter("content", "正文内容")
.addParameter("editor","所需编辑器/markdown")
.addParameter("locale","地区/zh")
.addParameter("isPublished", "是否发布/true"))
.addParameter("isPrivate", "是否私有/false");
List<String> tags = pageCreateReq.getTags();
StringBuilder builder = new StringBuilder();
// 构建 json 数组格式的标签信息,好像可以直接序列化。
mutationNode.addParameter("tags", builder.toString());

mutationNode.createChild("responseResult").addMember("succeeded", "errorCode", "slug", "message");
mutationNode.createChild("page").addMember("id");
// 这里参考了 RestTemplate 的 API
GraphqlResponse<PageCreateResp> graphqlResponse = graphqlClient.post(requestHeader, graphqlMutation, PageCreateResp.class);

针对 GraphQL 最值得注意的问题是,参数的json序列化中,key是不能带引号的。

在请求 wiki.js 的创建接口的同时,我看了下服务器的情况。如下图所示:

在连续请求的过程中,每次接口请求和返回可以感觉到明显的延时。同时我在管理后端删除页面也能感觉到卡顿,我想也许是新增和删除的时候需要重新构建虚拟目录树。也许新建时还要做页面渲染,并将渲染结果存储在数据库中,因为在网页创建时有一个渲染提示。在一个入门级的服务器上,这个过程 CPU 占用约在 30% 左右,偶尔瞬间达到 75% , 似乎还能接受。

为了有一个相对好的体验,我将服务器上不知道用了多久的 Mysql 5.6 升级到了 Mysql 8。不知道是不是我的错觉,感觉应用的启动和运行都顺畅了许多,但是内存占用似乎也多了(只是感觉上)。

如上图经过一段时间等待后,创建了300多个页面。将页面风格改为深色色调后,无论是web端还是移动端都感觉非常舒服。另外标签的同步有些问题,经过统一调整后全部重传一遍。

3. 畅想

将已有的 Rest 接口换成 GraphQL 也不是没有想过。只是到目前为止,还没有看到一个比较完整的,对比 Rest 的完整的技术以及相关业务解决方案的“最佳实践”,或者说对应的可用解决方案。

技术上说,GraphQL 实际上替换的是 MVC 中的表示层的以往构建方式,也就是将 Controller 全部聚合起来。这样一来对于外面使用确实是带来了非常大的便利,但是就如同我第一次看到 GraphQL 的介绍那种想法,这给我的感觉是将通信的复杂度全部转嫁到后端开发了。所以对应以往的 Rest/MVC 的开发,无论是技术还是业务以及项目管理都要做相应的改变。而对于牵扯到这些的方方面面,我并没有看到详细的解决方案以及实践。

就从技术上说 Rest 在目前依托MVC 表示层的项目结构以及开发模式,造就了基于 Controller 的各种业务,以及相关技术,如在AOP,拦截器,过滤器基础上的方案:认证管理,权限管理,日志管理,跟踪管理等等。这些并没有转换到 GraphQL 的“最佳实践”,当然了两种协议都是基于 http 的网络通信,无论是像 twitter 那种的 URL 传参还是 wikijs那样的 POST JSON Body,最终都会拿到这个请求信息,那么后面怎么做都可以的。但是这在具体做起来还是存在各种问题,在业务层前面的各种技术的替换,如网关,反向代理,负载均衡,事务处理各种配置和业务处理方式的修改,想想都头疼。当然了另外一种方式是做一个聚合入口使用 GraphQL ,后端再分散为 Rest 接口来调用内部微服务和异构服务,这样改造似乎成本低一些。

我在调试 wikijs 接口的过程中确实体会到前所未有的舒畅感,那种看着 schema 编写查询的方式实在是过于无拘无束。所以我想不如加一个后端的管理员调试接口,用于做日常维护和问题排除。