本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2022年12月13日 统计字数: 7118字 阅读时间: 15分钟阅读 本文链接: https://soulteary.com/2022/12/13/rsscan-make-golang-applications-with-v8-part-2.html ----- # RSS Can:借助 V8 让 Golang 应用具备动态化能力(二) 继续聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。 ## 写在前面 在上一篇文章[《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》](https://soulteary.com/2022/12/12/rsscan-better-rsshub-service-build-with-golang-part-1.html)里,我们简单介绍了使用 Go 来获取传统网站的信息。 RSS Can(RSS 罐头)的相关代码已经开源在[soulteary/RSS-Can](https://github.com/soulteary/RSS-Can) 。 项目中的代码,将会伴随文章更新而更新,如果你觉得项目有趣,欢迎“一键三连”。当然,如果你觉得这个事情有价值,也有趣,也欢迎加入项目,一起折腾。 ### 为什么需要动态化 Golang 是传统的“编译型选手”,本身的动态化能力很弱,姑且不讨论 Golang 应用是否应该做动态化的哲学问题,**单就效率角度来看** ,存在太多问题了。 比如,当我们遇到目标网站改版、想要快速调整规则完善获取信息的时候,重复编译 Golang 程序,即使构建速度再快,也是一件反效率的事情,前后牵扯的七七八八的事情一箩筐。 但其实,我们的程序主体并没有修改调整,需要调整的内容只有一些细微的规则,所以,**将这块经常变化的内容抽象出来单独维护**,是一件有必要的事情;考虑到部署涉及到额外的测试、补停机切换等需要不少基础技术设施,我们没有必要为一个需求建立一座城堡。所以,**将程序部分动态化或许是最简单的省事策略之一**。 ### 为什么选择 JavaScript 作为动态化的 DSL 为什么考虑使用 JS 作为程序动态化的 DSL ,而不是使用 JSON、TOML、YAML 等传统的“静态”配置文件格式呢? ![JavaScript x V8 x Golang](https://attachment.soulteary.com/2022/12/13/js-with-go.jpg) 首先,动态语言相比“静态配置”对于程序要 “fancy” 不少。除了描述配置,还具备了和“程序实际运行的上下文交互”的能力,甚至在一些场景里,可以用 JavaScript 中现成的功能处理数据,而非一定要在 Golang 里做程序实现。 其次,在上一篇文章[《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》](https://soulteary.com/2022/12/12/rsscan-better-rsshub-service-build-with-golang-part-1.html)里,我提到了我们面对的场景除了包含“静态的”服务端生成场景之外,还包含“动态的”客户端生成的内容,使用 “JavaScript”,**可以更好的和动态内容“打成一片”** (后面聊 CSR 的时候详细展开)。一套配置表达方式,可以在两套,甚至未来多套环境中运行,而不是一个环境玩一套,还是很符合个人的技术审美的。 最后,我挺喜欢 JavaScript 这门年轻但是充满活力的和表现力的语言的。在 Golang 生态里,虽然各种语言的运行时实现都有,但是不论是 [V8 实现](https://github.com/rogchap/v8go),还是 [Quick JS 实现](https://github.com/lithdew/quickjs),都深得我心。 考虑到后面要我们展开的 CSR 部分的内容,项目这里就先选择使用 “V8” 实现,暂时不使用 Quick JS 啦。 我们先来聊聊如何在 Go 里调用 JavaScript 代码。 ## 如何在 Go 里调用 JavaScript 想要在 Go 里调用 JavaScript 代码,在引入上文提到的 “v8” 之后,最简单的方式莫过于下面这样的简单代码示例: ```go // 创建一个用于运行代码的“容器”(虚拟机) 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`,将得到下面符合预期的结果: ```bash addition result: 7 ``` 不过,真的想在 Go 里放心的运行 JavaScript ,我们还需要对执行的方法做一些额外的处理,避免出现“意外”。 ## 更安全的 JavaScript 沙箱环境 在引入 V8 之后,其实除了运行我们的动态配置、灵活的“小动态函数”之外,还能够运行来自三方的代码。不论是运行哪一种代码,都有可能出现等效下面的逻辑: ```bash while (true) { // Loop forever } ``` 我们当然不希望程序整体,因为这类原因 “hang” 死,甚至影响同机器运行的其他服务。所以,对于调用 JavaScript 的 Golang 方法,需要做出一些改进: ```go 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: %v\n", 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: %v\n", duration) time.Sleep(JS_EXECUTE_TIMEOUT) return nil, err } } ``` 上面的程序将会保证我们想要执行的代码按照预期执行,当程序出现需要运行特别久的情况时(例子中是200毫秒),会自动停止运行代码,并休息 2s 避免潜在的重复调用造成系统负载飙升。 ```go func main() ctx := v8.NewContext() safeJsSandbox(ctx, ` while (true) { // Loop forever }`, "loop.js") } ``` 当我们再次执行程序,会得到程序自动终止了运行时间过长的动态代码的日志提醒: ```bash execution timeout: 12.206µs ``` ## 使用 JavaScript 定义简单的动态配置 本篇文章,我们先不聊能够同时运行在 CSR、SSR 环境中的 JS SDK 的设计。先从一段简单的配置开始,只聊 Go 从 JavaScript 文件中获取配置并动态解析执行。 我们根据上一篇文章,不难梳理出消息列表中的每一条消息里,包含“标题、作者、分类、时间、描述、文章链接”的元素的信息,为了让我们的 Go 程序能够得到这个配置,我们需要将配置“包”在一个可执行函数或可访问的变量中。 ```js 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 代码的函数: ```go 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) } ``` 当我们执行完毕上面的代码后,将得到下面的结果: ```bash 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](https://mholt.github.io/json-to-go/)” 来偷个懒,将上面的内容粘贴到网站的编辑器中,网页程序将自动转换出我们所需要的 Go Struct 定义。 ![JSON-to-GO 在线工具](https://attachment.soulteary.com/2022/12/13/json-to-go.jpg) 简单调整得到的代码,不难写出下面的程序,来将上文中的 JSON 数据转换为程序需要的内存对象。 ```go 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” 的代码。 ```go 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) }) } ``` 我们将动态配置和上面的程序进行结合,可以将程序简单调整为类似下面这样: ```go ... 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 #%d\n", i+1) fmt.Printf("%s (%s)\n", title, time) fmt.Printf("[%s] , [%s]\n", author, category) fmt.Printf("> %s %s\n", 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 代码中定义的配置,来尝试解析页面中的信息啦。 ```bash Aritcle #1 动画市场迎来《三体》,然后呢? (1小时前) [娱乐独角兽] , [文娱直播新动向] > 内容生产需要向上走。 /p/2041078218796039 Aritcle #2 ... ``` ## 最后 接下来的内容里,我们继续聊聊,如何将这些信息源转换为 RSS 阅读器可以使用的信息源,以及如何针对不同类型的网站进行信息整理。 当然,也会继续聊聊之前系列文章中提到的有趣的技术点。 --EOF