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, ¶ms); !isValid {
return resp
}
article := &model.Article{
Title: params.Title,
Slug: params.Slug,
Desc: params.Desc,
Content: params.Content,
Draft: ¶ms.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
}
服务包含三个基本的接口,NewXXService
,Start
及 Stop
:
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。