【译】用Go实现一个静态博客生成器

【译】用Go实现一个静态博客生成器

静态站点生成器是一种工具,给一些输入(例如,markdown),使用HTML,CSS和JavaScript生成完全静态的网站。

为什么这很酷?一般来说,搭建一个静态网站更容易,而且通常运行也会比较快一些,同时占用资源也更少。虽然静态网站不是所有场景的最佳选择,但是对于大多数非交互型网站(如博客)来说,它们是非常好的。

在这篇文章中,我将讲述我用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 提供了所有这些和更多的功能,我没有花太多时间进行可定制性或可配置性。

当然,应该不要一直重新发明轮子,但是有时重新发明一两轮可能是有益的,可以帮助你在这个过程中学到很多东西。:)

英文原文

1 Star2 Stars3 Stars4 Stars5 Stars (1 人打了分, 平均分:5.00,总分:5)
Loading...

发表评论

电子邮件地址不会被公开。