继续聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。

写在前面

在上一篇文章《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》里,我们简单介绍了使用 Go 来获取传统网站的信息。

RSS Can(RSS 罐头)的相关代码已经开源在soulteary/RSS-Can

项目中的代码,将会伴随文章更新而更新,如果你觉得项目有趣,欢迎“一键三连”。当然,如果你觉得这个事情有价值,也有趣,也欢迎加入项目,一起折腾。

为什么需要动态化

Golang 是传统的“编译型选手”,本身的动态化能力很弱,姑且不讨论 Golang 应用是否应该做动态化的哲学问题,单就效率角度来看 ,存在太多问题了。

比如,当我们遇到目标网站改版、想要快速调整规则完善获取信息的时候,重复编译 Golang 程序,即使构建速度再快,也是一件反效率的事情,前后牵扯的七七八八的事情一箩筐。

但其实,我们的程序主体并没有修改调整,需要调整的内容只有一些细微的规则,所以,将这块经常变化的内容抽象出来单独维护,是一件有必要的事情;考虑到部署涉及到额外的测试、补停机切换等需要不少基础技术设施,我们没有必要为一个需求建立一座城堡。所以,将程序部分动态化或许是最简单的省事策略之一

为什么选择 JavaScript 作为动态化的 DSL

为什么考虑使用 JS 作为程序动态化的 DSL ,而不是使用 JSON、TOML、YAML 等传统的“静态”配置文件格式呢?

JavaScript x V8 x Golang

首先,动态语言相比“静态配置”对于程序要 “fancy” 不少。除了描述配置,还具备了和“程序实际运行的上下文交互”的能力,甚至在一些场景里,可以用 JavaScript 中现成的功能处理数据,而非一定要在 Golang 里做程序实现。

其次,在上一篇文章《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》里,我提到了我们面对的场景除了包含“静态的”服务端生成场景之外,还包含“动态的”客户端生成的内容,使用 “JavaScript”,可以更好的和动态内容“打成一片” (后面聊 CSR 的时候详细展开)。一套配置表达方式,可以在两套,甚至未来多套环境中运行,而不是一个环境玩一套,还是很符合个人的技术审美的。

最后,我挺喜欢 JavaScript 这门年轻但是充满活力的和表现力的语言的。在 Golang 生态里,虽然各种语言的运行时实现都有,但是不论是 V8 实现,还是 Quick JS 实现,都深得我心。

考虑到后面要我们展开的 CSR 部分的内容,项目这里就先选择使用 “V8” 实现,暂时不使用 Quick JS 啦。

我们先来聊聊如何在 Go 里调用 JavaScript 代码。

如何在 Go 里调用 JavaScript

想要在 Go 里调用 JavaScript 代码,在引入上文提到的 “v8” 之后,最简单的方式莫过于下面这样的简单代码示例:

// 创建一个用于运行代码的“容器”(虚拟机)
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 之后,其实除了运行我们的动态配置、灵活的“小动态函数”之外,还能够运行来自三方的代码。不论是运行哪一种代码,都有可能出现等效下面的逻辑:

while (true) {
    // Loop forever
}

我们当然不希望程序整体,因为这类原因 “hang” 死,甚至影响同机器运行的其他服务。所以,对于调用 JavaScript 的 Golang 方法,需要做出一些改进:

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 避免潜在的重复调用造成系统负载飙升。

func main()
	ctx := v8.NewContext()

	safeJsSandbox(ctx, `
	while (true) {
	    // Loop forever
	}`, "loop.js")
}

当我们再次执行程序,会得到程序自动终止了运行时间过长的动态代码的日志提醒:

execution timeout: 12.206µs

使用 JavaScript 定义简单的动态配置

本篇文章,我们先不聊能够同时运行在 CSR、SSR 环境中的 JS SDK 的设计。先从一段简单的配置开始,只聊 Go 从 JavaScript 文件中获取配置并动态解析执行。

我们根据上一篇文章,不难梳理出消息列表中的每一条消息里,包含“标题、作者、分类、时间、描述、文章链接”的元素的信息,为了让我们的 Go 程序能够得到这个配置,我们需要将配置“包”在一个可执行函数或可访问的变量中。

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 代码的函数:

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)
}

当我们执行完毕上面的代码后,将得到下面的结果:

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” 来偷个懒,将上面的内容粘贴到网站的编辑器中,网页程序将自动转换出我们所需要的 Go Struct 定义。

JSON-to-GO 在线工具

简单调整得到的代码,不难写出下面的程序,来将上文中的 JSON 数据转换为程序需要的内存对象。

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” 的代码。

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)
	})
}

我们将动态配置和上面的程序进行结合,可以将程序简单调整为类似下面这样:

...

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 代码中定义的配置,来尝试解析页面中的信息啦。

Aritcle #1
动画市场迎来《三体》,然后呢? (1小时前)
[娱乐独角兽] , [文娱直播新动向]
> 内容生产需要向上走。 /p/2041078218796039

Aritcle #2
...

最后

接下来的内容里,我们继续聊聊,如何将这些信息源转换为 RSS 阅读器可以使用的信息源,以及如何针对不同类型的网站进行信息整理。

当然,也会继续聊聊之前系列文章中提到的有趣的技术点。

–EOF