继续聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。
写在前面
在上一篇文章《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》[1]里,我们简单介绍了使用 Go 来获取传统网站的信息。
RSS Can(RSS 罐头)的相关代码已经开源在soulteary/RSS-Can[2] 。
项目中的代码,将会伴随文章更新而更新,如果你觉得项目有趣,欢迎“一键三连”。当然,如果你觉得这个事情有价值,也有趣,也欢迎加入项目,一起折腾。
为什么需要动态化
Golang 是传统的“编译型选手”,本身的动态化能力很弱,姑且不讨论 Golang 应用是否应该做动态化的哲学问题,单就效率角度来看 ,存在太多问题了。
比如,当我们遇到目标网站改版、想要快速调整规则完善获取信息的时候,重复编译 Golang 程序,即使构建速度再快,也是一件反效率的事情,前后牵扯的七七八八的事情一箩筐。
但其实,我们的程序主体并没有修改调整,需要调整的内容只有一些细微的规则,所以,将这块经常变化的内容抽象出来单独维护,是一件有必要的事情;考虑到部署涉及到额外的测试、补停机切换等需要不少基础技术设施,我们没有必要为一个需求建立一座城堡。所以,将程序部分动态化或许是最简单的省事策略之一。
为什么选择 JavaScript 作为动态化的 DSL
为什么考虑使用 JS 作为程序动态化的 DSL ,而不是使用 JSON、TOML、YAML 等传统的“静态”配置文件格式呢?
JavaScript x V8 x Golang
首先,动态语言相比“静态配置”对于程序要 “fancy” 不少。除了描述配置,还具备了和“程序实际运行的上下文交互”的能力,甚至在一些场景里,可以用 JavaScript 中现成的功能处理数据,而非一定要在 Golang 里做程序实现。
其次,在上一篇文章《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》[3]里,我提到了我们面对的场景除了包含“静态的”服务端生成场景之外,还包含“动态的”客户端生成的内容,使用 “JavaScript”,可以更好的和动态内容“打成一片” (后面聊 CSR 的时候详细展开)。一套配置表达方式,可以在两套,甚至未来多套环境中运行,而不是一个环境玩一套,还是很符合个人的技术审美的。
最后,我挺喜欢 JavaScript 这门年轻但是充满活力的和表现力的语言的。在 Golang 生态里,虽然各种语言的运行时实现都有,但是不论是 V8 实现[4],还是 Quick JS 实现[5],都深得我心。
考虑到后面要我们展开的 CSR 部分的内容,项目这里就先选择使用 “V8” 实现,暂时不使用 Quick JS 啦。
我们先来聊聊如何在 Go 里调用 JavaScript 代码。
如何在 Go 里调用 JavaScript
想要在 Go 里调用 JavaScript 代码,在引入上文提到的 “v8” 之后,最简单的方式莫过于下面这样的简单代码示例:
代码语言:javascript复制// 创建一个用于运行代码的“容器”(虚拟机)
ctx := v8.NewContext()
// 全局执行代码
ctx.RunScript("const add = (a, b) => a b", "math.js")
// 继续执行新代码,可以访问之前的代码
ctx.RunScript("const result = add(3, 4)", "main.js")
// 在 Go 里访问刚刚代码的执行结果
val, _ := ctx.RunScript("result", "value.js")
// 在 Go 里打印日子好,将结果打印出来
fmt.Printf("addition result: %s", val)
当然,如果我们想让 JavaScript 代码在 Go 里和 Go 的函数进行更多的交互,还需要做一些函数的调用绑定。当我们将代码放进项目里,执行 go run main.go
,将得到下面符合预期的结果:
addition result: 7
不过,真的想在 Go 里放心的运行 JavaScript ,我们还需要对执行的方法做一些额外的处理,避免出现“意外”。
更安全的 JavaScript 沙箱环境
在引入 V8 之后,其实除了运行我们的动态配置、灵活的“小动态函数”之外,还能够运行来自三方的代码。不论是运行哪一种代码,都有可能出现等效下面的逻辑:
代码语言:javascript复制while (true) {
// Loop forever
}
我们当然不希望程序整体,因为这类原因 “hang” 死,甚至影响同机器运行的其他服务。所以,对于调用 JavaScript 的 Golang 方法,需要做出一些改进:
代码语言:javascript复制const JS_EXECUTE_TIMEOUT = 200 * time.Millisecond
const JS_EXECUTE_THORTTLING = 2 * time.Second
func safeJsSandbox(ctx *v8.Context, unsafe string, fileName string) (*v8.Value, error) {
vals := make(chan *v8.Value, 1)
errs := make(chan error, 1)
start := time.Now()
go func() {
val, err := ctx.RunScript(unsafe, fileName)
if err != nil {
errs <- err
return
}
vals <- val
}()
duration := time.Since(start)
select {
case val := <-vals:
fmt.Fprintf(os.Stderr, "cost time: %vn", duration)
return val, nil
case err := <-errs:
return nil, err
case <-time.After(JS_EXECUTE_THORTTLING):
vm := ctx.Isolate()
vm.TerminateExecution()
err := <-errs
fmt.Fprintf(os.Stderr, "execution timeout: %vn", duration)
time.Sleep(JS_EXECUTE_TIMEOUT)
return nil, err
}
}
上面的程序将会保证我们想要执行的代码按照预期执行,当程序出现需要运行特别久的情况时(例子中是200毫秒),会自动停止运行代码,并休息 2s 避免潜在的重复调用造成系统负载飙升。
代码语言:javascript复制func main()
ctx := v8.NewContext()
safeJsSandbox(ctx, `
while (true) {
// Loop forever
}`, "loop.js")
}
当我们再次执行程序,会得到程序自动终止了运行时间过长的动态代码的日志提醒:
代码语言:javascript复制execution timeout: 12.206µs
使用 JavaScript 定义简单的动态配置
本篇文章,我们先不聊能够同时运行在 CSR、SSR 环境中的 JS SDK 的设计。先从一段简单的配置开始,只聊 Go 从 JavaScript 文件中获取配置并动态解析执行。
我们根据上一篇文章,不难梳理出消息列表中的每一条消息里,包含“标题、作者、分类、时间、描述、文章链接”的元素的信息,为了让我们的 Go 程序能够得到这个配置,我们需要将配置“包”在一个可执行函数或可访问的变量中。
代码语言:javascript复制function getConfig(){
return {
ListContainer: "#app .main-right .kr-home-main .kr-home-flow .kr-home-flow-list .kr-flow-article-item",
Title: ".article-item-title",
Author: ".kr-flow-bar-author",
Category: ".kr-flow-bar-motif a",
DateTime: ".kr-flow-bar-time",
Description: ".article-item-description",
Link: ".article-item-title",
}
}
使用 Go 解析动态配置
如何在 Golang 中执行上面的 JavaScript 代码,并得到执行结果,其实也非常简单,我们可以借助上文中提到的能够“安全执行” JavaScript 代码的函数:
代码语言:javascript复制func main() {
jsApp, _ := os.ReadFile("./config.js")
inject := string(jsApp)
ctx := v8.NewContext()
safeJsSandbox(ctx, inject, "main.js")
result, _ := ctx.RunScript("JSON.stringify(getConfig());", "config.js")
jsonRaw := []byte(fmt.Sprintf("%s", result))
fmt.Printf("addition result: %s", jsonRaw)
}
当我们执行完毕上面的代码后,将得到下面的结果:
代码语言:javascript复制cost time: 10.382µs
addition result: {"ListContainer":"#app .main-right .kr-home-main .kr-home-flow .kr-home-flow-list .kr-flow-article-item","Title":".article-item-title","Author":".kr-flow-bar-author","Category":".kr-flow-bar-motif a","DateTime":".kr-flow-bar-time","Description":".article-item-description","Link":".article-item-title"}
想要快速将上面的 “JSON” 格式的输出内容解析成 Go 的内存对象,我们可以使用 “JSON-to-Go[6]” 来偷个懒,将上面的内容粘贴到网站的编辑器中,网页程序将自动转换出我们所需要的 Go Struct 定义。
JSON-to-GO 在线工具
简单调整得到的代码,不难写出下面的程序,来将上文中的 JSON 数据转换为程序需要的内存对象。
代码语言:javascript复制func main() {
...
type Config struct {
ListContainer string `json:"ListContainer"`
Title string `json:"Title"`
Author string `json:"Author"`
Category string `json:"Category"`
DateTime string `json:"DateTime"`
Description string `json:"Description"`
Link string `json:"Link"`
}
var jsonData Config
err := json.Unmarshal(jsonRaw, &jsonData)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(jsonData)
}
调用动态配置获取网站数据
在上一篇程序里,我们的程序实现类似下面这样,是比较典型的 “hard code” 的代码。
代码语言:javascript复制func getFeeds() {
// Request the HTML page.
doc, err := getRemoteDocument("https://36kr.com/")
if err != nil {
log.Fatal(err)
}
// Find the article items
doc.Find("#app .main-right .kr-home-main .kr-home-flow .kr-home-flow-list .kr-flow-article-item").Each(func(i int, s *goquery.Selection) {
title := strings.TrimSpace(s.Find(".article-item-title").Text())
time := strings.TrimSpace(s.Find(".kr-flow-bar-time").Text())
fmt.Printf("Aritcle %d: %s (%s)n", i 1, title, time)
})
}
我们将动态配置和上面的程序进行结合,可以将程序简单调整为类似下面这样:
代码语言:javascript复制...
type Config struct {
ListContainer string `json:"ListContainer"`
Title string `json:"Title"`
Author string `json:"Author"`
Category string `json:"Category"`
DateTime string `json:"DateTime"`
Description string `json:"Description"`
Link string `json:"Link"`
}
func getFeeds(config Config) {
// Request the HTML page.
doc, err := getRemoteDocument("https://36kr.com/")
if err != nil {
log.Fatal(err)
}
// Find the article items
doc.Find(config.ListContainer).Each(func(i int, s *goquery.Selection) {
title := strings.TrimSpace(s.Find(config.Title).Text())
author := strings.TrimSpace(s.Find(config.Author).Text())
time := strings.TrimSpace(s.Find(config.DateTime).Text())
category := strings.TrimSpace(s.Find(config.Category).Text())
description := strings.TrimSpace(s.Find(config.Description).Text())
href, _ := s.Find(config.Link).Attr("href")
link := strings.TrimSpace(href)
fmt.Printf("Aritcle #%dn", i 1)
fmt.Printf("%s (%s)n", title, time)
fmt.Printf("[%s] , [%s]n", author, category)
fmt.Printf("> %s %sn", description, link)
fmt.Println()
})
}
func main() {
jsApp, _ := os.ReadFile("./config/config.js")
inject := string(jsApp)
ctx := v8.NewContext()
safeJsSandbox(ctx, inject, "main.js")
result, _ := ctx.RunScript("JSON.stringify(getConfig());", "config.js")
var config Config
err := json.Unmarshal([]byte(fmt.Sprintf("%s", result)), &config)
if err != nil {
fmt.Println(err)
return
}
getFeeds(config)
}
当我们再次运行程序,Go 程序就会跟着 JavaScript 代码中定义的配置,来尝试解析页面中的信息啦。
代码语言:javascript复制Aritcle #1
动画市场迎来《三体》,然后呢? (1小时前)
[娱乐独角兽] , [文娱直播新动向]
> 内容生产需要向上走。 /p/2041078218796039
Aritcle #2
...
最后
接下来的内容里,我们继续聊聊,如何将这些信息源转换为 RSS 阅读器可以使用的信息源,以及如何针对不同类型的网站进行信息整理。
当然,也会继续聊聊之前系列文章中提到的有趣的技术点。
--EOF
引用链接
[1]
《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》: https://soulteary.com/2022/12/12/rsscan-better-rsshub-service-build-with-golang-part-1.html
[2]
soulteary/RSS-Can: https://github.com/soulteary/RSS-Can
[3]
《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》: https://soulteary.com/2022/12/12/rsscan-better-rsshub-service-build-with-golang-part-1.html
[4]
V8 实现: https://github.com/rogchap/v8go
[5]
Quick JS 实现: https://github.com/lithdew/quickjs
[6]
JSON-to-Go: https://mholt.github.io/json-to-go/