请选择 进入手机版 | 继续访问电脑版
 找回密码
 立即注册
搜索

本文来自

安全运维工具

安全运维工具

人已关注

请添加对本版块的简短描述

精选帖子

用GO实现一个静态博客生成器

[复制链接]
870 abc 发表于 2019-2-10 21:12:59
静态站点生成器是一种工具,给一些输入(例如,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 接口如下所示:

1
type DataSource interface {
2
    Fetch(from, to string) ([]string, error)
3
}
Generator 接口如下所示:

1
type Generator interface {
2
    Generate() error
3
}
很简单。每个生成器还接收一个配置结构,其中包含生成器所需的所有必要数据。

目前已有 7 个生成器:

* SiteGenerator
* ListingGenerator
* PostGenerator
* RSSGenerator
* SitemapGenerator
* StaticsGenerator
* TagsGenerator

SiteGenerator 是元生成器,它调用所有其他生成器并输出整个静态网站。

目前版本是基于 HTML 模板的,使用的是 Go 的 html/template 包。

实现细节
在本节中,我将只介绍几个有觉得有意思的部分,例如 git DataSource 和不同的 Generators。

数据源
首先,我们需要一些数据来生成我们的博客。如上所述,这些数据存储在 git 仓库。 以下 Fetch 函数涵盖了 DataSource 实现的大部分内容:

1
func (ds *GitDataSource) Fetch(from, to string) ([]string, error) {
2
    fmt.Printf("Fetching data from %s into %s...\n", from, to)
3
    if err := createFolderIfNotExist(to); err != nil {
4
        return nil, err
5
    }
6
    if err := clearFolder(to); err != nil {
7
        return nil, err
8
    }
9
    if err := cloneRepo(to, from); err != nil {
10
        return nil, err
11
    }
12
    dirs, err := getContentFolders(to)
13
    if err != nil {
14
        return nil, err
15
    }
16
    fmt.Print("Fetching complete.\n")
17
    return dirs, nil
18
}
使用两个参数调用 Fetch,from 是一个仓库 URL,to 是目标文件夹。 该函数创建并清除目标文件夹,使用 os/exec 加上 git 命令克隆仓库,最后读取文件夹,返回仓库中所有文件的路径列表。

如上所述,仓库仅包含表示不同博客文章的文件夹。 然后将具有这些文件夹路径的数组传递给生成器,它可以为仓库中的每个博客文章执行其相应的操作。

拉开帷幕
Fetch 之后,就是 Generate 阶段。执行博客生成器时,最高层执行以下代码:

1
ds := datasource.New()
2
dirs, err := ds.Fetch(RepoURL, TmpFolder)
3
if err != nil {
4
    log.Fatal(err)
5
}
6
g := generator.New(&generator.SiteConfig{
7
    Sources:     dirs,
8
    Destination: DestFolder,
9
})
10
err = g.Generate()
11
if err != nil {
12
    log.Fatal(err)
13
}
generator.New 函数创建一个新的 SiteGenerator,这是一个基础生成器,它会调用其他生成器。这里我们提供了仓库中的博客文章目录(数据源)和目标文件夹。

由于每个生成器都实现了上述接口的 Generator,因此 SiteGenerator 有一个 Generate 方法,它返回 error。 SiteGenerator 的 Generate 方法准备目标文件夹,读取模板,准备博客文章的数据结构,注册其他生成器并并发的运行它们。

SiteGenerator 还为博客注册了一些设置信息,如URL,语言,日期格式等。这些设置只是全局常量,这当然不是最漂亮的解决方案,也不是最具可伸缩性的,但很简单,这也是我最高的目标。

文章
博客中最重要的概念是 – 惊喜,惊喜 – 博客文章! 在这个博客生成器的上下文中,它们由以下数据结构表示:

1
type Post struct {
2
    Name      string
3
    HTML      []byte
4
    Meta      *Meta
5
    ImagesDir string
6
    Images    []string
7
}
这些文章是通过遍历仓库中的文件夹,读取 meta.yml 文件,将 post.md 文件转换为 HTML 并添加图像(如果有的话)创建的。

相当多的工作,但是一旦我们将文章表示为一个数据结构,那么生成文章就会很简单,看起来像这样:

1
func (g *PostGenerator) Generate() error {
2
    post := g.Config.Post
3
    destination := g.Config.Destination
4
    t := g.Config.Template
5
    staticPath := fmt.Sprintf("%s%s", destination, post.Name)
6
    if err := os.Mkdir(staticPath, os.ModePerm); err != nil {
7
      return fmt.Errorf("error creating directory at %s: %v", staticPath, err)
8
    }
9
    if post.ImagesDir != "" {
10
      if err := copyImagesDir(post.ImagesDir, staticPath); err != nil {
11
          return err
12
      }
13
    }
14
    if err := writeIndexHTML(staticPath, post.Meta.Title, template.HTML(string(post.HTML)), t); err != nil {
15
      return err
16
    }
17
    return nil
18
}
首先,我们为该文章创建一个目录,然后我们复制图像,最后使用模板创建该文章的 index.html 文件。

列表创建
当用户访问博客的着陆页时,她会看到最新的文章,其中包含文章的阅读时间和简短描述等信息。 对于此功能和归档,我实现了ListingGenerator,它使用以下配置:

1
type ListingConfig struct {
2
    Posts                  []*Post
3
    Template               *template.Template
4
    Destination, PageTitle string
5
}
该生成器的 Generate 方法遍历该文章,组装其元数据,并根据给定的模板创建概要。 然后这些块被附加并写入相应的 index 模板。

我喜欢一个媒体的功能:文章大概阅读时间,所以我实现了我自己的版本,基于一个普通人每分钟读取大约 200 个字的假设。 图像也计入整体阅读时间,并为该帖子中的每个 img 标签添加了一个常量 12 秒。这显然不会对任意内容进行扩展,但对于我惯常的文章应该是一个很好的近似值:

1
func calculateTimeToRead(input string) string {
2
    // an average human reads about 200 wpm
3
    var secondsPerWord = 60.0 / 200.0
4
    // multiply with the amount of words
5
    words := secondsPerWord * float64(len(strings.Split(input, " ")))
6
    // add 12 seconds for each image
7
    images := 12.0 * strings.Count(input, "<img")
8
    result := (words + float64(images)) / 60.0
9
    if result < 1.0 {
10
        result = 1.0
11
    }
12
    return fmt.Sprintf("%.0fm", result)
13
}
Tags
接下来,要有一种按主题归类和过滤文章的方法,我选择实现一个简单的 tag(标签) 机制。 文章在他们的 meta.yml 文件中有一个标签列表。这些标签应该列在单独的标签页上,并且点击标签后,用户应该看到带有所选标签的文章列表。

首先,我们创建一个从 tag 到 Post 的 map:

1
func createTagPostsMap(posts []*Post) map[string][]*Post {
2
result := make(map[string][]*Post)
3
    for _, post := range posts {
4
        for _, tag := range post.Meta.Tags {
5
            key := strings.ToLower(tag)
6
             if result[key] == nil {
7
                 result[key] = []*Post{post}
8
             } else {
9
                 result[key] = append(result[key], post)
10
             }
11
        }
12
    }
13
    return result
14
}
接着有两项任务要实现:

* 标签页
* 所选标签的文章列表

标签(Tag)的数据结构如下所示:

1
type Tag struct {
2
    Name  string
3
    Link  string
4
    Count int
5
}
所以,我们有实际的标签(名称),链接到标签的列表页面和使用此标签的文章数量。这些标签是从 tagPostsMap 创建的,然后按 Count 降序排序。

1
tags := []*Tag{}
2
for tag, posts := range tagPostsMap {
3
    tags = append(tags, &Tag{Name: tag, Link: getTagLink(tag), Count: len(posts)})
4
}
5
sort.Sort(ByCountDesc(tags))
标签页基本上只是包含在 tags/index.html 文件中的列表。

所选标签的文章列表可以使用上述的 ListingGenerator 来实现。 我们只需要迭代标签,为每个标签创建一个文件夹,选择要显示的帖子并为它们生成一个列表。

Sitemap 和 RSS
为了提高网络的可搜索性,最好建立一个可以由机器人爬取的 sitemap.xml。创建这样的文件是非常简单的,可以使用 Go 标准库来完成。

然而,在这个工具中,我选择使用了 etree 库,它为创建和读取 XML 提供了一个很好的 API。

SitemapGenerator 使用如下配置:

1
type SitemapConfig struct {
2
    Posts       []*Post
3
    TagPostsMap map[string][]*Post
4
    Destination string
5
}
博客生成器采用基本的方法来处理 sitemap,只需使用 addURL 函数生成 URL 和图像。

1
func addURL(element *etree.Element, location string, images []string) {
2
    url := element.CreateElement("url")
3
     loc := url.CreateElement("loc")
4
     loc.SetText(fmt.Sprintf("%s/%s/", blogURL, location))
5

6
     if len(images) > 0 {
7
         for _, image := range images {
8
            img := url.CreateElement("image:image")
9
             imgLoc := img.CreateElement("image:loc")
10
             imgLoc.SetText(fmt.Sprintf("%s/%s/images/%s", blogURL, location, image))
11
         }
12
     }
13
}
在使用 etree 创建XML文档之后,它将被保存到文件并存储在输出文件夹中。

RSS 生成工作方式相同 – 迭代所有文章并为每个文章创建 XML 条目,然后写入 index.xml。

处理静态资源
最后一个概念是静态资源,如 favicon.ico 或静态页面,如 About。 为此,该工具将使用下面配置运行 StaticsGenerator:

1
type StaticsConfig struct {
2
    FileToDestination map[string]string
3
    TemplateToFile    map[string]string
4
    Template          *template.Template
5
}
FileToDestination-map 表示静态文件,如图像或 robots.txt,TemplateToFile是从静态文件夹中的模板到其指定的输出路径的映射。

这种配置可能看起来像这样:

1
fileToDestination := map[string]string{
2
    "static/favicon.ico": fmt.Sprintf("%s/favicon.ico", destination),
3
    "static/robots.txt":  fmt.Sprintf("%s/robots.txt", destination),
4
    "static/about.png":   fmt.Sprintf("%s/about.png", destination),
5
}
6
templateToFile := map[string]string{
7
    "static/about.html": fmt.Sprintf("%s/about/index.html", destination),
8
}
9
statg := StaticsGenerator{&StaticsConfig{
10
FileToDestination: fileToDestination,
11
   TemplateToFile:    templateToFile,
12
   Template:          t,
13
}}
用于生成这些静态资源的代码并不是特别有趣 – 您可以想像,这些文件只是遍历并复制到给定的目标。

并行执行
为了使博客生成器运行更快,所有生成器应该并行执行。正因为此,它们都遵循 Generator 接口, 这样我们可以将它们全部放在一个 slice 中,并发地调用 Generate。

这些生成器都可以彼此独立工作,不使用任何全局可变状态,因此使用 channel 和 sync.WaitGroup 可以很容易的并发执行它们。

1
func runTasks(posts []*Post, t *template.Template, destination string) error {
2
    var wg sync.WaitGroup
3
    finished := make(chan bool, 1)
4
    errors := make(chan error, 1)
5
    pool := make(chan struct{}, 50)
6
    generators := []Generator{}
7

8
    for _, post := range posts {
9
        pg := PostGenerator{&ostConfig{
10
            Post:        post,
11
             Destination: destination,
12
             Template:    t,
13
        }}
14
        generators = append(generators, &pg)
15
    }
16

17
    fg := ListingGenerator{&ListingConfig{
18
        Posts:       posts[:getNumOfPagesOnFrontpage(posts)],
19
        Template:    t,
20
        Destination: destination,
21
        PageTitle:   "",
22
    }}
23

24
    …创建其他的生成器...
25

26
    generators = append(generators, &fg, &ag, &tg, &sg, &rg, &statg)
27

28
    for _, generator := range generators {
29
        wg.Add(1)
30
        go func(g Generator) {
31
            defer wg.Done()
32
            pool <- struct{}{}
33
            defer func() { <-pool }()
34
            if err := g.Generate(); err != nil {
35
                errors <- err
36
            }
37
        }(generator)
38
    }
39

40
    go func() {
41
        wg.Wait()
42
        close(finished)
43
    }()
44

45
    select {
46
    case <-finished:
47
        return nil
48
    case err := <-errors:
49
        if err != nil {
50
           return err
51
        }
52
    }
53
    return nil
54
}
runTasks 函数使用带缓冲的 channel,限制最多只能开启50个 goroutines,来创建所有生成器,将它们添加到一个 slice 中,然后并发运行。

这些例子只是在 Go 中编写静态站点生成器的基本概念的一个很短的片段。

如果您对完整的实现感兴趣,可以在此处找到代码。

总结
写个人博客生成器是绝对的爆炸和伟大的学习实践。 使用我自己的工具创建我的博客也是非常令人满意的。

为了发布我的文章到 AWS,我还创建了 static-aws-deploy,这是另一个 Go命令行工具。

如果你想自己使用这个工具,只需要 fork repo 并更改配置。 但是,由于 Hugo 提供了所有这些和更多的功能,我没有花太多时间进行可定制性或可配置性。

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

英文原文https://zupzup.org/static-blog-g ... pzup/blog-generator
http://blog.studygolang.com/2017/06/static-blog-generator-go/



回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表