前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【译】用Go实现一个静态博客生成器

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

作者头像
李海彬
发布2018-03-27 14:46:02
1.9K0
发布2018-03-27 14:46:02
举报
文章被收录于专栏:Golang语言社区
静态站点生成器是一种工具,给一些输入(例如,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 接口如下所示:

代码语言:javascript
复制
type DataSource interface {

    Fetch(from, to string) ([]string, error)

}

Generator 接口如下所示:

代码语言:javascript
复制
type Generator interface {

    Generate() error

}

很简单。每个生成器还接收一个配置结构,其中包含生成器所需的所有必要数据。

目前已有 7 个生成器:

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

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

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

实现细节

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

数据源

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

代码语言:javascript
复制
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 阶段。执行博客生成器时,最高层执行以下代码:

代码语言:javascript
复制
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,语言,日期格式等。这些设置只是全局常量,这当然不是最漂亮的解决方案,也不是最具可伸缩性的,但很简单,这也是我最高的目标。

文章

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

代码语言:javascript
复制
type Post struct {

    Name      string

    HTML      []byte

    Meta      *Meta

    ImagesDir string

    Images    []string

}

这些文章是通过遍历仓库中的文件夹,读取 meta.yml 文件,将 post.md 文件转换为 HTML 并添加图像(如果有的话)创建的。

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

代码语言:javascript
复制
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,它使用以下配置:

代码语言:javascript
复制
type ListingConfig struct {

    Posts                  []*Post

    Template               *template.Template

    Destination, PageTitle string

}

该生成器的 Generate 方法遍历该文章,组装其元数据,并根据给定的模板创建概要。 然后这些块被附加并写入相应的 index 模板。

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

代码语言:javascript
复制
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:

代码语言:javascript
复制
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)的数据结构如下所示:

代码语言:javascript
复制
type Tag struct {

    Name  string

    Link  string

    Count int

}

所以,我们有实际的标签(名称),链接到标签的列表页面和使用此标签的文章数量。这些标签是从 tagPostsMap 创建的,然后按 Count 降序排序。

代码语言:javascript
复制
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 使用如下配置:

代码语言:javascript
复制
type SitemapConfig struct {

    Posts       []*Post

    TagPostsMap map[string][]*Post

    Destination string

}

博客生成器采用基本的方法来处理 sitemap,只需使用 addURL 函数生成 URL 和图像。

代码语言:javascript
复制
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:

代码语言:javascript
复制
type StaticsConfig struct {

    FileToDestination map[string]string

    TemplateToFile    map[string]string

    Template          *template.Template

}

FileToDestination-map 表示静态文件,如图像或 robots.txt,TemplateToFile是从静态文件夹中的模板到其指定的输出路径的映射。

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

代码语言:javascript
复制
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 可以很容易的并发执行它们。

代码语言:javascript
复制
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 提供了所有这些和更多的功能,我没有花太多时间进行可定制性或可配置性。

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2017-07-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Golang语言社区 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
命令行工具
腾讯云命令行工具 TCCLI 是管理腾讯云资源的统一工具。使用腾讯云命令行工具,您可以快速调用腾讯云 API 来管理您的腾讯云资源。此外,您还可以基于腾讯云的命令行工具来做自动化和脚本处理,以更多样的方式进行组合和重用。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档