基于Github RESTful API实现网站-数据分离的个人博客搭建实验
概要
原先的博客是基于Vitepress加上一些现有的轮子和脚手架搭起来的,现在自己对Vite和Typescript的了解更深入了,想着是时候重构一下自己的博客了
突发奇想想到了用Github提供的RESTful API,将数据单独存放在一个Repo里面,网站部署在Github Page上,这样可以实现数据和网站的分离(四舍五入Github给我当Web后端)。查询资料后看到有人直接用Issue作为文章数据,直接连评论都带上了,但是考虑后感觉还是不符合本人归档分类的习惯,遂放弃。
想法是把所有markdown文章存放在一个repo里——Metion_Archive,一方面我的网站可以通过RESTfulAPI直接查询数据,另一方面是在其他平台上可以通过支持git的第三方客户端访问文章数据(比如我在iPhone上通过Metion编辑文章,该App自带完整的Git功能),实现跨平台的数据同步和编辑。这么看的话最大的问题其实就是网站要如何实现接口了。
最初尝试
最初通过repo根目录的contentsAPI 直接访问了一下,得到以下结构的JSON对象的Array,实际上对Repo中的大部分资源的直接访问都会返回以下格式的JSON对象(访问File)或者数组(访问Dir),其中url
代表访问该资源的API。
如果资源的Type属于file
,那么还会多出encode:string
(通常是base64
)和content:string,
两个字段
1 2 3 4 5 6 7 8 9 10 11 12
| interface GithubResponse{ _links:Object, download_url:string, git_url:string, html_url:string, name:string, path:string, sha:string, size:number, type:string, url:string, }
|
很无奈地说,我们并不能通过Github的RESTful API直接获取到整个Repo的结构
- 第一次访问时,它会返回给我数个Dir和数个File
- 如果我想知道Dir里的内容,那么我还需要再对每个Dir再发送一次Http请求
- 这就导致了要探明整个Repo的结构,发送Http请求的次数等于Repo里的目录数
- 当然,如果要查询文章的内容,那还需要额外单独发送一次请求获取markdown的content
考虑到我本人喜欢用文件夹对文章进行归档,这无疑导致每次生成Repo的目录结构——从而进一步生成文章的分类结构,需要对Github进行几十次的请求才能完成。在查询官方文档后得知
- 对于未验证的Http请求,单IP有每小时60次的请求限制
- 对于请求头的authorization字段中附上Github Token的Http请求,单IP有每小时5000次的请求限制
- 例外:如果这里的Token使用的是Github Action中的Secret_Token,那么是每小时1000次的请求限制
- 由于网站是通过Github Page部署的,所以我们不得不采用上述方式,如果不考虑目录结构只考虑文章访问的请求,每小时最多1000次的文章访问限制对于我个人博客的访问也算绰绰有余
- 通过OAuth登录接口登录后的Http请求,单IP有每小时5000次的请求限制
不论如何,每次访问网站时递归调用Github Api来生成目录结构是无法承载多少访问量的
缓存机制
最好的解决效果应当是获取目录结构几乎不需要发送请求,只有在访问文章正文时才会调用Github Api
为此,需要通过某种方式建立一个缓存机制,将文章仓库的目录结构序列化并缓存起来
最初有过两种设想:
- 将目录结构硬编码?
- 显然,这么做是不可行的,这样的话每次文章仓库更新的时候,网站这边就要重新编译并推送,和本意是不符的
- 通过LocalStorage缓存
- 解决方案的简单设想是建立一个缓存机制,每个主机初次访问网站时,网站仍然会通过深度递归获取一次目录结构,但是与此同时,网站会将生成的目录结构(一个嵌套的JSON对象)序列化并存储到浏览器的
localstorage
中
- 这么做的缺点是:第一次访问时仍然需要深度遍历占用不少请求次数;当文章仓库更新时,无法建立一个有效的机制来通知本地更新缓存,Github的Webhook服务无法通知静态网站
通过Github Action生成目录树
Github Action的好处在于:在文章仓库设定好运行脚本后,每次对文章仓库推送时,都会触发Action,并且Action支持对原仓库内容更新,于是流程就变为:
- 更新文章并推送到仓库中
- 触发Github Action
- 运行TypeScript脚本,生成对应的目录结构对象,并将其序列化
- 将序列化字节流写入文件
archive.tree
中,并通过Github Action将其推送至仓库
- 访问网站时,网站通过Github RESTful Api访问文章仓库的
archive.tree
,获取序列化对象后将其反序列化得到目录结构对象,并生成目录
如此一来,用户无论如何访问网站,前后都只调用了一次Github Api
序列化实现
在去掉不必要的信息后,我设计了一个GithubResponse
对象的子集,用于目录结构对象
1 2 3 4 5 6 7 8 9
| interface GitSimpleResponse{ name:string, path:string, sha:string, type:string, url:string, html_url:string, contents:Array<GitSimpleResponse> }
|
其中contents用于当该节点为dir
时,存储该节点下的子目录/文件
最开始想直接通过JS自带的JSON.stringfy
和JSON.parse
序列化和反序列化目录结构,但是实践过后发现序列化到本地存储的对象只有最外层的节点,查询后得知JS自带的序列化不支持复杂的嵌套对象,需要寻找其他可用的第三方库
最终将目光放在了MsgPack上,这个库有如下几个优点
- 最重要的一点,支持对复杂嵌套对象的序列化
- 该库支持将序列化后的JSON再次二进制编码,最后以二进制流的形式输出,进一步减少空间
- 该库是一个开放的编码标准,有多种实现
- 对于网站项目,可以用Npm导入官方的实现库
- 对于文章仓库上的脚本,我直接引入了一个单文件的第三方实现,从而避免了对于单文件TypeScript脚本运行时难以引入Npm第三方依赖的问题
接下来是编写文章仓库中,目录结构的构建脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
| let msgpack = require('./msgpack.min.js'); let fs = require('fs') let token = process.argv[2] let fileTree: Array<GitSimpleResponse> = []
interface GitSimpleResponse { name: string, path: string, sha: string, type: string, url: string, html_url: string, contents: Array<GitSimpleResponse> }
interface GithubResponse { _links: Object, download_url: string, git_url: string, html_url: string, name: string, path: string, sha: string, size: number, type: string, url: string, contents: Array<GithubResponse> }
const simplify = (response: GithubResponse) => { return { name: response.name, path: response.path, sha: response.sha, type: response.type, url: response.url, html_url: response.html_url, contents: [] } as GitSimpleResponse }
const createSubtree = async (parent: GitSimpleResponse) => { if (parent.type === "file") return
parent.contents = [] await fetch( parent.url, { method: 'GET', headers: { Accept: 'application/json', Authorization: token } }).then((response) => response.json()).then(async (json) => { let contents = json as Array<GithubResponse> for await (const content of contents) { let simplifiedContent = simplify(content) parent.contents.push(simplifiedContent) if (content.type === "dir") { await createSubtree(simplifiedContent) } } }) }
console.log("Token:" + token)
fetch("https://api.github.com/repos/Smileslime47/Metion_Archive/contents", { method: 'GET', headers: { Accept: 'application/json', Authorization: token }, }).then((response) => { if (!response.ok) { console.log("Http Response Not Ok.") } return response.json() }).then(async (json) => { let contents = json as Array<GithubResponse> contents.forEach((content: GithubResponse, _: any) => { fileTree.push(simplify(content)) })
await Promise.all(fileTree.map(async (content) => { await createSubtree(content) }))
console.log(msgpack.serialize(fileTree) as Uint8Array) fs.writeFile('./archive.tree', msgpack.serialize(fileTree) as Uint8Array, (err: any) => { if (err) { console.log("Write Failed") } else { console.log("Write Success") } }) })
|
Github Action配置
在编写完构建脚本prebuild-tree.ts
后,接下来是如何在仓库每次推送时执行该脚本并将生成的tree文件再次推送到仓库
编写Github Action配置文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| name: Preebuild-Tree
on: workflow_dispatch: push: branches: - main
jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3
- name: Install Node uses: actions/setup-node@v3
- name: Install Ts-Node run: npm install ts-node -g npm install @types/node -g
- name: Run run: ts-node ./prebuild-tree.ts ${{ secrets.GITHUB_TOKEN }}
- name: Commit run: | git config --global user.name 'Smileslime47' git config --global user.email 'Smile_slime_47@outlook.com' git add ./archive.tree git commit -am "Prebuild Archive Tree Commit" git push
|
此时我们已经可以看到Github后台在执行脚本并生成构件了:
此时,远端仓库的根目录下已经有了archive.tree
文件
编写网站逻辑
接下来,是编写代码让网站从https://github.com/Smileslime47/Metion_Archive/blob/main/archive.tree
中获取二进制流并反序列化,生成目录结构
在网站逻辑上,只拥有目录的层级结构是不够的,因为我们还需要随机访问某一个节点,此时需要一个通过节点路径快速获取到节点对象的哈希表
比如说,当我点击Java目录下的Spring目录时,此时网站是xxx/Java/Spring,如果我通过Vue的props将Spring目录的节点对象传入,那么在刷新网站时该对象就会丢失,就无法获取到这个目录的信息了。所以应当让网站获取当前的URL,并通过这个路径查询哈希表,如Java/Spring
来快速找到Spring
这个目录节点。由于目录路径是不可重复的,所以用路径作为Key也是可行的
编写从远端仓库获取archive.tree
并初始化目录结构对象的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| export const fileTreeInit = async (force:boolean=false)=>{ if (fileTree.length !== 0 && !force) { return } await axios.get( Constant.RAW_URL+Constant.REPO_URL+Constant.TREE_URL, { responseType:"arraybuffer" } ).then((encodedTree)=>{ let buffer:ArrayBuffer = encodedTree.data let uInt8 = new Uint8Array(buffer) console.log(uInt8) fileTree = decode(encodedTree.data) as GitSimpleResponse[] console.log(fileTree) }) }
|
编写生成<path:string,node:GitSimpleResponse>
哈希表的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| export const fileMapInit = async (force: boolean = false) => { if (fileMap.size !== 0 && !force) { return } if (fileTree.length === 0) { await fileTreeInit() } fileMap=new Map<string, GitSimpleResponse>() fileTree.forEach((rootNode,_)=>{ traverseTree(rootNode,(node )=>{ fileMap.set(node.path,node) }) }) }
const traverseTree = (treeNode:GitSimpleResponse,action:(treeNode:GitSimpleResponse)=>void) => { action(treeNode) if(treeNode.type==="file"){ return } treeNode.contents.forEach((content,_)=>{ traverseTree(content,action) }) }
|
两个目录对象的Getter方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export const getFileTree = async () => { if(fileTree.length===0){ await fileTreeInit() } console.log("tree") console.log(fileTree) return fileTree }
export const getFileMap = async () => { if(fileMap.size===0){ await fileMapInit() } return fileMap }
|
至此,必要的逻辑已经基本实现,在经过一些前端工程后,可以看到效果
其中网络请求除获取index.md
文章内容外,只有一次获取archive.tree
的请求,完美符合要求