为什么这很酷?一般来说,搭建一个静态网站更容易,而且通常运行也会比较快一些,同时占用资源也更少。虽然静态网站不是所有场景的最佳选择,但是对于大多数非交互型网站(如博客)来说,它们是非常好的。
在这篇文章中,我将讲述我用Go写的静态博客生成器。
动机
您可能熟悉静态站点生成器,比如伟大的Hugo,它具有关于静态站点生成的所有功能。
那么为什么我还要来编写另外一个功能较少的类似工具呢? 原因是双重的。
一个原因是我想深入了解Go,一个基于命令行的静态站点生成器似乎是磨练我技能很好的方式。
第二个原因就是我从来没有这样做过。 我已经完成了平常的Web开发工作,但是我从未创建过一个静态站点生成器。
这听起来很有趣,因为理论上,从我的网站开发背景来看,我满足所有先决条件和技能来构建这样一个工具,,但我从来没有尝试过这样做。
大约2个星期,我实现了它,并且很享受做的过程。 我使用我的博客生成器创建我的博客,迄今为止,它运行良好。
概念
早些时候,我决定采用 markdown 格式写博客,同时保存在 GitHub Repo。这些文章是以文件夹的形式组织的,它们代表博客文章的网址。
对于元数据,如发布日期,标签,标题和副标题,我决定保存在每篇文章的(post.md) meta.yml 文件中,它具有以下格式:
标题:玩BoltDB 简介:“为你的 Go 应用程序寻找一个简单的 key/value 存储器吗?看它足够了! 日期:20.04.2017 标签: - golang - go - boltdb - bolt
这样,我将内容与元数据分开了,但稍后会发现,其实仍然是将所有内容都放在了同一个地方。
GitHub Repo 是我的数据源。下一步是想功能,我想出了如下功能列表:
* 非常精益(在 gzipped 压缩情况下,入口页1请求应<10K) * 列表存档 * 在博客文章中使用代码语法高亮和和图像 * tags * RSS feed(index.xml) * 可选静态页面(例如 About) * 高可维护性 – 使用尽可能少的模板 * 针对 SEO 的 sitemap.xml * 整个博客的本地预览(一个简单的 run.sh 脚本)
相当健康的功能集。 从一开始,对我来说非常重要的是保持一切简单,快速和干净 – 没有任何第三方跟踪器或广告,因为这会影响隐私,并会影响速度。
基于这些想法,我开始制定一个粗略的架构计划并开始编码。
架构概述
应用程序足够简单 高层次的要素有:
* 命令行工具(CLI) * 数据源(DataSource) * 生成器(Generators)
在这种场景下,CLI 非常简单,因为我没有在可配置性方面添加任何功能。它基本上只是从DataSource 获取数据,并在其上运行 Generator。
DataSource 接口如下所示:
type DataSource interface {
Fetch(from, to string) ([]string, error)
}
Generator 接口如下所示:
type Generator interface {
Generate() error
}
很简单。每个生成器还接收一个配置结构,其中包含生成器所需的所有必要数据。
目前已有 7 个生成器:
* SiteGenerator * ListingGenerator * PostGenerator * RSSGenerator * SitemapGenerator * StaticsGenerator * TagsGenerator
SiteGenerator 是元生成器,它调用所有其他生成器并输出整个静态网站。
目前版本是基于 HTML 模板的,使用的是 Go 的 html/template 包。
实现细节
在本节中,我将只介绍几个有觉得有意思的部分,例如 git DataSource 和不同的 Generators。
数据源
首先,我们需要一些数据来生成我们的博客。如上所述,这些数据存储在 git 仓库。 以下 Fetch 函数涵盖了 DataSource 实现的大部分内容:
func (ds *GitDataSource) Fetch(from, to string) ([]string, error) {
fmt.Printf("Fetching data from %s into %s...\n", from, to)
if err := createFolderIfNotExist(to); err != nil {
return nil, err
}
if err := clearFolder(to); err != nil {
return nil, err
}
if err := cloneRepo(to, from); err != nil {
return nil, err
}
dirs, err := getContentFolders(to)
if err != nil {
return nil, err
}
fmt.Print("Fetching complete.\n")
return dirs, nil
}
使用两个参数调用 Fetch,from 是一个仓库 URL,to 是目标文件夹。 该函数创建并清除目标文件夹,使用 os/exec 加上 git 命令克隆仓库,最后读取文件夹,返回仓库中所有文件的路径列表。
如上所述,仓库仅包含表示不同博客文章的文件夹。 然后将具有这些文件夹路径的数组传递给生成器,它可以为仓库中的每个博客文章执行其相应的操作。
拉开帷幕
Fetch 之后,就是 Generate 阶段。执行博客生成器时,最高层执行以下代码:
ds := datasource.New()
dirs, err := ds.Fetch(RepoURL, TmpFolder)
if err != nil {
log.Fatal(err)
}
g := generator.New(&generator.SiteConfig{
Sources: dirs,
Destination: DestFolder,
})
err = g.Generate()
if err != nil {
log.Fatal(err)
}
generator.New 函数创建一个新的 SiteGenerator,这是一个基础生成器,它会调用其他生成器。这里我们提供了仓库中的博客文章目录(数据源)和目标文件夹。
由于每个生成器都实现了上述接口的 Generator,因此 SiteGenerator 有一个 Generate 方法,它返回 error。 SiteGenerator 的 Generate 方法准备目标文件夹,读取模板,准备博客文章的数据结构,注册其他生成器并并发的运行它们。
SiteGenerator 还为博客注册了一些设置信息,如URL,语言,日期格式等。这些设置只是全局常量,这当然不是最漂亮的解决方案,也不是最具可伸缩性的,但很简单,这也是我最高的目标。
文章
博客中最重要的概念是 – 惊喜,惊喜 – 博客文章! 在这个博客生成器的上下文中,它们由以下数据结构表示:
type Post struct {
Name string
HTML []byte
Meta *Meta
ImagesDir string
Images []string
}
这些文章是通过遍历仓库中的文件夹,读取 meta.yml 文件,将 post.md 文件转换为 HTML 并添加图像(如果有的话)创建的。
相当多的工作,但是一旦我们将文章表示为一个数据结构,那么生成文章就会很简单,看起来像这样:
func (g *PostGenerator) Generate() error {
post := g.Config.Post
destination := g.Config.Destination
t := g.Config.Template
staticPath := fmt.Sprintf("%s%s", destination, post.Name)
if err := os.Mkdir(staticPath, os.ModePerm); err != nil {
return fmt.Errorf("error creating directory at %s: %v", staticPath, err)
}
if post.ImagesDir != "" {
if err := copyImagesDir(post.ImagesDir, staticPath); err != nil {
return err
}
}
if err := writeIndexHTML(staticPath, post.Meta.Title, template.HTML(string(post.HTML)), t); err != nil {
return err
}
return nil
}
首先,我们为该文章创建一个目录,然后我们复制图像,最后使用模板创建该文章的 index.html 文件。
列表创建
当用户访问博客的着陆页时,她会看到最新的文章,其中包含文章的阅读时间和简短描述等信息。 对于此功能和归档,我实现了ListingGenerator,它使用以下配置:
type ListingConfig struct {
Posts []*Post
Template *template.Template
Destination, PageTitle string
}
该生成器的 Generate 方法遍历该文章,组装其元数据,并根据给定的模板创建概要。 然后这些块被附加并写入相应的 index 模板。
我喜欢一个媒体的功能:文章大概阅读时间,所以我实现了我自己的版本,基于一个普通人每分钟读取大约 200 个字的假设。 图像也计入整体阅读时间,并为该帖子中的每个 img 标签添加了一个常量 12 秒。这显然不会对任意内容进行扩展,但对于我惯常的文章应该是一个很好的近似值:
func calculateTimeToRead(input string) string {
// an average human reads about 200 wpm
var secondsPerWord = 60.0 / 200.0
// multiply with the amount of words
words := secondsPerWord * float64(len(strings.Split(input, " ")))
// add 12 seconds for each image
images := 12.0 * strings.Count(input, "<img")
result := (words + float64(images)) / 60.0
if result < 1.0 {
result = 1.0
}
return fmt.Sprintf("%.0fm", result)
}
Tags
接下来,要有一种按主题归类和过滤文章的方法,我选择实现一个简单的 tag(标签) 机制。 文章在他们的 meta.yml 文件中有一个标签列表。这些标签应该列在单独的标签页上,并且点击标签后,用户应该看到带有所选标签的文章列表。
首先,我们创建一个从 tag 到 Post 的 map:
func createTagPostsMap(posts []*Post) map[string][]*Post {
result := make(map[string][]*Post)
for _, post := range posts {
for _, tag := range post.Meta.Tags {
key := strings.ToLower(tag)
if result[key] == nil {
result[key] = []*Post{post}
} else {
result[key] = append(result[key], post)
}
}
}
return result
}
接着有两项任务要实现:
* 标签页 * 所选标签的文章列表
标签(Tag)的数据结构如下所示:
type Tag struct {
Name string
Link string
Count int
}
所以,我们有实际的标签(名称),链接到标签的列表页面和使用此标签的文章数量。这些标签是从 tagPostsMap 创建的,然后按 Count 降序排序。
tags := []*Tag{}
for tag, posts := range tagPostsMap {
tags = append(tags, &Tag{Name: tag, Link: getTagLink(tag), Count: len(posts)})
}
sort.Sort(ByCountDesc(tags))
标签页基本上只是包含在 tags/index.html 文件中的列表。
所选标签的文章列表可以使用上述的 ListingGenerator 来实现。 我们只需要迭代标签,为每个标签创建一个文件夹,选择要显示的帖子并为它们生成一个列表。
Sitemap 和 RSS
为了提高网络的可搜索性,最好建立一个可以由机器人爬取的 sitemap.xml。创建这样的文件是非常简单的,可以使用 Go 标准库来完成。
然而,在这个工具中,我选择使用了 etree 库,它为创建和读取 XML 提供了一个很好的 API。
SitemapGenerator 使用如下配置:
type SitemapConfig struct {
Posts []*Post
TagPostsMap map[string][]*Post
Destination string
}
博客生成器采用基本的方法来处理 sitemap,只需使用 addURL 函数生成 URL 和图像。
func addURL(element *etree.Element, location string, images []string) {
url := element.CreateElement("url")
loc := url.CreateElement("loc")
loc.SetText(fmt.Sprintf("%s/%s/", blogURL, location))
if len(images) > 0 {
for _, image := range images {
img := url.CreateElement("image:image")
imgLoc := img.CreateElement("image:loc")
imgLoc.SetText(fmt.Sprintf("%s/%s/images/%s", blogURL, location, image))
}
}
}
在使用 etree 创建XML文档之后,它将被保存到文件并存储在输出文件夹中。
RSS 生成工作方式相同 – 迭代所有文章并为每个文章创建 XML 条目,然后写入 index.xml。
处理静态资源
最后一个概念是静态资源,如 favicon.ico 或静态页面,如 About。 为此,该工具将使用下面配置运行 StaticsGenerator:
type StaticsConfig struct {
FileToDestination map[string]string
TemplateToFile map[string]string
Template *template.Template
}
FileToDestination-map 表示静态文件,如图像或 robots.txt,TemplateToFile是从静态文件夹中的模板到其指定的输出路径的映射。
这种配置可能看起来像这样:
fileToDestination := map[string]string{
"static/favicon.ico": fmt.Sprintf("%s/favicon.ico", destination),
"static/robots.txt": fmt.Sprintf("%s/robots.txt", destination),
"static/about.png": fmt.Sprintf("%s/about.png", destination),
}
templateToFile := map[string]string{
"static/about.html": fmt.Sprintf("%s/about/index.html", destination),
}
statg := StaticsGenerator{&StaticsConfig{
FileToDestination: fileToDestination,
TemplateToFile: templateToFile,
Template: t,
}}
用于生成这些静态资源的代码并不是特别有趣 – 您可以想像,这些文件只是遍历并复制到给定的目标。
并行执行
为了使博客生成器运行更快,所有生成器应该并行执行。正因为此,它们都遵循 Generator 接口, 这样我们可以将它们全部放在一个 slice 中,并发地调用 Generate。
这些生成器都可以彼此独立工作,不使用任何全局可变状态,因此使用 channel 和 sync.WaitGroup 可以很容易的并发执行它们。
func runTasks(posts []*Post, t *template.Template, destination string) error {
var wg sync.WaitGroup
finished := make(chan bool, 1)
errors := make(chan error, 1)
pool := make(chan struct{}, 50)
generators := []Generator{}
for _, post := range posts {
pg := PostGenerator{&PostConfig{
Post: post,
Destination: destination,
Template: t,
}}
generators = append(generators, &pg)
}
fg := ListingGenerator{&ListingConfig{
Posts: posts[:getNumOfPagesOnFrontpage(posts)],
Template: t,
Destination: destination,
PageTitle: "",
}}
…创建其他的生成器...
generators = append(generators, &fg, &ag, &tg, &sg, &rg, &statg)
for _, generator := range generators {
wg.Add(1)
go func(g Generator) {
defer wg.Done()
pool <- struct{}{}
defer func() { <-pool }()
if err := g.Generate(); err != nil {
errors <- err
}
}(generator)
}
go func() {
wg.Wait()
close(finished)
}()
select {
case <-finished:
return nil
case err := <-errors:
if err != nil {
return err
}
}
return nil
}
runTasks 函数使用带缓冲的 channel,限制最多只能开启50个 goroutines,来创建所有生成器,将它们添加到一个 slice 中,然后并发运行。
这些例子只是在 Go 中编写静态站点生成器的基本概念的一个很短的片段。
如果您对完整的实现感兴趣,可以在此处找到代码。
总结
写个人博客生成器是绝对的爆炸和伟大的学习实践。 使用我自己的工具创建我的博客也是非常令人满意的。
为了发布我的文章到 AWS,我还创建了 static-aws-deploy,这是另一个 Go命令行工具。
如果你想自己使用这个工具,只需要 fork repo 并更改配置。 但是,由于 Hugo 提供了所有这些和更多的功能,我没有花太多时间进行可定制性或可配置性。
当然,应该不要一直重新发明轮子,但是有时重新发明一两轮可能是有益的,可以帮助你在这个过程中学到很多东西。