reblog: 使用go+Next.JS重构我的博客

前往Redish101 Blog查看效果

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 + gengorm是go开发中使用相当广泛的orm,功能较为齐全,但gorm并不是类型安全的,在调用一些接口时没有补全和检查:govar product Productdb.First(&product, 1) // find product with integer primary keydb.First(&product, "code = ?", "D42") // find product with code D42如此处查询 codeD42的产品,如果code拼写错误,编译阶段并不会导致错误,容易导致运行时异常。而gen通过代码生成实现了更为友好且不易出错的api:gop := query.Productproduct, 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无法设置为私有,所以需要将配置文件明文储存在仓库,为了避免数据库密码等信息的泄露,可以通过此接口从环境变量中读取配置:yaml# reblog.ymldb: type: postgres host: localhost # ... pass: env("POSTGRES_PASSWORD") # ...## 依赖注入reblog通过一个 App结构体封装查询,fiber,配置信息等依赖实例:gotype App struct { config *config.Config fiber *fiber.App query *query.Query validator *validator.Validate dev bool service *map[string]Service}在启动时生成app实例:gofunc Start() { log.Info("欢迎使用reblog") config := config.NewFromFile() app := core.NewApp(config) loadPlugins(app) app.Bootstrap() LoadHttp(app) log.Fatal(app.Listen())}通过传参的形式将app实例注入到handler:gofunc 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结构体中:gopackage coreimport "fmt"type Service interface { Start() error Stop() error}服务包含三个基本的接口,NewXXServiceStartStopgotype 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}这部分接口会被统一调用,在注入等操作时调用,统一管理服务的生命周期:go// 注入服务到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`注入服务:gofunc (app *App) initDefaultServices() { AppInject(app, NewAuthService(app)) AppInject(app, NewMarkdownService(app))}注入时根据类型自动生成服务名称:gofunc getServiceNameT any string { var t T // struct name := fmt.Sprintf("%T", t) if name != “” { return name } // interface return fmt.Sprintf("%T", new(T))}````AppService函数接收服务的类型和app指针,用以获取对应的服务实例:```goauth, err := core.AppService[*core.AuthService](app)auth.VerifyToken(token)```## 身份验证reblog使用jwt实现身份验证机制,登录时生成jwt并储存到客户端:```goclaims := 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实现身份验证:```goparsedToken, 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是插件的清单文件,用以声明插件的基本信息:```go{ "name": "Hello", "version": "0.1.0", "path": "libhello.so"}```启动时reblog会加载清单中指定的动态链接库:```gop, err := plugin.Open(path + "/" + manifest.Path)```调用NewPlugin方法,向插件注入依赖,并获取插件实例:```gofactoryFuncLookup, 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注入插件服务:```goapp.Inject(fmt.Sprintf("Plugin%s", manifest.Name), service)```此时插件便可以以服务的形式参与到reblog的运行中,并通过修改app的形式修改reblog的行为,例如增加一个handler:```gofunc (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:```tsxconst Dashboard: React.FC = () => "Plugin Page..."export default definePlugin({ name: "Hello", views: [ { path: "/", title: "设置 Hello", icon: <PluginIcon />, component: <Dashboard /> } ]})```> 也可能维护一个vite插件处理插件的打包及开发阶段的mock等(?)前端从后端或者cdn加载插件的打包产物,加载后调用插件暴露的api并以此修改前端的行为。前端插件可能更多适用于类似评论这种扩展型插件。## RSS由于群友[清羽飞扬](https://liushen.fun)的强烈催更,我给reblog加入了rss功能。## ThemeKit前文提到,reblog前后端之间使用HTTP API进行交互,而直接通过HTTP API交互较为繁琐,编码时没有提示,且当api接口变动时若不修改接口调用会导致异常。基于此,reblog提供了ThemeKit(@reblog/themekit),将HTTP API封装,使主题能够通过js的形式调用api:```tsximport 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} )} )}详细的api接口可见[ThemeKit API文档](https://reblog-docs.redish101.top/develop/themekit)。## 主题前文提到,reblog提供ThemeKit方便主题的开发,所以主题可以专心的处理样式和渲染等,而不用与fetch斗智斗勇(目前此网站所用的主题是[reblog-theme-next](https://github.com/Redish101/reblog-theme-next),使用Next.js开发。## Markdown渲染reblog并不想过多约束主题的实现,希望让主题开发者能够得到更多自定义的便利,所以采用了前后端分离架构而非模板渲染,基于同样的目的,reblog后端除rss外,默认提供**未经渲染**的Markdown正文,方便主题自定义渲染逻辑以及实现。对于reblog-theme-next,我使用了remark处理文章的渲染,并使用了shiki处理高亮:typescriptconst 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。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值