前言

我从 9 月的 27 号开始阅读 Miniflux 项目的代码,阅读成熟的开源项目就像在阅读一本适合自己的书,节奏虽慢但充实。因为快接近阅读这个项目代码的尾声,故整理这篇博文作为过程的记录和分享。

开始之前简单介绍一下 Miniflux。Miniflux 是一个开源的 RSS 订阅服务实现,类似 Tiny Tiny RSS,使用过 RSS 订阅服务的朋友应该不陌生,部署 Miniflux 服务相当于拥有了自己的 Feedly 服务。该项目是基于 Go 和 Vanilla JS (没有使用任何框架的原生 JavaScript API) 的 SaaS APP 实现,提倡尽可能少的外部依赖,简单可维护易扩展,所以这个项目会有很多基础功能的实现(这些细节在参与 Web 框架本身的开发时才会涉及,平时基于 Web 框架的项目开发,比如 Django/Rails 会自带这些功能,直接用就可以)。

本文会通过少量的代码,记录阅读过程中发现的有趣的点,如果你对这些点感兴趣,再去阅读代码即可。我更希望这篇博客像在介绍一本书,让你知道这本书哪里有趣,然后你再自己去判断适不适合你,或者有哪些点适合你。读书不一定要整本通读,发现感兴趣的点,只读感兴趣的部分,也是一种不错的选择。

软件工程应该是件有趣的事,或者说,作为从业者我们应该去发现那些有趣的点,穿透高度抽象带来的认知迷雾。

内容

不使用 ORM 如何实现数据库抽象

如果你想在项目内使用 Golang 标准库的 database/sql 来封装 SQL 操作,可以参考。

Miniflux 中的数据库表的管理,代码分成两类,第一类是对这个表的 SQL 操作的封装,在 storage 包内,不同的表对应不同的 storage 文件夹内的文件,比如 users 表对应 user.go 文件,user.go 中一个操作:

// SetLastLogin updates the last login date of a user.
func (s *Storage) SetLastLogin(userID int64) error {
	query := `UPDATE users SET last_login_at=now() WHERE id=$1`
	_, err := s.db.Exec(query, userID)
	if err != nil {
		return fmt.Errorf(`store: unable to update last login date: %v`, err)
	}

	return nil
}

第二类是对表结构和相关的改动请求的抽象,在 model 包内,不同的表对应不同的 model 包内的文件,比如 users 表对应 user.go 文件,user.go 中的两个抽象:

// User represents a user in the system.
type User struct {
	ID                     int64      `json:"id"`
	Username               string     `json:"username"`
	Password               string     `json:"-"`
	IsAdmin                bool       `json:"is_admin"`
	Theme                  string     `json:"theme"`
	Language               string     `json:"language"`
	Timezone               string     `json:"timezone"`
	EntryDirection         string     `json:"entry_sorting_direction"`
	EntryOrder             string     `json:"entry_sorting_order"`
	Stylesheet             string     `json:"stylesheet"`
	GoogleID               string     `json:"google_id"`
	OpenIDConnectID        string     `json:"openid_connect_id"`
	EntriesPerPage         int        `json:"entries_per_page"`
	KeyboardShortcuts      bool       `json:"keyboard_shortcuts"`

    // Part of the code has been omitted...

}

// UserCreationRequest represents the request to create a user.
type UserCreationRequest struct {
	Username        string `json:"username"`
	Password        string `json:"password"`
	IsAdmin         bool   `json:"is_admin"`
	GoogleID        string `json:"google_id"`
	OpenIDConnectID string `json:"openid_connect_id"`
}

第一个结构体对应整个 users 表的记录,当需要创建用户的时候,创建成功后,返回的就是这个结构体的实例。

第二个结构体对应的创建用户请求时用户提供的实际数据。创建一个用户时,只会要求用户提供必要的信息(比如:用户名/密码),非必要的信息使用数据库字段默认值(用户通过编辑 profile 的方式进行信息补充)。在 Storage 包的 user.go 中会有消费必要信息的对应方法,比如:

func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*model.User, error) {

    // Part of the code has been omitted...

    query := `
		INSERT INTO users
			(username, password, is_admin, google_id, openid_connect_id)
		VALUES
			(LOWER($1), $2, $3, $4, $5)
		RETURNING
			id,
			username,
			is_admin,
			language,
			theme,
			timezone,
			entry_direction,
			entries_per_page,
			keyboard_shortcuts,

            // Part of the code has been omitted...

	`

	tx, err := s.db.Begin()
	if err != nil {
		return nil, fmt.Errorf(`store: unable to start transaction: %v`, err)
	}

	var user model.User
	err = tx.QueryRow(
		query,
		userCreationRequest.Username,
		hashedPassword,
		userCreationRequest.IsAdmin,
		userCreationRequest.GoogleID,
		userCreationRequest.OpenIDConnectID,
	).Scan(
		&user.ID,
		&user.Username,
		&user.IsAdmin,
		&user.Language,
		&user.Theme,
		&user.Timezone,
		&user.EntryDirection,
		&user.EntriesPerPage,
		&user.KeyboardShortcuts,

        // Part of the code has been omitted...

	)
	if err != nil {
		tx.Rollback()
		return nil, fmt.Errorf(`store: unable to create user: %v`, err)
	}

    // Part of the code has been omitted...

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf(`store: unable to commit transaction: %v`, err)
	}

	return &user, nil
}

在上面这个方法中,可以看到创建用户请求的结构体,作为了函数参数传递给了操作相关的函数。创建成功后,用户的完整信息会作为 User 结构体的实例,从函数返回。

通过上面基于代码功能的分类,将数据抽象和操作分离,就可以相对简单/清晰的实现基于 database/sql 对管理数据库的抽象。

如何实现数据库的 migration

因为没有使用 ORM,所以 Miniflux 中的数据库 migration 操作也是自己实现的,大概的思路是:

在项目中实际上是将所有 migration 相关的数据库改动,封装成一个个独立的 func(tx *sql.Tx) error 类型的函数值:

var migrations = []func(tx *sql.Tx) error{
    func(tx *sql.Tx) (err error) {
        sql := `
            CREATE TABLE schema_version (
                version text not null
            );

            CREATE TABLE users (
                id serial not null,
                username text not null unique,
                password text,
                is_admin bool default 'f',
                language text default 'en_US',
                timezone text default 'UTC',
                theme text default 'default',
                last_login_at timestamp with time zone,
                primary key (id)
            );
            # Part of the code has been omitted...
        `
        _, err = tx.Exec(sql)
        return err
    },
    // Part of the code has been omitted...
}

其中:[]func(tx *sql.Tx) error 切片的长度就是 migration 的版本号,数据库中会记录当前项目最后一次执行的 migration 的版本号,项目启动时,如果数据库中的最新版本号跟切片的长度一致,则不需要执行 migration,如果数据库中的版本号小于切片的长度,则从: 版本号 - 1 为索引的切片成员开始,执行对应成员内包含的数据库事务操作直到切片末尾,并将数据库中的版本号更新为最新的切片长度。

// Migrate executes database migrations.
func Migrate(db *sql.DB) error {
	var currentVersion int
	db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)
    // Part of the code has been omitted...
	for version := currentVersion; version < schemaVersion; version++ {
		newVersion := version + 1

		tx, err := db.Begin()
		if err != nil {
			return fmt.Errorf("[Migration v%d] %v", newVersion, err)
		}

		if err := migrations[version](tx); err != nil {
			tx.Rollback()
			return fmt.Errorf("[Migration v%d] %v", newVersion, err)
		}

		if _, err := tx.Exec(`DELETE FROM schema_version`); err != nil {
			tx.Rollback()
			return fmt.Errorf("[Migration v%d] %v", newVersion, err)
		}

		if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, newVersion); err != nil {
			tx.Rollback()
			return fmt.Errorf("[Migration v%d] %v", newVersion, err)
		}

		if err := tx.Commit(); err != nil {
			return fmt.Errorf("[Migration v%d] %v", newVersion, err)
		}
	}

	return nil
}

原生 JavaScript 如何实现快捷键

类似 ChatGPT 最近加入的快捷键。在 ChatGPT Web 页面,通过 Ctrl+/ 可以看到所有的快捷键(Github/Jira 查看所有快捷键的操作是输入 ? 号),很多 SaaS App 都会有快捷键支持,有兴趣可以自己探索一下。

Miniflux 项目中有这些快捷键的实现,且不同于很多 SaaS App 中 Ctrl+Key 形式的快捷键,Miniflux 的快捷键支持字母组合,比如:g u(即:先按 g 再按 u),这种快捷键在很大程度上避免了跟系统全局快捷键冲突的可能,是一种相对良好的快捷键实现方案。

大概说一下组合快捷键的实现思路:

在全局对象创建一个队列:queue,一个包含所有组合快捷键首字母的数组:trigger,其中 queue 用于跟踪多个事件的输入。如果当前事件对应的 key 在 trigger 数组内且 queue 为空,则 key 是组合快捷键的首字母,不触发快捷键,push 当前 key 到 queue 队列内,等待接下来的其他事件。

在接下来的事件中,再次判断事件对应的 key 是否是组合快捷键首字母。还是通过 key 是否在 trigger 数组内和 queue 是否为空来判断,如果 key 不在 trigger 数组内且 queue 已经有一个成员了,即为一个完整的组合快捷键,触发对应的操作。

另外,在设计快捷键系统的时候,通过 event 对象的 target.tagName 属性判断当前事件关联的 element 是否是输入框,如果是则不触发快捷键,这块的思路也值得参考。

原生 JavaScript 如何实现触摸设备快捷键(滑动删除/双击翻页)

这个效果类似 iPhone 邮件客户端中,新邮件左滑已读,右滑归档的效果,Miniflux 项目中有这些触屏快捷键的实现。

滑动动画的实现思路:

通过 JavaScript 创建一个全局实例,这个全局的实例会在多个触控事件间记录最新的触控坐标(即手指触摸屏幕的坐标)信息。监听:touchstarttouchmovetouchend 事件,touchstart 对应触控操作开始,touchmove 对应点击后不松开手指移动的操作(可以认为是许多触控操作的集合,类似视频跟帧画面的关系),touchend 对应松开手指的操作。

这些事件被触发的时候,每个事件的 event 对象中会包含点击发生时的坐标,将触控开始时的坐标作为初始坐标存储到全局实例上,在后续的任何一个 touchmove 事件中,计算 X 轴方向上相对初始坐标的距离,从而得出手指的实时相对距离。有了实时相对距离,通过 JavaScript 实时更新目标 element 的 CSS 属性 translateX 为该相对距离的值,这样就有了按住滑动的动画效果了。

双击翻页的实现思路:

通过 touchend 记录第一次点击的时间戳,在下一次 touchend 事件触发的时候,如果当前的时间戳和上一次的时间戳的差值小于 200ms 即为双击。

如何整合 Prometheus

如何通过 client_golang 实现 /metrics API,将项目整合到 Prometheus。

Miniflux 项目有完整的 Prometheus 集成,并且仓库中提供了对应的 Grafana 可视化的 JSON 文件。如果你也希望为自己的项目整合 Prometheus,可以参考。

Miniflux 中主要使用了:GaugeHistogram 两种度量类型,前者适用于实时状态数据(CPU利用率,负载,内存使用率,温度,湿度这类数据,没有明确的开始和结束),后者适用于有特定时间分布的数据(有明确的开始和结束。比如:请求的持续时间,数据库查询时间,文件 IO 持续时间,队列等待时间,服务间调用时间,垃圾收集持续时间,用户会话持续时间)。

原生 JavaScript 如何通过 Service Worker 实现离线交互

在客户端设备断线的时候,基于 JavaScript 和本地的缓存,告知用户网络失败的原因和推荐的操作,这块的实现会让用户觉得这是一个 App 而不是 Web。

如何实现高质量的 SaaS 程序

Heroku 的创始人总结了这块的一些原则,见:The Twelve-Factor APP

Miniflux 项目本身遵循了这套方法。

如何基于环境变量/配置文件实现配置管理

有在 Heroku 这类平台部署过项目的小伙伴应该不陌生,这类平台会要求用户提供定制化当前项目所需的环境变量和值,所以这块属于常用的技能。

配置文件本身就是:KEY=Value 的形式,KEY 是环境变量,Value 是值,每行一个。

遍历思路是通过:os.Environ() 获取到所有的运行时环境变量,假设返回值是:

[]string{"LOG_FILE=./stdout.log", "PROXY_PRIVATE_KEY=RSA...XXX" }

遍历该返回值,在循环内通过 strings.SplitN 获得每次遍历的 key 和对应的 value,并映射逻辑操作:

for _, line := range lines {
    fields := strings.SplitN(line, "=", 2)
    key := strings.TrimSpace(fields[0])
    value := strings.TrimSpace(fields[1])
    switch key {
    case "LOG_FILE":
        p.opts.logFile = parseString(value, defaultLogFile)
    // Part of the code has been omitted...
    }
}

配置项就跟相关的代码逻辑关联起来了。

配置文件本身是环境变量键值对的按行存储,因此只需通过 Scanner 按行扫描文件内容,再经由上面的逻辑进行处理即可。

基于 Golang 的泛型,如何实现一个多类型通用的 slice 去重函数

如下:

// removeDuplicate removes duplicate entries from a slice
func removeDuplicates[T string | int](sliceList []T) []T {
    allKeys := make(map[T]bool)
    list := []T{}
    for _, item := range sliceList {
        if _, value := allKeys[item]; !value {
            allKeys[item] = true
            list = append(list, item)
        }
    }
    return list
}

Golang 如何实现命令行交互式窗口

比如在交互式的命令行界面内,读取用户名和密码,读取密码的时候具备隐藏效果(类似 Linux 环境内输入用户密码的效果)。

这块可以使用 golang.org/x/term,实现如下:

func askCredentials() (string, string) {
	fd := int(os.Stdin.Fd())

	if !term.IsTerminal(fd) {
		printErrorAndExit(fmt.Errorf("this is not a terminal, exiting"))
	}

	fmt.Print("Enter Username: ")

	reader := bufio.NewReader(os.Stdin)
	username, _ := reader.ReadString('\n')

	fmt.Print("Enter Password: ")

	state, _ := term.GetState(fd)
	defer term.Restore(fd, state)
	bytePassword, _ := term.ReadPassword(fd)

	fmt.Printf("\n")
	return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
}

如何整合 systemd 的 watchdog 和 notify socket

这块的功能和 k8s 的 Readiness Probe,Liveness Probe 作用类似,即在项目代码启动时检查是否就绪,在项目代码运行过程中,每隔一段时间发起一个检测操作确保项目代码持续可靠运行。

这块我在我的推特文章有详细的解释。

Golang 如何在编译的时候强制要求类型实现了特定接口

强制某个类型实现了某个接口,否则编译失败。

// Making sure that we're adhering to the autocert.Cache interface.
var _ autocert.Cache = (*CertificateCache)(nil)

autocert.Cache 是一个接口类型:

// Cache is used by Manager to store and retrieve previously obtained certificates
// and other account data as opaque blobs.
//
// Cache implementations should not rely on the key naming pattern. Keys can
// include any printable ASCII characters, except the following: \/:*?"<>|
type Cache interface {
	// Get returns a certificate data for the specified key.
	// If there's no such key, Get returns ErrCacheMiss.
	Get(ctx context.Context, key string) ([]byte, error)

	// Put stores the data in the cache under the specified key.
	// Underlying implementations may use any data storage format,
	// as long as the reverse operation, Get, results in the original data.
	Put(ctx context.Context, key string, data []byte) error

	// Delete removes a certificate data from the cache under the specified key.
	// If there's no such key in the cache, Delete returns nil.
	Delete(ctx context.Context, key string) error
}

Miniflux 项目通过强制 CertificateCache 结构体实现接口类型,确保证书有良好的集中存储实现。

原生的 SQL 如何实现 UPSERT 操作

在 Miniflux 项目中,RSS feed 条目中如果包含附件(音频/图片的地址信息等),附件的存储就是通过 SQL 的 UPSERT 操作进行存储。UPSERT 操作即:特定的资源,如果资源对应的 ID 存在则更新记录,否则创建记录。

代码如下:

func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error {
	enclosureURL := strings.TrimSpace(enclosure.URL)
	if enclosureURL == "" {
		return nil
	}

	query := `
		INSERT INTO enclosures
			(url, size, mime_type, entry_id, user_id, media_progression)
		VALUES
			($1, $2, $3, $4, $5, $6)
		ON CONFLICT (user_id, entry_id, md5(url)) DO NOTHING
		RETURNING
			id
	`
	if err := tx.QueryRow(
		query,
		enclosureURL,
		enclosure.Size,
		enclosure.MimeType,
		enclosure.EntryID,
		enclosure.UserID,
		enclosure.MediaProgression,
	).Scan(&enclosure.ID); err != nil && err != sql.ErrNoRows {
		return fmt.Errorf(`store: unable to create enclosure: %w`, err)
	}

	return nil
}

上面的代码中,如果某个用户的某条 feed 条目包含附件,通过附件的 url 进行判断,如果附件已经有相关的记录则跳过,否则创建。

Golang 项目如何获取客户端来源 IP

常用的技能。这里的思路是,首先通过特定的请求头来判断,在 http 的请求头中可能会包含真实来源 IP,头部本身是类似:1.1.1.1,2.2.2.2 的字符串,表示客户端 IP 和经过的代理服务器 IP,所以如果这些头部值不为空,则根据 , 进行字符串切割,然后取切割后的字符串数组的第一个元素。

// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
func FindClientIP(r *http.Request) string {
	headers := []string{"X-Forwarded-For", "X-Real-Ip"}
	for _, header := range headers {
		value := r.Header.Get(header)

		if value != "" {
			addresses := strings.Split(value, ",")
			address := strings.TrimSpace(addresses[0])
			address = dropIPv6zone(address)

			if net.ParseIP(address) != nil {
				return address
			}
		}
	}

	// Fallback to TCP/IP source IP address.
	return FindRemoteIP(r)
}

如果这些请求头的值为空,则根据网络层的来源 IP 作为请求的真实 IP,如果网络层的来源 IP 获取失败,则将 127.0.0.1 作为来源 IP:

// FindRemoteIP returns remote client IP address.
func FindRemoteIP(r *http.Request) string {
	remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		remoteIP = r.RemoteAddr
	}
	remoteIP = dropIPv6zone(remoteIP)

	// When listening on a Unix socket, RemoteAddr is empty.
	if remoteIP == "" {
		remoteIP = "127.0.0.1"
	}

	return remoteIP
}

原生 JavaScript 如何捕获并区分单击和双击

通过捕获 touchend 事件来实现的,通过一个全局的对象保存每次 touchend 事件发生时的时间戳,比对当前事件的时间戳和上一次事件的时间戳的间隔,如果小于 200ms 即为双击,否则为单击。

如何实现 Web 分页

这块是非常常用的技能,本质上是通过构建特定 OFFSET 和 LIMIT 的 SQL 查询来实现的,当前项目也不例外。

如何通过 Server-Timing 实现 App 性能的跟踪

Server-Timing 是一个 HTTP 的响应头,通过 Server-Timing,我们可以在 Chrome 的 DevTool 内看到特定 API 在服务器端执行所花费的时间,比如上面的截图中的 /unread API:

  • 服务器通过 SQL 统计所有未读的文章的数目花了 4ms
  • 服务器通过 SQL 抓取最新的 20 篇文章的内容花了 12ms
  • 服务器渲染模板花了 3ms

不使用框架,如何实现 modal(互动窗口)

常用的技能。这里以快捷键:? 的 modal 为例,当我们点击 ? 的时候,会出现快捷键的详细页面。

这里的思路是通过 Javascript 操作 dom 树,出现的时候是将默认隐藏的 <template>...</template> 中的内容作为新创建的 <div></div> 的内容,并将新创建的 <div></div> 添加到 body 末尾。

import template content

因为 <div></div> 的内容中包含了默认的 CSS 类名,因此,一旦相关的 DOM 被创建,就会有对应的位于左侧的窗口。

static open(fragment, initialFocusElementId) {

	// Part of the code has been omitted...

	let container = document.createElement("div");
	container.id = "modal-container";
	container.setAttribute("role", "dialog");

	// 这里的 fragment 对应的 template 中的内容,通过 importNode 并配置第二个参数为 true(即递归复制)
	container.appendChild(document.importNode(fragment, true));
	document.body.appendChild(container);

	// Part of the code has been omitted...

}

如何使用原生 JavaScript 给滑动操作添加非线性的动画效果

我们在左滑/右滑一篇 RSS 项的时候,当滑动的距离大于 75 Pixel 的时候,会有划不动的反馈,这个就是非线性动画效果的应用。

大概的原理:

给实时相对距离添加一个阈值,如果超过这个阈值,手指滑动的距离和 element 移动的速度的关系从线性转变成平方根的关系。

下图是手指移动距离和 element 移动距离的关系图:

如何使用 Golang template 系统

这块会对应很多 Web 框架的模板系统,比如 Django/Rails,如果你的项目中使用了 Golang Template 可以看看。

Golang Template 如何添加 Ansible Filters 类的函数(比如 dict2items)

项目中实现了很多类似的函数,如果你的项目中需要实现类似 filters 函数,可以参考。

如何使用构建器模式

Miniflux 项目中,在构建复杂的请求和 SQL 查询的时候会基于构建器模式。这里面既有 Golang 的实践,也有原生 JavaScript 的实践。

下面是一个 SQL 请求的构建器模式实践的示例分析。

Miniflux 本身会轮询订阅的站点,然后抓取站点的文章存储到数据库内,下面就是对抓取到的文章进行查询所包含的数据库操作的逻辑。

因为文章管理会涉及到不同的用户/不同的分类/不同的文章状态等条件,比较复杂,所以这里使用了构建器。

通过结构体对 SQL 语句进行抽象:

// EntryQueryBuilder builds a SQL query to fetch entries.
type EntryQueryBuilder struct {
	store           *Storage
	args            []interface{}
	conditions      []string
	sortExpressions []string
	limit           int
	offset          int
	fetchEnclosures bool
}

// NewEntryQueryBuilder returns a new EntryQueryBuilder.
func NewEntryQueryBuilder(store *Storage, userID int64) *EntryQueryBuilder {
	return &EntryQueryBuilder{
		store:      store,
		args:       []interface{}{userID},
		conditions: []string{"e.user_id = $1"},
	}
}

通过实现不同的具体构建器,对 SQL 语句的逻辑进行单端封装:

// WithFeedID filter by feed ID.
func (e *EntryQueryBuilder) WithFeedID(feedID int64) *EntryQueryBuilder {
	if feedID > 0 {
		e.conditions = append(e.conditions, fmt.Sprintf("e.feed_id = $%d", len(e.args)+1))
		e.args = append(e.args, feedID)
	}
	return e
}

// WithCategoryID filter by category ID.
func (e *EntryQueryBuilder) WithCategoryID(categoryID int64) *EntryQueryBuilder {
	if categoryID > 0 {
		e.conditions = append(e.conditions, fmt.Sprintf("f.category_id = $%d", len(e.args)+1))
		e.args = append(e.args, categoryID)
	}
	return e
}

// Part of the code has been omitted...

在具体的封装方面,因为 Golang 支持指针类型接收者方法,所以项目中没有用到构建器模式的流式调用的语法,如下:

builder := h.store.NewEntryQueryBuilder(userID)
builder.WithFeedID(feedID)
builder.WithCategoryID(categoryID)
builder.WithStatuses(statuses)

// Part of the code has been omitted...

entries, err := builder.GetEntries()

如果按照流式调用的语法,也可以:

builder := h.store.NewEntryQueryBuilder(userID)
builder.WithFeedID(feedID).WithCategoryID(categoryID).WithStatuses(statuses).GetEntries()

最后,GetEntries 方法是执行逻辑,转换结构体为具体的语句,并执行请求:

// GetEntry returns a single entry that match the condition.
func (e *EntryQueryBuilder) GetEntry() (*model.Entry, error) {
	e.limit = 1
	entries, err := e.GetEntries()

    // Part of the code has been omitted...

	return entries[0], nil
}

// GetEntries returns a list of entries that match the condition.
func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
	query := `
		SELECT
			e.id,
			e.user_id,
			e.feed_id,
            # Part of the code has been omitted...
			f.title as feed_title,
			f.feed_url,
			f.site_url,
            # Part of the code has been omitted...
		FROM
			entries e
		LEFT JOIN
			feeds f ON f.id=e.feed_id
		LEFT JOIN
			categories c ON c.id=f.category_id
		LEFT JOIN
			feed_icons fi ON fi.feed_id=f.id
		LEFT JOIN
			users u ON u.id=e.user_id
		WHERE %s %s
	`

	condition := e.buildCondition()
	sorting := e.buildSorting()
	query = fmt.Sprintf(query, condition, sorting)

	rows, err := e.store.db.Query(query, e.args...)

    // Part of the code has been omitted...

	entries := make(model.Entries, 0)
	for rows.Next() {
		var iconID sql.NullInt64
		var tz string
		var hasEnclosure sql.NullBool

		entry := model.NewEntry()

		err := rows.Scan(
			&entry.ID,
			&entry.UserID,
			&entry.FeedID,
            // Part of the code has been omitted...
			&entry.Feed.Title,
			&entry.Feed.FeedURL,
			&entry.Feed.SiteURL,
            // Part of the code has been omitted...
		)

        // Part of the code has been omitted...
	}
	return entries, nil
}

构建器模式通过实现不同的具体构建器(Concrete Builder),即上面的代码中 WithXXX 开头的方法,将 SQL 的不同语句(WHEN/ORDER BY/LIMIT)抽象成不同的具体构建器。通过组合不同的具体构建器,实现良好的细粒度和兼容性控制。

如何实现文章的预计阅读时间功能

这块的大概思路:

给每个用户设置一个默认的阅读速度,比如:每分钟 200 个 rune,通过:

每篇文章总长度 / 阅读速度

即可得到阅读需要的大概时间(这里面文章总长度是以 Rune 为单位,而不是字节,多语言友好的体现)。

如何绑定 XML,实现 XML 数据到 Golang 结构体的序列化和反序列化

常用技能,类似 JSON 序列化和反序列化。

如何实现 i18N

常用的技能,多语言支持基本上是标配功能。

这里我觉得比较有趣的点是如何实现指标的单数(singular form)和复数形式(plural form)。比如:

singular form

plural form

上面的截图中,如果绑定的 Passkey 只有 1 个时,Remove 1 PasskeyPasskey 是单数,在第二张截图中是复数。

大概的思路:实现模板过滤器,根据用户所在的地区,确认语言对应的判断函数(不同语言的单复数形式的判断函数是不同的)。判断单复数形式的函数只做一件事,就是返回索引。以英语为例,一个单词的单复数形式只有 2 种。那么在英语对应的 i18N 文件中,对应按钮的文字就会有两种形式:

{
	// Part of the code has been omitted...
	"page.settings.webauthn.delete": [
		"Remove %d passkey",
		"Remove %d passkeys"
	]
}

判断单复数形式的函数将个数作为参数传入,如果个数大于 1 则返回英语对应的 i18N 文件中复数形式的表达式的索引,也就是上面 JSON 中 page.settings.webauthn.delete 的索引(数组索引从 0 开始):1。否则返回索引: 0

有了对应的索引,再通过 fmt.Sprintf() 获取索引的表达式,并渲染表达式中的个数,就得到对应的字符串了。

原生 SQL 的用法

项目中不使用 ORM,因此有很多 SQL 命令可以参考,比如:创建索引,连接查询,全文搜索等。

关于 SERIAL 和 BIGSERIAL:

Miniflux 中很多的表使用 SERIAL 作为主键类型,PostgreSQL 在遇到这种类型的时候,会自动创建对应的 sequence,即从 1 开始自增的整数,其中 SERIAL 是 32 位整数(4字节),BIGSERIAL 是 64 位整数(8字节)。在大多数情况下,如果不确定是否会超过 SERIAL 的限制,从一开始就使用 BIGSERIAL 可能是一种简单的前瞻性做法。

serial-bigserial

关于索引性能的评估:

索引性能可以通过 EXPLAINEXPLAIN ANALYSE 来验证,前者是显示计划操作(是否进行了顺序扫描,如何使用索引,预计返回行数,是计划的操作而不是实际执行),后者是通过实际执行来显示资源使用情况(后者会有执行时间,实际上发生了数据库查询的操作)。

可以在数据库的 session 中,通过:

set enable_indexscan = off;
set enable_bitmapscan = off;

来关闭当前 session 的索引功能,从而验证索引带来的性能提升。

下面是不使用索引的例子:

without-indexes

对比使用索引的例子:

with-indexes

索引是为了避免全表扫描,适合 WHERE/ORDER BY/GROUP BY 中出现的字段或者 join 中被选择的字段。其中唯一索引也会被用来确保字段插入记录的唯一性。

静态资源的缓存策略

这块主要是关于 HTTP 请求头: If-None-Match If-Modified-Since HTTP 响应头: ETag Last-Modified Expires 相关的客户端侧资源缓存策略,我在我的推特文章做了比较详细的解释,有兴趣可以看看。

自动切换站点主题(白天/黑夜)

这里主要包含两个部分。

第一部分:

<meta name="theme-color" content="{{ theme_color .theme "light" }}" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="{{ theme_color .theme "dark" }}" media="(prefers-color-scheme: dark)">

上面是模本未渲染时的内容,渲染完成后大概会是这个样子:

<meta name="theme-color" content="#fff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#222" media="(prefers-color-scheme: dark)">

作用是:配置移动设备地址栏的颜色。

第二部分是通过伪类选择器 :root 和媒体查询来实现,原理是颜色相关的变量覆盖。

默认 CSS 颜色相关的配置项的值会被指定为对应的变量,变量的默认值是 light 主题相关的。

body {
    font-family: var(--font-family);
    text-rendering: optimizeLegibility;
    color: var(--body-color);
    background: var(--body-background);
}

:root {
    --font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
    --body-color: #333;
    --body-background: #fff;
    --hr-border-color: #ccc;
    --title-color: #333;
    --link-color: #3366CC;
    --link-focus-color: red;
    --link-hover-color: #333;
    --link-visited-color: purple;

	/* Part of the code has been omitted... */

}

当系统颜色变化时,媒体查询会让伪类中 dark 主题的变量值覆盖默认值,网站的颜色便从 light 变成 dark。

@media (prefers-color-scheme: dark) {
	:root {
        --body-color: #efefef;
        --body-background: #222;
        --hr-border-color: #555;
        --title-color: #aaa;
        --link-color: #aaa;
        --link-focus-color: #ddd;
        --link-hover-color: #ddd;
        --link-visited-color: #f083e4;

		/* Part of the code has been omitted... */

	}
}

效果如下:

这块会涉及如何基于 Cookie 创建/恢复 Session 来管理用户的登录状态,管理用户端的一些个性化配置(比如字体/主题)等。

说一下 Miniflux 项目中的 Session 管理:

Miniflux 项目的 Session 直接存入数据库,通过 AppSession 和 UserSession 两个表来管理。

其中 AppSession 负责用户无关的公开配置,比如:CSRF,Lanuage,Theme 等这种不需要登录就能修改的配置。

UserSession 主要负责存储用户的 Token。Token 是用户成功登录后,服务器生成的一段随机字符串(30 天内有效的临时密码)。在每次登录成功后,服务器会将 Token 作为 Cookie 发送并存储在客户端(比如用户的浏览器),并将 Token 和用户 ID 的关系记录到 UserSession 表内。客户端(浏览器)在接下来的每个请求中都会携带这个 Cookie。服务器收到每个新请求,都会尝试读取 Cookie 中的 Token,并通过这个 Token 查询 UserSession 表。如果 UserSession 表中有记录,则代表用户已经登录,将请求上下文中认证相关的 KEY 标记为 true,并根据记录中的用户 ID 恢复用户的信息。这样,后续的操作就可以用该用户的身份进行,且只需检查上下文中认证相关的 KEY 是否为 true 即可知道是否是经过授权的请求。

Miniflux 项目通过 Channel 和 Goroutine 实现了一个 worker,用于执行定时任务。worker 会每隔 24 小时执行一次,其中就包含了清理 UserSession 和 AppSession 表中过期的 Session 的清理操作。实际上就是通过比对 created_at 字段,对 duration 大于用户配置的时间(比如 30 天)的记录执行 delete 操作:

DELETE FROM
    user_sessions
WHERE
    id IN (SELECT id FROM user_sessions WHERE created_at < now() - interval '%d days')

如何使用 Golang 中的 Valuer interfaceScanner interface

在项目中的 model.Session 结构体会跟数据库的 sessions 表对应,结构体中的成员 dataSessionData 结构体类型,序列化的时候会转换成 jsonb 类型存入数据库,在执行:db.Exec 或者 db.Scan 的时候,通过为 SessionData 实现上述两个接口,从而实现在对 model.Session 执行 db.Exec 或者 db.Scan 时自动序列化和反序列化 data 这个内嵌的结构体。

加密相关

常用技能。除了生成随机密码,还会包含如何实现明文密码的加密,计算哈希。在 crypto 包内。

数据库的健康检查

常用技能。标准库有 API,直接用就行。数据库健康检查的逻辑会被用在许多场景,比如代码启动的时候,作为一个命令行操作,作为一个 API(/healthcheck)。

下面的 Ansible task 是 miniflux 基于 Docker 部署时加入健康检查的实现:

- name: "Start docker miniflux image"
  docker_container:
    name: "miniflux"
    image: "miniflux/miniflux:latest"
    domainname: "miniflux"
    hostname: "miniflux"
    # Part of the code has been omitted...
    env:
      NODE_ENV: production
      RUN_MIGRATIONS: "1"
      # Part of the code has been omitted...
    healthcheck:
      test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
      interval: 10s
      timeout: 10s
      retries: 3
      start_period: 30s

在这个健康检查的命令行功能中,就包含了数据库连接和可操作性的检查。而这块 Golang 的标准库实际上已经有成熟的 API 了,我们只需要整合到命令行选项内即可。

如何实现 BasicAuth 认证

这块会包含两个部分,一个是 Prometheus Metrics 的基础认证,另外就是用户登录部分的基础认证,如何通过 bcrypt 对明文密码进行加密存储和验证等。

如何实现用户的 API KEY 认证

如果你在实现用户的 API 密钥系统,这块可以参考。

Miniflux 的实现是通过创建一个 API KEY 相关的表,将用户 ID 和每个 KEY 记录到表内。在认证的时候会通过 User 表 LEFT JOIN 这里的 API 表,然后将 API 表中的 token 列作为过滤条件,如果特定的 KEY (token 列的值)能在 LEFT JOIN 的虚拟表内查到并恢复出一个用户,即为认证成功。

Golang 标准库中的适配器模式实践

标准库中,http.Handler 接口是非常典型的适配器模式的应用场景。

整合 Let’s Encrypt 实现证书自托管的 HTTPS 服务

即实现一个无需运维的 HTTPS 服务器,服务本身在启动的时候会通过 HTTP-01/TLS-ALPN-01 自动向 Let’s Encrypt 申请证书,并在证书到期前自动刷新和重新加载证书。

Miniflux 项目中有这块的完整实现。

Golang 实现静态资源的打包(bundle)

因为 Miniflux 提倡简单交付,所以整个 Miniflux 项目在交付的时候,会被打包到一个二进制内(CSS文件/HTML文件/JS文件/图片),交付的时候也只有一个二进制而文件。

如果你对下面的主题感兴趣,可以去看看代码:

  • 如何将静态文件通过 Go Embed 打包到生产的二进制内?
  • 项目运行的时候,如何加载这些内容到内存内备用?
  • 打包这些资源的时候,如何进行压缩?
  • 模板系统是如何组织 HTML 文件的复用的?比如 layout.html 文件是如何生效的?
  • JavaScript 文件是如何生效的?

如何通过 CSRF token 规避跨站请求伪造

CSRF 是一个随机生成的字符串,Miniflux 项目将 CSRF token 作为用户无关的 Session 中内容的一部分存储在数据库内。客户端的在通过 POST 发起请求时,之前通过 GET 获取到对应的 HTML Form 表单中会含有一个隐藏的 input tag,比如:

<form action="/login" method="post">
    <input type="hidden" name="csrf" value="{{ .csrf }}">

    <label for="form-username">username:</label>
    <input type="text" name="username" id="form-username" value="default value" autocomplete="username" required autofocus>

    <label for="form-password">password:</label>
    <input type="password" name="password" id="form-password" value="default value" autocomplete="current-password" required>

    <div class="buttons">
        <button type="submit" class="button button-primary">submit</button>
    </div>
</form>

在上面的例子中,CSRF 这个 input 的 value 是一个占位符,意味着对应的值是在服务器响应 GET 请求时动态生成的。用户在带有 CSRF token 的表单内输入账号密码,点击 submit 提交表单,这样表单请求中就有 CSRF token 了。

攻击者会尝试伪造一个 POST 请求,比如一个修改密码的请求,攻击者会将这个伪造的请求包装成促销优惠/密码泄露短信(短信里面有链接),发给被攻击者,如果被攻击者之前登录过该网站,浏览器本地会有保持登录状态的 Cookie。如果目标网站没有 CSRF 策略,当被攻击者点击了链接,这个伪造的请求就会被做为正常的请求发给网站服务器,密码就被窃取了。

有 CSRF token 的网站如何抵御:

一个有 CSRF token 的网站,在任何 POST 请求被处理之前,会先执行 CSRF token 的验证逻辑,因为这个 token 是用户每次发起 POST 请求前,通过 GET 请求登录页面时,作为 html 的一部分在服务端动态生成的,攻击者伪造一个请求的时候,因为同源策略(浏览器不允许 A 的 JavaScript 通过 fetch 获取 B 的 CSRF token,不允许 A 通过 JavaScript 访问 B 含有 CSRF token 的本地缓存),这样伪造的请求就没有办法获取到有效的 CSRF token,从而会被服务器识别出来。

在服务器上,对请求进行校验的中间件中会有如下的判断逻辑:

if r.Method == http.MethodPost {
    formValue := r.FormValue("csrf")
    headerValue := r.Header.Get("X-Csrf-Token")

    if session.Data.CSRF != formValue && session.Data.CSRF != headerValue {
        slog.Warn("Invalid or missing CSRF token",
            slog.Any("url", r.RequestURI),
            slog.String("form_csrf", formValue),
            slog.String("header_csrf", headerValue),
        )

        if mux.CurrentRoute(r).GetName() == "checkLogin" {
            html.Redirect(w, r, route.Path(m.router, "login"))
            return
        }

        html.BadRequest(w, r, errors.New("invalid or missing CSRF"))
        return
    }
}

如果用户是在 login 页面发起的 POST 请求且 CSRF token 失效,让用户重新获取登录页面(服务器会动态渲染新的 CSRF token),否则直接拒绝该请求(HTTP Status Code: 400)。

现在的很多前端请求会大量的使用 AJAX 或者 fetch API 这类来发起请求,而不是 Form 表单。对于这类请求,则是通过:X-Csrf-Token 请求头来识别跨站请求攻击。

X-Csrf-Token 的值是通过构建器模式,构建 AJAX 请求来实现的:

class RequestBuilder {
    constructor(url) {
        this.callback = null;
        this.url = url;
        this.options = {
            method: "POST",
            cache: "no-cache",
            credentials: "include",
            body: null,
            headers: new Headers({
                "Content-Type": "application/json",
                "X-Csrf-Token": this.getCsrfToken()
            })
        };
    }

// Part of the code has been omitted...

    getCsrfToken() {
        let element = document.querySelector("body[data-csrf-token]");
        if (element !== null) {
            return element.dataset.csrfToken;
        }

        return "";
    }
}

在上面的 JavaScript 代码中,会通过 document.querySelector 查找当前页面,获取 body 自定义数据属性中的 CSRF token 的值(这个值跟 form 中的 CSRF token 的值是一样的),把获取到的 CSRF token 作为 AJAX 请求头,发给服务器。

如何整合 OIDC 认证

这里面会包含授权码流程的具体细节,现有的用户如何跟 OIDC 中的信息建立映射,如何基于 OIDC 中的信息自动创建用户等。

授权码流程:

用户在登录页面,通过点击授权登录的按钮,向服务器发起授权码流程的登录请求,服务器收到请求后,重定向用户到授权服务器。

重定向请求会有很多参数,比如(我做了格式化):

https://accounts.jinmiaoluo.com/realms/jinmiaoluo/protocol/openid-connect/auth?
&client_id=openid_miniflux
&code_challenge=igxNrRorNVbX0a31uiJw6z3ZWkDG9M0ujuGb58oWtys
&code_challenge_method=S256
&redirect_uri=https%3A%2F%2Fminiflux.jinmiaoluo.com%2Foauth2%2Foidc%2Fcallback
&response_type=code
&scope=openid+profile+email
&state=acc073d048014997ce8b58606f4e096b6b933e305ccf9b21

其中会包含一个随机字符串 state,一个随机字符串(codeVerifier)的哈希(基于 SHA2)code_challenge,这两个字符串的值会被记录到 Session 表内。

用户在授权服务器的登录页输入账号密码成功登录后,授权服务器会重定向用户到服务器端的回调地址(redirect_uri),并记录 code_challenge 的值备用,回调的请求会有如下参数:

https://miniflux.jinmiaoluo.com/oauth2/oidc/callback?
&state=acc073d048014997ce8b58606f4e096b6b933e305ccf9b21
&session_state=1d05b9ae-5446-4309-999c-471c8e8c6713
&code=5ea52ccf-6af7-4aec-a559-a62cdc9b366e.1d05b9ae-5446-4309-999c-471c8e8c6713.96cf1386-b0e1-4e3b-93c0-541fc18799d7

服务器收到请求,基于 code 和存储在 Session 表内的 codeVerifier,向授权服务器申请 token。此时授权服务器会校验 code 的值,并基于 codeVerifier 和 code_challenge_method 计算最新的 code_challenge,如果和之前用户登录时提交的 code_challenge 一致,则授予 token。

服务器端拿到 token 后,基于 OIDC 的 entrypoint 获取 userinfo 的查询地址(无需授权),然后基于 token 访问 userinfo 的 API 地址(需要 token 认证),获取对应的 OIDC 上的用户信息(这里面就有 OIDC 用户的 ID)。

Miniflux 在用户表记录了 OIDC 上的用户 ID(在数据库中的字段是:openid_connect_id) 和用户表中的 ID 的映射关系,如果通过 openid_connect_id 可以在用户表查到记录,则代表该记录中的用户登录成功,在登录状态相关的 UserSession 插入该用户的 UserSession Token 记录,并下发跟已登录状态相关的 Cookie。

解释一下 state:

在授权码流程中,state 的作用是为了规避跨站请求伪造,这块的攻击是针对服务器的。假设用户本地已经登录过授权服务器(有授权服务器登录页的 Cookie),用户点击了伪造的授权请求的链接(短信中的链接或者邮件中的链接)。攻击者修改了授权登录请求中的服务器回调地址,改为攻击者的服务器地址,攻击者拿到正确的授权码后,将正确的授权码发给服务器,由于 state 是 Session 级别的,现代的浏览器的同源策略禁止 A 站点获取 B 站点 URL 中的参数(state),因此攻击者拿不到正确的 state 值。因此,攻击者将伪造 state 值的请求发给服务器时,服务器发现 Session 表中 state 值和攻击者提供的 state 值不一致,这个攻击就被识别了。

解释 codeVerifier 和 code_challenge:

这块的攻击是直接针对授权服务器。假设用户被中间人攻击授权码泄露,此时攻击者直接向授权服务器发起 token 的申请,由于用户登录时在授权服务器记录了 codeVerifier 的哈希 code_challenge 的值,攻击者没有正确的 codeVerifier,导致授权服务器根据攻击者提供的 codeVerifier 计算出来的哈希和 code_challenge 的值不匹配,这个攻击就被识别了。

webauthn(passkey) 整合

webauthn(passkey) 是最近引入的一个标准,允许用户通过自己设备的人脸识别,指纹识别,快速且安全的登录 Web 站点,类似电脑开机或者手机解锁屏幕时的操作。

这块的实现有两个部分组成。

第一,设备注册。用户会先创建并登录用户,然后在个人用户配置页面绑定设备到账号内。

Miniflux Passkey settings page

这个绑定的过程可以概述为:用户触发绑定设备的请求,服务器返回创建密钥对所需要的信息发给浏览器,浏览器得到信息后通过 JavaScript API 触发本地设备创建密钥对(这里包括认证器验证用户身份,验证通过后创建密钥对等操作),密钥对创建成功后返回公钥给浏览器并保存私钥和站点信息到本地,浏览器将公钥发给服务器并将公钥保存到服务器上,服务器通过创建数据库表记录的方式建立公钥和对应用户的绑定关系。

第二,用户认证。用户触发登录

Miniflux Passkey login page

服务端返回挑战码给浏览器。浏览器通过 JavaScript API 调用认证器,认证器根据站点信息,使用对应的私钥对挑战码进行签名并返回签名和用户信息给浏览器。

Miniflux Windows authenticator page

浏览器转发这些信息(Assertion)给服务器。服务器根据 Assertion 中的用户信息,确认对应的公钥,并用公钥和挑战码校验 Assertion 中的签名,如果校验通过则登录成功。

结尾

如果你发现本文中存在任何语法错误,欢迎通过 主页 中 Email 联系我,另外你也可以在 关于 里面找到我的微信二维码。