reblog: 使用 Go + Next.js重构我的博客

发布于 2024/08/30

2022年,我曾使用django开发过一个简单的动态博客框架Cooler-dev/Cooler-old,并且用了一段时间。之后觉得功能太少,又觉得hexo比较方便,就将博客迁移到了hexo。之后我学习了go,尝试用go重写cooler的后端,然后便遥遥无期,最终由换回了hexo。去年三月份,我将博客迁移到了Next.js,并与去年七月份将博客迁移到Next.js App Router

期间我还尝试过halo,typecho,wordpress等动态博客框架,但因为对php,java等不熟练,无法进行进一步定制,就换回了hexo。

今年年初,我开始尝试开发一个动态博客框架,并取名reblog。

技术选型

fiber

对于后端,我选择了fiber,fiber是一个轻量化的go http框架,由于其以fasthttp作为底层,所以有着相当不错的性能表现。同时,fiber有着类似于express的api风格,我相对比较熟悉。

在开始开发时,fiber v3处在开发阶段,但是redish相信fiber v3正式版会先reblog一步发布()所以便使用了fiber v3作为底层。

gorm + gen

gorm是go开发中使用相当广泛的orm,功能较为齐全,但gorm并不是类型安全的,在调用一些接口时没有补全和检查:

var product Product
db.First(&product, 1) // find product with integer primary key
db.First(&product, "code = ?", "D42") // find product with code D42

如此处查询 code D42的产品,如果code拼写错误,编译阶段并不会导致错误,容易导致运行时异常。而gen通过代码生成实现了更为友好且不易出错的api:

p := query.Product
product, err := p.Where(p.Code.Eq("D42")).First()

但是代码生成让编译流程又复杂了一些.......

Next.js

我对React比较熟悉一些,博客场景又需要良好的sso,所以自然选择next。

基本架构

reblog采用前后端分离的架构,分为三个部分:后端、控制台和主题(前端)。

其中控制台嵌入到后端,用户通过访问后端对应url进入控制台。主题则通过HTTP API与后端交互,获取数据。

配置文件

yaml是一种常用的配置文件格式,相比于json更适合人类阅读,reblog采用yaml作为配置文件格式。为了未来的Serverless支持等一些不方便将配置信息明文储存在配置文件中,reblog提供了 env(ENV_NAME)这一特殊接口,在解析配置时读取环境变量,进行替换。

例如使用vercel部署,由于fork无法设置为私有,所以需要将配置文件明文储存在仓库,为了避免数据库密码等信息的泄露,可以通过此接口从环境变量中读取配置:

# reblog.yml
db:
	type: postgres
	host: localhost
	# ...
	pass: env("POSTGRES_PASSWORD")
	# ...

依赖注入

reblog通过一个 App结构体封装查询,fiber,配置信息等依赖实例:

type App struct {
	config    *config.Config
	fiber     *fiber.App
	query     *query.Query
	validator *validator.Validate
	dev       bool

	service *map[string]Service
}

在启动时生成app实例:

func Start() {
	log.Info("欢迎使用reblog")

	config := config.NewFromFile()
	app := core.NewApp(config)

	loadPlugins(app)
	app.Bootstrap()

	LoadHttp(app)

	log.Fatal(app.Listen())
}

通过传参的形式将app实例注入到handler:

func ArticleAdd(app *core.App, router fiber.Router) {
	router.Post("/:slug", func(c fiber.Ctx) error {
		a := app.Query().Article

		var params ArticleAddParams
		params.Slug = c.Params("slug")
		if isValid, resp := common.Param(app, c, &params); !isValid {
			return resp
		}

		article := &model.Article{
			Title:   params.Title,
			Slug:    params.Slug,
			Desc:    params.Desc,
			Content: params.Content,
			Draft:   &params.Draft,
		}

		err := a.Create(article)

		if err != nil {
			return common.RespServerError(c, err)
		}

		return common.RespSuccess(c, "操作成功", nil)
	}, common.Auth(app))
}

部分依赖(如验证器,查询等)直接作为 App结构体的字段,但也有一部分依赖(目前包含身份验证,Markdown渲染)动态注入,方便运行时的修改与插件侧的覆盖等操作,reblog将这些依赖统一封装为服务并以map的形式储存在 App结构体中:

package core

import "fmt"

type Service interface {
	Start() error
	Stop() error
}

服务包含三个基本的接口,NewXXServiceStartStop

type MarkdownService struct {
	app *App

	renderer *markdown.Renderer
	cache    map[string]string
}

func NewMarkdownService(app *App) *MarkdownService {
	return &MarkdownService{app: app}
}

func (s *MarkdownService) Start() error {
	s.renderer = markdown.NewRenderer()
	s.cache = make(map[string]string)

	return nil
}

func (s *MarkdownService) Stop() error {
	s.renderer = nil

	return nil
}

这部分接口会被统一调用,在注入等操作时调用,统一管理服务的生命周期:

// 注入服务到App实例
func (a *App) Inject(name string, service Service) {
	(*a.service)[name] = service
}

// 注入服务到App实例, 并生成服务名称
func AppInject[T Service](app *App, service T) {
	log.Debugf("[SERVICE] 注入服务 %s", getServiceName[T]())
	app.Inject(getServiceName[T](), service)
}

func (app *App) Service(name string) (Service, error) {
	if app.service == nil {
		return nil, fmt.Errorf("服务未初始化")
	}

	if _, isExits := (*app.service)[name]; !isExits {
		return nil, fmt.Errorf("服务 %s 不存在", name)
	}

	return (*app.service)[name], nil
}

func AppService[T Service](app *App) (T, error) {
	service, err := app.Service(getServiceName[T]())

	if err != nil {
		var zero T
		return zero, err
	}

	return service.(T), nil
}

AppInject函数接受app指针与服务,向 app.service注入服务:

func (app *App) initDefaultServices() {
	AppInject(app, NewAuthService(app))
	AppInject(app, NewMarkdownService(app))
}

注入时根据类型自动生成服务名称:

func getServiceName[T any]() string {
	var t T

	// struct
	name := fmt.Sprintf("%T", t)
	if name != "<nil>" {
		return name
	}

	// interface
	return fmt.Sprintf("%T", new(T))
}

AppService函数接收服务的类型和app指针,用以获取对应的服务实例:

auth, err := core.AppService[*core.AuthService](app)
auth.VerifyToken(token)

身份验证

reblog使用jwt实现身份验证机制,登录时生成jwt并储存到客户端:

claims := TokenClaim{
	user.Username,
	user.Password,
	jwt.RegisteredClaims{
		Issuer:    "reblog-server",
		IssuedAt:  jwt.NewNumericDate(time.Now()),
		ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
	},
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, _ := token.SignedString(a.key)

return signedToken

通过校验token实现身份验证:

parsedToken, err := jwt.ParseWithClaims(token, &TokenClaim{}, func(t *jwt.Token) (interface{}, error) {
	return a.key, nil
})

if err != nil {
	return false
}

if _, ok := parsedToken.Claims.(*TokenClaim); ok && parsedToken.Valid {
	return true
}

return false

控制台

动态博客自然是要有控制台的,控制台一般并不需要seo,且由于reblog使用go作为后端并不方便处理ssr,所以reblog控制台使用客户端渲染。由于我对react比较熟悉,所以控制台依然采用了react。但由于直接使用react开发要处理路由等基本框架,较为繁琐,所以控制台使用了umi进行开发。

umi是一个基于react的前端开发框架,封装了路由、布局等常用的api,提供了从编码到构建各个阶段的轮子,能够使开发更快速。

插件

reblog实验性的支持运行时插件功能,插件开发者可以通过覆写、扩展或修改app结构体中的字段的形式在运行时更改reblog的行为。插件将被构建为动态链接库格式,并以服务的形式在运行时被加载,通过将app注入到插件中实现插件对reblog的修改。reblog在启动时会读取配置文件中的plugin字段,plugin字段是一个指向插件路径的数组,reblog将尝试加载对应路径下的插件。

插件的路径最少有两个文件,manifest.json与插件的动态链接库。

manifest.json是插件的清单文件,用以声明插件的基本信息:

{
    "name": "Hello",
    "version": "0.1.0",
    "path": "libhello.so"
}

启动时reblog会加载清单中指定的动态链接库:

p, err := plugin.Open(path + "/" + manifest.Path)

调用 NewPlugin方法,向插件注入依赖,并获取插件实例:

factoryFuncLookup, err := p.Lookup(fmt.Sprintf("New%sPlugin", manifest.Name))
if err != nil {
	log.Warnf("[PLUGIN] 插件 %s 未实现 New%sPlugin 方法", path, manifest.Name)
}

factoryFunc := factoryFuncLookup.(func(*core.App) core.Service)

service := factoryFunc(app)

if service == nil {
	log.Warnf("[PLUGIN] 插件 %s 未返回有效服务实例", manifest.Name)
}

然后向app注入插件服务:

app.Inject(fmt.Sprintf("Plugin%s", manifest.Name), service)

此时插件便可以以服务的形式参与到reblog的运行中,并通过修改app的形式修改reblog的行为,例如增加一个handler:

func (p *HelloPlugin) Start() error {
	log.Infof("[HelloPlugin] Start")
	p.app.Fiber().All("/api/hello", func(c fiber.Ctx) error {
		return common.RespSuccess(c, "Hello from plugin!", nil)
	})

	return nil
}

插件目前暂不能修改前端的行为,前端插件正在开发中,目前设想是插件通过下面的形式对外暴露api:

const Dashboard: React.FC = () => "Plugin Page..."

export default definePlugin({
    name: "Hello",
    views: [
        {
            path: "/",
            title: "设置 Hello",
            icon: <PluginIcon />,
            component: <Dashboard />
        }
    ]
})

也可能维护一个vite插件处理插件的打包及开发阶段的mock等(?)

前端从后端或者cdn加载插件的打包产物,加载后调用插件暴露的api并以此修改前端的行为。前端插件可能更多适用于类似评论这种扩展型插件。

RSS

由于群友清羽飞扬的强烈催更,我给reblog加入了rss功能。

ThemeKit

前文提到,reblog前后端之间使用HTTP API进行交互,而直接通过HTTP API交互较为繁琐,编码时没有提示,且当api接口变动时若不修改接口调用会导致异常。基于此,reblog提供了ThemeKit(@reblog/themekit),将HTTP API封装,使主题能够通过js的形式调用api:

import ThemeKit from "@reblog/themekit";
 
const themekit = new ThemeKit({
  server: {
    url: "https://reblog.example.com",
  },
  cache: "no-store",
});

const ArticleList: React.FC = async () => {
    const articles = await themekit.getArticleList({
  		pageIndex: 1,
 		 pageSize: 10,
	});
  
    return (
    	<div>
        	{articles.map(
                article => <Link href={`/article/${article.slug}`}>{article.title}</Link>
            )}
        </div>
    )
}

详细的api接口可见ThemeKit API文档

主题

前文提到,reblog提供ThemeKit方便主题的开发,所以主题可以专心的处理样式和渲染等,而不用与fetch斗智斗勇(

目前此网站所用的主题是reblog-theme-next,使用Next.js开发。

Markdown渲染

reblog并不想过多约束主题的实现,希望让主题开发者能够得到更多自定义的便利,所以采用了前后端分离架构而非模板渲染,基于同样的目的,reblog后端除rss外,默认提供未经渲染的Markdown正文,方便主题自定义渲染逻辑以及实现。

对于reblog-theme-next,我使用了remark处理文章的渲染,并使用了shiki处理高亮:

const render = cache(async (content: string) => {
  const processor = unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype)
    .use(rehypeShiki, { theme: "github-dark" })
    .use(rehypeStringify);

  const result = await processor.process(content);
  return result.toString();
});

理论上reblog主题可以使用mdx甚至rst,latex等其他的格式作为正文格式,但可能需要主题提供对应的插件以支持渲染这些格式的正文。

评论

因为我并不像再多部署一个评论的管理面板站,所以使用了支持嵌入式面板的twikoo。

未来reblog可能会内置评论,目前暂定是重新复活基本停止维护的retalk,retalk后端与reblog一致均为fiber + gorm + gen的组合,所以可能能方便的嵌入到reblog后端中使用。

结语

reblog基本上是自用项目,当然如果你想用我也是支持的(),同时reblog的接口也比较有扩展性,欢迎来写主题或者插件(

目前的控制台似乎有点过于杂乱了,当时只想着就剩控制台这个小任务就完成了,所以控制台代码质量堪忧,准备在后端更为稳定后重新开发一个控制台,并使用自定义的ui代替antd。

正在加载评论...