写了个小玩意来展示我做的小玩意(禁止套娃

写了个小玩意来展示我做的小玩意(禁止套娃

编程那点事 随便写写 Go 4735 字 / 10 分钟

HGAME 完结撒花~

长达 4 周的 HGAME 2020 结束啦!因为是放寒假,所以每天几乎都睡到 12 才醒,醒来一看手机消息总会有几个学弟来问题。 陆陆续续下来,协会的招新工作也差不多接近尾声,一些有潜力的新生后面也要开始关注起来了。

在准备 HGAME 的空余时间呢,我依旧是在瞎写东西。Week2 在老家的时候把上学期开的 Pie-Baker 这个坑填了不少,估计以后可以写篇文章介绍一下。她是一个用 Go 编写的任务编排执行程序,有点像 iOS 上的 Workflow。你可以编写 JSON 格式的文件编排任务,然后 Pie-Baker 会解析执行。后期如果配上可视化的前端页面,用户只需用鼠标拖动就可以编排任务,像极了 Workflow。

回到深圳后,有天晚上看到博客的关于我页面,突然来了个新的点子。打算专门做个站来展示我以前写过的小玩意儿。但转而一想,如果又是一套 Gin + Vue 的增删改查未免也太无趣了点。 因而这次我便写了个静态网页生成器,我只需要写好一个个项目的 Markdown,她就可以生成对应的页面,甚至还有按照编程语言分类的页面。美其名曰:Ego,因为刚开始写的时候尝试去读了 Hugo 的部分源码哈哈哈。

『My ego is big』

Ego 现在已经上线了!欢迎大家参观:https://p.github.red/

Ego

因为是全静态页面,这个网站目前是部署在七牛云 CDN 上的。为了方便上传部署,我使用了七牛的 Go SDK 给 Ego 加上了上传文件至七牛云的功能。 下面就开始具体聊聊 Ego 开发中我所遇到的坑吧~

最初开始写的时候,为了避免走太多弯路,我的想法是参考阅读一下前辈 Hugo 的代码。Hugo 发展到今天,已经是一个很成熟的静态网页生成器了,其支持的功能也是五花八门。去繁求简,我翻到了 Hugo 的第一个 commit,打算从她的第一版的核心代码开始看。

看了一个晚上后,我便开始按照 Hugo 最初的结构开始写:Page一个结构体,其里面有一些例如RenderedContent RawMarkdown Draft等等属性,然后又有Node这个奇怪的结构体;这些看似有用的属性,我总感觉后面会有用到,因此全部也都照着这么写了。 直到写到后面我自己都无法理解啥是啥了,遂决定抛弃 Hugo 的那一套,最终还是按自己的想法来了。

以下便是 Ego 最后的目录结构了:

├── about.go	“关于”页面
├── cli.go		命令行相关
├── config.go	全局的配置
├── data	用户的数据(我的 Markdown 就是放在这下面的)
│   ├── about.md		“关于”页面内容
│   ├── config.toml		全局配置文件
│   └── project			项目文件夹
│       └── cube		这是我的示例项目 Cube
│           ├── cube.md		项目的简介(与文件夹同名)
│           └── history.yml		项目的更新历史记录文件
├── funcs.go		Go template 自定义函数
├── helpers.go		常用的函数
├── language.go		“编程语言分类”页面
├── main.go			入口
├── model.go		定义的结构体
├── page.go			单独一个页面的相关方法
├── precheck.go		运行前的预检(检查文件、目录是否存在)
├── project.go		“项目详细”页面
├── public				渲染导出的页面
│   ├── ...
│
├── qiniu.go			七牛云相关
├── render.go		页面渲染相关
├── server.go		内置 HTTP 服务器,方便导出后快速预览
└── templates		模板文件
    ├── about.html		“关于”页面模板
    ├── assets		静态文件(包括用户自己的图片)
    │   ├── ...
    ├── index.html		“主页”模板
    ├── language.html	“编程语言分类”页面模板
    ├── layouts			每个页面渲染都要载入的模板
    │   ├── footer.html			页头
    │   ├── header.html			页尾
    │   ├── include.html		引入的静态文件
    │   └── profile.html		个人信息
    └── project.html		“项目”页面模板

可以看到这个 Ego 是很有“针对性”的,她就只能生成我这么一个项目展示网站,不像 Hugo 功能那么广泛而抽象,可以用于生成各种展示型站点。

Go 模板渲染

整个 Ego 的核心其实就是 Go 的template包,这是 Go 自带的模板引擎。除了 Hugo 外,很多 Go Web 框架的模板引擎也是基于template包的,比如 Beego。 我在之前的文章中也提到过:从一个项目看 beego 的 MVC

使用的方法其实很简单:


{
	...
	tpl := template.New(templateName).Funcs(r.FunctionMaps)		// New 一个新的模板,并加入自定义的模板函数
	...
}

func (p *Page) Render() ([]byte, error) {
	tpl, err := p.Tpl.ParseFiles(append([]string{"./templates/" + p.TemplateName}, p.Layouts...)...)		// 解析页面模板(其中 Layouts 是每个页面渲染都要载入的公有模板)
	if err != nil {
		return nil, err
	}

	var wr bytes.Buffer
	p.Params["Title"] = p.Title // 设置标题

	err = tpl.Execute(&wr, p.Params)		// 将数据 p.Params 绑定到模板中,渲染!
	if err != nil {
		return nil, err
	}
	p.Content = wr.Bytes()		// []bytes
	return p.Content, nil
}

template包里的相关方法虽然支持链式调用,但是这里的调用是有顺序要求的。首先是New()指定模板名,一般与文件名相同。在使用ParseFiles()解析模板文件前,需要先调用Funcs()载入自定义的模板函数。 比如设置数据不进行 HTML 实体化的unescaped函数,通过 Go 反射来计算切片或字典元素数量的count函数。

最后使用Execute()将绑定的数据送入模板中,渲染出 HTML 文本。

可以注意到这里的wr我是转成[]bytes而非string类型进行存储的。最初我看 Hugo 也大量使用[]bytes而不是string,因此猜测[]bytes可能会带来性能上的提升。在查阅了相关资料后,发现其实还是需要根据实际应用场景来选择。 string在 Go 底层的定义其实是一个结构体:

type stringStruct struct {
    str unsafe.Pointer
    len int
}

存储了字符串的数组指针以及长度。 而byteuint8的别名,因此[]byte其实是个uint8的切片。在之前写 RoarCTF Go 题的 writeup 里聊到了 Go 底层对 Slice 的定义:

type slice struct {
	array unsafe.Pointer
	len    int
	cap   int
}

一个指向数据数组的指针,一个len存储切片的长度,一个cap存储切片的容量,后面append的时候容量不够了会以 2 的倍数扩容,同时因为指针指向的是大小不可变的数组,因此会开辟一块新的数组存放数据。

这两种类型虽然都是指向一个数组,但是string类型所指向的数组是只读的。这意味着只要你修改一个string,它就必须分配一次内存,用一个新的数组来存储新的数据。而对slice类型进行操作时,是可以修改其底层数组的内容的。只要不是因为拼接了一大串内容导致底层数组容量不够了需要扩容,像改几个字这种操作,是可以一直对指针指向的那一个数组进行操作的。

但是话说回来,string类型却可以直接使用等号进行比较,但[]bytes又有切片的那一套特性,可以轻松对单个字符进行修改。所以各有各的好,还是要按照实际使用场景来看。

模板中的 range

在 Ego 的project.html中,有嵌套的两层循环:

 {{ range $intro := .HistoryKey }}
<at-timeline-item>
	<p>{{ $intro }}</p>
	{{ range $item := index $.History $intro }}
	<p>{{ $item }}</p>
	{{ end }}
</at-timeline-item>
{{ end }}

外层是循环HistoryKey这个切片中的元素intro,内层再通过intro作为下标取到History这个 Map 中的元素。 这么做的原因是因为 Map 是无序的,每次渲染时 range 的元素顺序都不一样,因此我这里先对 Map 的 key 做了一波排序,再用排序好的 key 去取 value。

问题就在于这里用了两层循环,其中内层循环的History是在执行Execute()函数时传入的变量,按理来说直接使用.History即可取得。但在循环中直接这么写却会报错。 需要在前面加上$才行。这个问题当时也是困扰了我很久,甚至一度怀疑这是 Go 模板引擎的 bug 哈哈哈。 最后还是在 Stack Overflow 上找到了答案:https://stackoverflow.com/questions/43263280/go-template-cant-evaluate-field-x-in-type-y-x-not-part-of-y-but-stuck-in-a

Go template包的文档里也进行的描述:

When execution begins, $ is set to the data argument passed to Execute, that is, to the starting value of dot.

即告诉我们在 range if with 等结构里,需要用$来获取那些从Execute()函数传入的变量。

还是循环的问题

下面一个也还是循环的问题,是我在写 Ego 的上方导航栏时遇到的问题,精简后的有问题代码如下:

users := []string{"aaa", "bbb", "ccc"}
test := make([]*string, 0)

for _, user := range users {
	test = append(test, &user)
}

for k, v := range test {
	fmt.Println(k, *v)
}

这里是通过一个循环将users切片里的每个值的地址都传入了test这个切片中。test切片中的每个元素都是string指针。最后的for循环输出test切片的值。 预期结果应该是:

0 aaa
1 bbb
2 ccc

然而实际结果却是:

0 ccc
1 ccc
2 ccc

怎么回事???前面两个指针的值都被第三个给覆盖了??? 为了方便看出问题所在,我在for循环中将user的地址打印出来:

users := []string{"aaa", "bbb", "ccc"}
test := make([]*string, 0)

for _, user := range users {
	fmt.Println(&user)			// 这里打印一下 user 的地址
	test = append(test, &user)
}

for k, v := range test {
	fmt.Println(k, *v)
}

输出:

0xc000010210
0xc000010210
0xc000010210
0 ccc
1 ccc
2 ccc

原来for循环体内每次循环的元素地址都是一样的!!每次循环都是将当前元素的值赋值给同一个变量。每次append的都是user的地址,而最后一次循环是将ccc的值赋值给user变量,所以最后就都是ccc啦。 不过仔细想想也确实,Go 里面的一切都是值传递,而user又是个string类型的变量,每次循环都只是一个赋值的操作。

后来在网上找了一番,Stack Overflow 上的这个问题正好就是:https://stackoverflow.com/questions/39546804/differences-between-pointer-and-value-slice-in-for-range-loop

这个问题和回答都很棒,值得各位去细细阅读品味一番。他这里给出的例子比我这里更复杂一些。 在他这个例子中,print方法是输入结构体指针*field的。因此在对类型为field的结构体调用这个方法时,虽然也是v.print(),但这里其实是一个语法糖,写全了应该是:

(*field).print(&v)

可以看到这里就是在循环体里对迭代的变量v取了地址,因此取到的都是同一个变量v,就会出现我上面遇到的问题。 不过因为他这里是通过go起协程来执行输出的,所以可以time.Sleep(100)来放慢循环执行的速度,虽然每次都是取到同一个指针&v,但是指针的值还是本次循环变量的值,还未改变。所以也能获得正确的输出。(当然这种做法只是扩展一下,加time.Sleep()当然不能算是解决这个问题的办法。)

再说说七牛的事

Ego 会将最后渲染生成完的静态文件上传到七牛云仓库上,只需要在运行 Ego 时加上-p参数即可。(是不是很方便嘻嘻) 七牛云算是国内我挺喜欢的一家云服务商,主要还是他们的 CDN 简单不做作,不像阿里云 OSS 那种买了存储包还要买流量包,并且价格也还算亲民。同时七牛后端的技术栈也是以 Go 为主。因此他们自家的产品当然也提供了 Go 语言的 SDK。 然而…… 你敢想象七牛云上传文件居然不支持批量上传!!官方文档很简单直白的给出的解决方案是:给单文件上传套个循环来实现批量上传……

不过有一说一,上传的速度还是挺快的,两秒的样子整个站就上去了。这还是在没开 CDN 上传加速的情况下。 前些天在 @Moesang 大佬的建议下,给博客的图片上了 webp,其实也就是七牛云的图像处理服务。然后实名认证的用户每个月有 20T 的免费额度,这么一算那就是不仅不要钱我还血赚。

最后再聊几句

寒假结束了呀,明天就要开始在家上网课了。其实回顾整个寒假,带回来的书几乎就没看过,更多的时间还是在瞎写东西。协会的线下 AWD 平台我已经写了 90%,自认为写得还不算差。然后在老家的时候填了下 Pie-Baker 的坑,之后就是这个 Ego 了。仔细一想其实学到的新东西很少。 有时候明知自己还很弱,差得很远,但是却一直没有动力去学。感觉总是在原地踏步啥的…… 不往远了说,就说平时写复杂一些东西,整个代码结构或者逻辑就挺乱的,或者说实现总是挺笨拙的。因此我现在不确定自己是否能开发一些大型的项目。 嘛,总之心情挺丧的,开学就是大二下了,我感觉自己在这个时间点已经落后了。无奈归无奈,但只能硬着头皮赶啊。

最后用最喜欢的一首歌精神支柱结束吧:

真っ暗で明かりもない 崩れかけたこの道で
在这暗无光明的崩坏道路上
あるはずもないあの时の希望が见えた気がした
我却感觉看到了 不应存在的往日的希望
どうして
为什么
ブラックロックシューター 懐かしい记忆
Black★Rock Shooter 令人怀念的记忆
ただ楽しかったあの顷を
那单纯感到快乐的时候
ブラックロックシューター でも动けないよ
Black★Rock Shooter 但我已无法动弹了
暗を駆ける星に愿いを
向着划过黑暗的星星许愿
もう一度だけ走るから
再一次就好 我要向前奔跑