本科毕业以后就很少有时间来折腾自己的网站了,大部分时间里都是 ssh
到服务器上随便搭好一个服务后就任其自由生长,即便是服务挂掉了之后也没有太在意——毕竟是自己用的东西,对于网站的用户而言:ABSOLUTELY NO WARRANTY。不过随着网站近来的用户数量呈现难以置信的增长势头(虽然什么也没干),自然也就难免希望让网站变得更加「可靠」。
作为 2020 年的最后几项 TODO,我终于在圣诞节休假的第一周的前三天完成了整个 changkun.de[1] 的「架构升级」,从原来组织混乱、依赖复杂的 Native Nginx + Docker + tmux served binary + Jupyter notebook + hypervisor + crontab + ... 等等依赖 C/C++/Python/Node.js/Go/... 以及数不胜数的第三方依赖全面转向了以「尽可能自研、能不依赖就不依赖、 即使以来也要依赖使用 Go 开发的依赖」为指导思想的纯 Go 的后端结构。
这篇文章就介绍了 changkun.de[2] 作为个人网站,从最初的每年不到一百的活跃用户到现在的每月上万活跃用户这个过程中究竟积累并承载了哪些(公开的、但不那么可见的)个人以及面向公共的服务,以及它背后的迁移故事。
博客 blog.changkun.de[3] 的每次「重大」迁移,其实都有文章记录,在这里[4]可以找到。
回顾过去来看,changkun.de[5] 其实有相当长的迁移历史。从最早的内容散落在各种 BBS(贴吧、新浪博客、QQ 空间)、到后来本科时期 Wordpress(2012-2015)再到到后来初次迁移到 Hexo 的第一、二版(2015-2017)以及沿用至今的 Hexo 第三版(2017-2020)和刚刚上线的纯 Go 应用版(2021-至今)。
在 2013 年以前,我还是一个彻头彻尾的不知计算机技术为何物的纯数学爱好者(高中),对计算机技术曾嗤之以鼻。混迹在诸如几何吧、数学吧、猫扑天涯这类早期的 BBS 与人版聊数学,自然也不懂建站为何物。很多早期的内容都散布于此,博客里面很多早期的文字都是从这些地方后续搜集得来,具体的历史几乎不可考究,那时候还没有注册changkun.de[6] 这个域名。
购买域名和服务器
手动在服务器上编译源码并安装 MySQL
手动在服务器上安装 Wordpress
没有 HTTPS
本科三年很快就结束了,我也从那个对计算机一无所知成长为了一个能够用 C 语言徒手撸 parser 的萌新,可惜还没来得及进入职场就已经在 2015 年的下半年应差阳错的来到了德国开启了半年的留学访问之旅。但那时并没有对 Web 技术有多少了解,直到我旁听了一门 Online Multimedia 的课,才了解到了这个世界上还有 Polymer、Angular 这样的前端技术、以及 MongoDB 这样的非关系型数据库,就像开启了新世界的大门。由于开始尝鲜各种 Web 技术,了解到了静态博客的概念,发现了相对成熟的 Hexo 的存在。
考虑到博客作为一个年活跃不超过 100 个用户的自留地,继续使用 Linux+Apache+MySQL+PHP 这样的组合无疑是愚蠢而笨重的。与此同时因为当时的服务器在国内阿里云,而我身在德国直接 SSH 上服务器通常敲几个命令就会掉线,做不了任何事情。于是狠下心来决定将网站迁移到 GitHub Pages 上,这次迁移的工作包括:
从 Wordpress 将文章从 MySQL 中导出并转化为 Markdown 的格式
在本地编译博客静态文件,手动 push 到 GitHub
继续没有 HTTPS
第一纪元的的开启意味着整个网站从繁重的 LAMP 栈中摆脱出来,直接削减为了零成本的「Serverless」计算。
但这并不是没有缺点,其实当时整个网络环境并不如今天这么好,我早年的 Wordpress 站点曾被搜索引擎索引得很好,也有相当的流量从搜索引擎直接倒入。但由于 GitHub 自身优化的原因,博客部署到 GitHub Pages 之后便再没有被搜索引擎给索引,相当于整个站点从互联网上直接就消失了。另一个缺点是当时的 GitHub Pages 也还不支持配置 HTTPS,虽然说作为一个静态博客这种东西没有必要,但那时候能够启用 HTTPS 已经是一种尊贵的象征。
推动整个互联网向 HTTPS 迈进的一个历史性的关键节点是苹果的 iOS 开始强制应用 API 必须使用 HTTPS 协议,当时移动互联网热潮翻涌,我也非常热衷于希望给自己的博客开发一个 APP,如果还有博客的早期读者在的话应该还记得,这个博客曾经是有桌面客户端(基于 Electron)和移动客户端(基于 ReactNative)[7] 支持的, 而且为了能够支持 API 访问,我编写过 Hexo RESTful API Generator[8] 这种东西。
所以,这次迁移的工作包括:
在服务器上配置好 Nginx 网关
设置 Git 服务器,配置 post-hook 脚本
在本地编译博客静态文件,手动 push 到 Git 服务器并触发 post hook 来更新 /www/blog 下的静态文件
启用 HTTPS
值得一提的是,这个时候(2016)年还没有 LetsEncrypt,而自签名的证书并不会被主流浏览器判断为可靠证书,所以当时还花了一些时间来寻找可靠且免费的 SSL 证书。
第二纪元的做法并不安全,而且相当依赖本地原始文件的安全性,如果本地文件丢失,那么从编译好的 HTML 中恢复 markdown 将是不小的工作量(图片、公式的处理)。此外,每次向服务端 Push 编译后的静态文件严重依赖本地和服务器之间的传输速度,这对于纯文本的更新还好,但一旦需要向服务器 Push 图片、文件等内容时,将是巨大的灾难。我至今仍然记得为了更新一篇博客,敲完 push 后开着电脑等待一小时的尴尬场景(当时德国向国内阿里云华南地区服务器上传速度仅为 10kb/s 左右)。
为了解决网速缓慢的问题,于是这个博客开始引入 CI/CD 系统,核心目的其实是当 push 完毕后, CI 系统能够自动帮我完成编译、上传的过程,这样每次更新博客的流程就简化为了为博客源码提交一个 commit。虽然引入 CI 并没有彻底解决向国内阿里云服务器上传文件的速度问题,但这个上传过程是异步的,总体上是能接受的。
当时比较主流的 CI/CD 系统是 TravisCI,于是很快就用上了。这次迁移的工作虽然不多,但是期间花了很多时间将整个流程进行调优,例如使用 gulp (早于 Webpack 的一种打包工具)对 assets 中的文件进行 minify、图片压制等等。这些操作在今天看来无非就是引入一个基本的依赖,但当时做起来并不简单。
除此之外,有一个比较特别的地方,那就是路由管理。当时网站的路由被设计成了:
页面:/archives/{{year}}/{{month}}/{{id}}
图片:/images/posts/{{id}}/{{image}}.{{format}}
其中的 ID 是文章的全局 ID,但因为 Hexo 这样的工具并不像数据库那样为文章分配全局的 ID,所以当时还专门写了一个脚本:
import yaml
import glob
import time
POST_DIR = 'source/_posts/'
def loader():
posts = glob.glob(POST_DIR + '*.md')
settings = []
for post in posts:
raw = open(post).read()
head = raw.split('---')[1]
try:
setting = yaml.load(head)
if set(['title', 'date', 'tags', 'id']).issubset(setting):
setting['path'] = post
settings.append(setting)
else:
print('YAML parsing error: ', post)
except Exception as e:
print('YAML head not avaliable: ', post)
return len(settings) == len(posts), settings
def id_repair():
all_correct, settings = loader()
if all_correct:
print('ALL SUCCESS')
fixed = sorted(settings, key=lambda head: head['date'])
for index, fix in enumerate(fixed):
fix['id'] = index + 1
return fixed, settings
else:
print('PLEASE FIX YAML HEAD FORMAT FIRST')
def id_writer():
# goal:
# repair id: ../images/posts/old/* => /images/posts/fixed/*
fixed, old = id_repair()
for i, fix in enumerate(fixed):
try:
with open(fix['path'], 'r', encoding='utf-8') as f:
raw = f.read()
try:
content = raw.split('---', 1)[1].split('---', 1)[1]
content = content.replace('](../images/posts/', '](/images/posts/')
content = content.replace(
'/images/posts/' + str(old[i]['id']), '/images/posts/' + str(fix['id']))
except Exception as e:
print('ERROR: ', fix['path'])
print(e)
with open(fix['path'], 'w', encoding='utf-8') as f:
f.write('---\n')
stream = yaml.dump(
fix, default_flow_style=False, allow_unicode=True)
f.write(stream.replace('\n- ', '\n - '))
f.write('---')
f.write(content)
f.close()
print('SUCCESS: ', fix['path'])
except Exception as e:
print('FAILED: ', fix['path'])
print('REASON: ', e)
def main():
id_writer()
if __name__ == '__main__':
main()
这个脚本的主要功能就是遍历所有文章,然后将文章按发表时间排序,然后按时间顺序依此为文章分配 ID。这样做的原因有:
可能要在某个旧时间节点上插入新的文章(例如找到了一些新的散落在 BBS 值得回收的文章)
文章编写过程中使用图片的相对路径可以方便预览,生成后通过替换图片的 URL 来保证图片能够被正确的路由。
现在回想起来这种 /year/month/id 的路由简直反人类,因为这样的路由并没有提供任何有用的信息。但当时可能只是因为防止爬虫能够轻易爬取,现在想来真是没有必要,反倒给现在的迁移带来了麻烦。
这次的迁移的第一大原因是由数据驱动的。上面提到过,这个博客曾经只有不到一百的年活跃用户,但如今却已经成长为了每月上万的活跃用户了,这真是着实令我吃惊。
当然,这得益于今年莫名其妙的几次匿名推广,例如我本科时期写的蹩手蹩脚的 C++ 教程被国内知名公众大号机器之心在没有联系我的情况下直接推广[9] 等等。让我一跃晋升为坐拥 11k+ Star GitHub 项目的「大V」。如果有读者看到那个教程里 typo 百出,我很是抱歉,但无论如何看到自己的作品被他人认可还是很开心的。
进行迁移的第二个原因是我已经越来越难在新的机器上直接顺利编译整个基于 Hexo 的博客了。通常我的个人习惯会将所有的打包步骤封装到一个 make
命令。但随着时间的推移,我越发的发现 Hexo 及其所围绕的生态开始不断的进行破坏性的更改:
gulp 的打包脚本已经无法顺利执行,breaking changes 的修复需要重新熟悉 gulp 的工作机制并重写打包脚本
无法将博客顺利的升级到 Hexo 5.0 之后的版本
所依赖的主题 hexo-theme-next 的源仓库早已停止维护
自己定制的版本 changkun/hexo-theme-next 包含了太多主题上的定制
...
等等的这些原因让我开始觉得整个 Node.js 生态变得无比的不可靠,加之近几年对 Go 的使用愈发的频繁,并且开始被 Go 所崇尚的 New Jersey Style 的魅力而吸引,相比野蛮生长的 Node.js 和参差不齐的 NPM 生态,我更愿意相信 Go 的作者们,这也就成为了这次迁移定下了一个很好的目标:
到底能不能将这些混合了 C/C++/Python/JS/Go 的个人网站改造并简化为一个由纯 Go 驱动的网站?
正如你看到了这篇文章的那样,答案当然是可以的。
进行迁移自然会问自己这样的问题:都 2020 年了,你怎么还没用上 Kubernetes ?诚然,个人网站充当的是技术的试验场,早年折腾自己博客的主要目的就是做各种各样的技术试验,所以我并不是没有想过,而且我为了能够用上 Kubernetes 和诸多 Cloud Native 生态,在 2018 到 2020 年期间为了这个小小的静态博客网站曾三次尝试向其靠拢,但如今的我的回答依然是:如无必要,勿增实体(Occam's Razor 原则)。显然 Kubernetes 很好,它有它专注解决的领域问题,但个人网站自然有其发展的需要。
迁移前,我的所有服务都搭建在一个 $5/月 的 Digital Ocean[10] 的 Droplet。我们不妨来计算一下为了用上 Kubernetes,需要花费多少成本:
Kubernetes Cluster:$30
/月(1 Worker Node = $10
/月,3x)
Container Registry:$5
/月(Basic Plan,5 Repo,5GB Storage)
Spaces:$5
/月(250GB)
当然我不是没有考虑过其他的云,但大都比 DO 的开销更大(比如 GCP)。当然,并不是因为负担不起这笔费用,换做是本科时期对用上一些潮流技术极度痴迷的我,肯定早就换过去了。但随着自己「阅历」的增加,逐渐开始在这一方面变得越来越保守。
第一个原因是因为见过了一定数量轮替:比如早年我是 Evernote 的重度用户,但如今大热的 Notion 在我看来无非是资本狂欢下的又一个 Evernote,而且我现在也已经不再使用 Evernote,将里面的笔记整理迁移出来也成为了我一个非常头疼的待办事项。当然这样的例子还有很多,这里就不一一列举了;第二个原因是在我看来:技术的本质或者原理并没有发生变化,所谓的新生事物对我而言无非是新瓶装旧酒。与其追逐一些洪流,不如安下心来读一读 paper 并问问自己能够推导并实现多少个需要用到数学的算法。
那么就还是回到了刚才的问题,在 2020 年的今天究竟什么样的 Go 技术栈才适合个人网站?
想要迁移到 Go 的第一个重大的转变就是摆脱 Hexo。这个要求其实并不简单,虽然我可以彻彻底底的将之前的 Next 主题抛弃,但毕竟这个主题用了这么多年我还是很希望能够在 Hugo 上找到同类主题,不出意料,果然已经有人做过了: xtfly/hugo-theme-next[11]。
不过可惜位老哥做主题也是给他自己用的,没有太多迁移支持,初次尝试下来这个主题还有很多问题:
动画闪屏:这似乎是 Next 主题的老毛病,估计这位老哥是直接将 Next 主题的 JS 脚本直接搬了过来
多余的特性:比如边栏中的地理位置追踪,比如文章分类(我个人倾向于直接使用 Tag)等等
需要额外的定制:Footer 的 Powered by XYZ 改为 Copyright Changkun Ou 等等
前面提到了第三纪元中网站路由的智障设计,因此这次迁移还需要对路由进行迁移,将博客的文章路由统一为 /posts/:slug
的格式。好在 Hugo 提供链接别名的功能,可以在一篇文章的 YAML Header 中指定别名链接,当访问到这些链接时可以跳转到 slug 指定的路由页面:
---
-date: 2020-03-08 21:56:31
+date: "2020-03-08 21:56:31"
+toc: true
id: 264
-path: source/_posts/2020-03-08-我为什么不再写博客了?.md
+slug: /posts/why-i-stopped-blogging
+aliases:
+ - /archives/2020/03/264/
tags:
- - 随笔
+ - 随笔
+ - 博客
title: 我为什么不再写博客了?
---
当然这个功能并不是手动完成的,为此我用 Go 编写了一个简单的脚本:
type oldh struct {
Date string `yaml:"date"`
ID int `yaml:"id"`
Path string `yaml:"path"`
Tags []string `yaml:"tags"`
Title string `yaml:"title"`
}
type newh struct {
Date string `yaml:"date"`
TOC bool `yaml:"toc"`
ID int `yaml:"id"`
Slug string `yaml:"slug"`
Aliases []string `yaml:""`
Tags []string `yaml:"tags"`
Title string `yaml:"title"`
}
func main() {
filepath.Walk("../../content/posts", func(path string, info fs.FileInfo, err error) error {
if info.IsDir() { return nil }
b, err := os.ReadFile(path)
if err != nil { panic(err) }
ss := strings.Split(string(b), "---")
header := []byte(ss[1])
var old oldh
err = yaml.Unmarshal(header, &old)
if err != nil {
log.Fatalf("cannot parse configuration, err: %v\n", err)
}
dd, err := time.Parse("2006-01-02 15:04:05", old.Date)
if err != nil {
panic(err)
}
newHeader := newh{
Date: old.Date,
TOC: true,
ID: old.ID,
Slug: fmt.Sprintf("/posts/%s", strings.Replace(strings.ToLower(old.Title), " ", "-", -1)),
Aliases: []string{
fmt.Sprintf("/archives/%d/%02d/%d/", dd.Year(), dd.Month(), old.ID),
},
Tags: old.Tags,
Title: old.Title,
}
b, err = yaml.Marshal(newHeader)
if err != nil {
panic(err)
}
ss[1] = string(b)
err = os.WriteFile(path, []byte(strings.Join(ss, "---\n")), os.ModePerm)
if err != nil {
panic(err)
}
return nil
})
}
当对路由进行迁移时,还需要在 Disqus 中对文章的评论进行迁移,Disqus 提供了这样的迁移功能,可以通过指定路由映射表来迁移博客的评论,例如:
https://blog.changkun.de/archives/2020/01/262/, https://blog.changkun.de/posts/2018-2019-reading/
https://blog.changkun.de/archives/2020/02/263/, https://blog.changkun.de/posts/2019-summary/
https://blog.changkun.de/archives/2020/03/264/, https://blog.changkun.de/posts/why-i-stopped-blogging/
https://blog.changkun.de/archives/2020/03/265/, https://blog.changkun.de/posts/setup-wordpress-in-10-minutes/
https://blog.changkun.de/archives/2020/09/266/, https://blog.changkun.de/posts/eliminating-a-source-of-measurement-errors-in-benchmarks/
https://blog.changkun.de/archives/2020/11/267/, https://blog.changkun.de/posts/pointers-might-not-be-ideal-for-parameters/
综上所述,最终迁移到 Hugo 之后的博客使用的是我自己 Fork 并维护一份主题,有兴趣的读者可以在博客的源码中找到我自己定制的版本,其中着重修复前面提到的问题,并增加了这些特性:
Drak Mode
支持了 Disqus,删除了一些无用的百度、微信、多说(现已倒闭)分享等功能
增加了友链
标签按频次大小进行缩放
...等等
在 Go 的 1.16 中有一项新特性,能够将文件整个打包到二进制的执行文件中。
这段代码展示了如何利用 Go 1.16 的特性将生成的位于 public/*
文件夹中的静态文件统一打包到一个二进制文件中。
package main
import (
"embed"
"io/fs"
"net/http"
"os"
"path"
)
//go:embed public/*
var public embed.FS
// blogFS implements fs.FS
type blogFS struct {
content embed.FS
}
// Open opens a given filename from the public folder.
func (b blogFS) Open(name string) (fs.File, error) {
return b.content.Open(path.Join("public", name))
}
func main() {
r := http.NewServeMux()
r.Handle("/", http.FileServer(http.FS(blogFS{public})))
const addr = "0.0.0.0:9129"
s := &http.Server{Addr: addr, Handler: r}
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(fmt.Sprintf("cannot listen on %s, err: %v\n", addr, err))
}
}
我们已经决定了要将网站整个切换到纯 Go 的技术栈,那么我们就需要考虑要做那些变更。既然我们决定了不用 Kubernetes,那要不要考虑削弱版的 Docker Swarm?很明显也没有必要。但容器化是肯定的,容器化带来的打包和分发的便利性、以及运行时的隔离性、多重 replica 的特点对日后管理带来的方便远远大于其他缺点。同时考虑到 Docker 也是 Go 开发的项目(政治正确 应该叫 Moby),配合 Docker Compose 进行管理是再好不过了。
我个人喜欢 Go 的很大一个原因是他近乎完美的编译特性,一个简单的 go build
几乎解决所有的依赖问题,不需要静态链接更不需要运行时的动态链接。虽然编译过程中会有这些步骤,但这些步骤并不需要作为用户的我来操心,这也就让整个打包流程变得异常的简洁:
NAME=main
VERSION = $(shell git describe --always --tags)
build:
CGO_ENABLED=0 GOOS=linux go build # 交叉编译
docker build -t $(NAME):$(VERSION) -t $(NAME):latest -f Dockerfile .
Dockerfile 只需将打包好的二进制文件扔进 alpine 即可:
# 拷贝二进制文件即可
FROM alpine
COPY main /app/main
CMD ["/app/main"]
Docker compose 的编写也非常简单:
version: "3"
services:
main:
build:
context: .
dockerfile: Dockerfile
restart: always
image: main:latest
deploy:
replicas: 3
除了主网站和博客之外,还有一些其他的服务也运行中在服务器上。为了保证稳定性,我们需要对这些运行的服务进行监控,一旦出现问题就需要进行提醒,虽然个人网站自用的目的性更强,也就不会有 SLA 保障,但掉线好几天的对于网站的浏览者来说还是很不好的体验。个人使用的监控可以做得非常简陋:使用 crontab 不断的 curl 某个接口,当接口不可用时就触发某个邮件脚本,也可以做得非常花哨,比如用上 Prometheus 和 Grafana 等甚至对 CPU 和内存使用率进行监控。确实也可以,但作为个人网站,从历史经验来看,只有自己写的东西才能长久且兼容的维护下去。
所以,在这次的升级过程中,我顺手为 changkun.de[12] 编写了一个监控服务 upbot,并接入了邮件服务 mailgun,间隔固定的周期像某个服务发起请求来监视服务的可用性,当不可用时候给我发邮件报警。
除了监控服务外,changkun.de[13] 还运行了几个自己使用的服务,例如:
midgard:一个跨平台的剪贴板服务,可以在 Linux、Mac 和 iOS 之间自动同步剪贴板、快速为剪贴板中的内容或者本地的文件创建一个公开的链接等等功能(开发这个服务的原因是自身需要 ,但个服务搭建起来可能相对复杂,改天有时间录个使用教程吧 Orz 挖坑)
occamy:一个通用远程桌面服务,支持 VNC/RDP/SSH 三种协议的转译
redir:一个短链接服务,支持 PV/UV 统计
限于篇幅,这里就不详细介绍了,我们以后有机会再表,有兴趣的读者可以在文后找到链接。
当然网站上其实还有一些自己在用但没有公开提供的服务,比如服务器上还运行了一个 Jupyter 的 Server 等等。这些服务其实早年要么是直接一个 python3 -m http.server
直接跑在 Tmux 上,要么是用 hypervisor 挂起(部署方式取决于部署时的心情)。这次也一并统一给清理和容器化了。
最后,既然有这么多服务跑在服务器上,还需要反向代理的支持,虽然也可以自己写一个,而且 Go 的标准库中有 httputil.ReverseProxy[14]接口,但实现相对可靠、功能完善的反向代理的复杂性要比写一个简单的监控服务复杂得多(?) (至少在我看来还需要实现请求截断、重试、缓存、日志等等特性)。从远古纪元的 Apache、到第三纪元的 Nginx,既然我们的目标是至少用上 Go 编写的服务,那么 从 Cloud Native 生态发展而来的 Traefik 就是不二之选了,使用上非常傻瓜,看文档就能秒懂,也就不再进一步展开了。
这篇文章首先介绍了 changkun.de[15] 作为个人网站的迁移历史,以及每次迁移过程中的遇到的问题和决策,并据此展开了最近一次迁移过程中带来的「架构」升级,同时介绍了由于迁移需求而「自研」并正在运行的线上服务。此次迁移的亮点是目前整个网站完全依靠 Go 进行支撑,并从某种意义上实现了微服务。除此之外,文章还顺带介绍了如何利用 Go 1.16 带来的 embedded files 特性将网站的静态文件一并编译到用于分发的二进制文件中。
目前 changkun.de[16] 这个网站的整体结构如图所示:
这些服务都是用 Go 编写并开源的,感兴趣的读者可以在下面的链接中找到:
https://changkun.de/s/main
https://changkun.de/s/blog
https://changkun.de/s/midgard
https://changkun.de/s/redir
https://changkun.de/s/upbot
https://changkun.de/s/occamy
https://changkun.de/s/modern-cpp-tutorial (这是个例外 :)
除了这些之外,还有这些运行在 golang.design[17] 上的服务:
https://changkun.de/s/go-under-the-hood
https://changkun.de/s/go-history
https://changkun.de/s/gossa
https://changkun.de/s/code2img
他们也曾早期部署在 changkun.de[18] 上,算是由他孵化的新生儿吧。第三纪元的设计使用了三年左右的时间,希望这次的「组织架构」能够借助 Go 的兼容性稳定的服务超过三年。
欧长坤
2020 年 12 月 24 日 于 慕尼黑
[1]
changkun.de: https://changkun.de
[2]changkun.de: https://changkun.de
[3]blog.changkun.de: https://changkun.de
[4]这里: http://localhost:9219/tags/%E5%8D%9A%E5%AE%A2/
[5]changkun.de: https://changkun.de
[6]changkun.de: https://changkun.de
[7]桌面客户端(基于 Electron)和移动客户端(基于 ReactNative): https://github.com/changkun/changkun-blog-clients
[8]Hexo RESTful API Generator: https://github.com/changkun/hexo-generator-restful
[9]推广: https://www.jiqizhixin.com/articles/2020-10-17-5
[10]Digital Ocean: https://www.digitalocean.com/?refcode=834a3bbc951b&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=CopyPaste
[11]xtfly/hugo-theme-next: https://github.com/xtfly/hugo-theme-next
[12]changkun.de: https://changkun.de
[13]changkun.de: https://changkun.de
[14]httputil.ReverseProxy: https://golang.org/pkg/net/http/httputil/#ReverseProxy
[15]changkun.de: https://changkun.de
[16]changkun.de: https://changkun.de
[17]golang.design: https://golang.design
[18]changkun.de: https://changkun.de