Gloria 任务开发指南

本文章不再更新, 相关内容已被转移至 Gloria 的官方指南: http://docs.gloria.pub

有关 Gloria 的一切都是围绕着任务展开的, 每一个任务包含着它所需要执行的代码, 它们是 Gloria 最重要的部分. 这篇文章将完整地说明如何编写能够被 Gloria 执行的任务代码.

编程语言

JavaScript 是 Gloria 任务代码唯一支持的编程语言.

Gloria 作为一款 Chrome 扩展程序, 它本身就是用 JavaScript 开发的, 同时, 利用 JavaScript 作为动态语言的特性, 我们可以直接在 Gloria 里执行用户定义的 JavaScript 代码而不必遭受性能损失.

除了性能上的优势, JavaScript 还足够流行, 它在 Stack Overflow 已经蝉联全球最受欢迎的开发技术多年, 是事实上的胶水语言, 这意味着大部分开发人员都应该已经接触过它, 假如你不了解 JavaScript, 或许是时候开始学习它了.

另外, 由于 Gloria 的任务代码需要访问的对象多为网络服务, 而网络服务在浏览器上必然要通过 JavaScript 以及其他 ECMAScript 方言运行, 使用 JavaScript 作为编程语言将有助于你利用访问的网站对象已有的代码, 同构是一个巨大的优势.

我想说的是: 没有任何一门编程语言, 在编写 Gloria 任务代码这件事上比 JavaScript 更适合, 从一开始我们就走在正确的路上.

从最简单的例子开始

Gloria 使用指南里我提供了一段可以被 Gloria 执行的代码, 我们就从解释这段代码开始说起.

commit({ message: `Hello, it is ${new Date()}.` })  

这段代码首先创建了一个对象:

{
  message: `Hello, it is ${new Date()}.`
}

这个对象唯一的一个成员变量message, 被初始化为字符串'Hello, it is'加上当前的时间. 代码部分是通过ECMAScript 6的template string特性实现的, Gloria 在开发初期就没有考虑过在低版本的 Chrome 上运行, 所以在 Gloria 里你可以放下顾虑使用当前 Chrome 版本支持的新特性, 即便你想支持低版本的 V8 引擎所不支持的特性, 其中大部分也可以通过 Babel 等转译工具实现, 你是开发者, 一切受你控制.

创建完对象, 我们将这个对象作为参数调用了commit函数, 一切就结束了, Gloria 会定期弹出关于当前时间的通知.

但假如你把message的值设置为一个不变的字符串, 比如"Hello World", 再放到 Gloria 里执行, 你会发现它不会按你所想那样工作, 为什么呢? 继续读下去, 很快你就会知道了.

状态无关的回调函数: commit

需要提前说明的是, 我们的任务代码是在一个基于 Web Worker 技术搭建的沙箱中执行, 这确保了多个任务代码会在不同的线程中独立执行, 各个线程不会互相影响, 同时也保证了 Gloria 自身的运行环境不会被任务代码污染.

由于 Web Worker 的特性, 当我们的任务代码想要返回点什么给 Gloria 时, 我们必须调用回调函数, 而这个回调函数就是commit函数.

commit函数接收一个参数, 这个参数的类型可以是一个 Gloria Notification 对象, 也可以是一个由 Gloria Notification 对象组成的数组(Gloria Notification 对象将在后面详细说明). 在一个任务代码中commit函数只能被调用一次, 当该函数被调用, 其所在的 Web Worker 就会销毁自己, 也就是说, 调用commit就表示当前的任务代码执行完毕, 并将收集到的信息一次性返回给 Gloria 处理.

调用commit函数会将 Gloria Notification 返回给 Gloria, 但这并不意味着 Gloria 一定会将返回值以通知的形式呈现给用户. Gloria 的任务代码被刻意设定成状态无关的, 试想你要编写一个获取用户收件箱条目的任务, 你是要每一次返回所有的10个任务(这是该收件箱一次性能够展示内容的极限), 还是每次只返回其中新增的? 从直觉角度考虑, 返回新增的内容是正确的做法, 但在实践中我发现, 很多时候我们无法分辨由网络服务提供的内容新旧与否, 若是在任务代码里包含持久化, 或是管理状态的代码, 则会让代码的复杂度直线上升, 所以我决定让 Gloria 吸取函数式编程的思想, 让 Gloria 的任务代码保持简单——由 Gloria 决定哪些通知会被显示, 哪些应该被忽略, 这带来的直接影响便是为 Gloria 编写任务代码的开发者只需要完成用于获取信息的中间件代码, 剩下的一切都会由 Gloria 自动完成, 开发者无需关心状态.

决定是否推送消息的内部数组: stage

Gloria 内部决定是否显示一个通知的策略是非常简单的, 所有 commit 函数传来的 Gloria Notification 将被缓存进一个叫做 stage 的地方(你可能会觉得很熟悉, 是的, commit 和 stage 的命名就是抄自 Git), 在缓存进 stage 时, 程序将会对比 stage 里是否已经存在相似的 Gloria Notification 对象, 从而决定其是否应该被显示, 所以当message内容和之前一样时, Gloria 就会认为这个 Gloria Notification 是已经存在过的旧消息, 不允显示.

那么为什么当 commit 的参数是一个不变值时, 连一次显示都没有? 首次进入 stage 时难道不应该显示吗? 这和 Gloria 的设计理念密切相关, 在实践中, 我发现若是当你新建一个任务, 这个任务在第一次执行时会返回所有它能收集到的内容, 若是这些内容全部都被推送给用户, 将是一个非常恼人的事情. 所以 Gloria 假设在新建任务时, 之前的所有消息都是你已经读过的, 这样 Gloria 就只会推送新的消息, 而不会出现首次执行弹出一大堆消息的情况.

这个设计理念的具体实现形式, 便是 stage 对于第一次 commit 的结果会全部标记成已读状态, 不允许显示, 而当一个任务被创建时, 它的代码会被立刻执行一次, 这就是为什么当你创建任务时, 发现 Trigged 计数值已经变成了1, 却没有任何通知显示. 回到不变值的问题上, 第一次 commit 时, 这个不变值被缓存入 stage, 首次 commit 将全部认为是已读的, 于是不会显示, 在第二次 commit 时, 还是这个不变值, 于是 stage 认为这个值是旧的消息, 不应该显示, 所以当你用"Hello World"作为message时, 什么也不会显示给用户.

作为开发者, 实际上你无需关心 stage 的细节, 在任务代码里你无法访问 stage. 说明 stage, 只是为了更好的让你明白 Gloria 的工作原理, 以便你能不带有疑惑地编写任务代码.

最后值得额外说明的是, 每一个 stage 存在一个缓存数量上限, 当 stage 中缓存的 Gloria Notification 到达上限时, 它会删除掉旧的 Gloria Notification 为新的 Gloria Notification 腾出空间, 所以理论上同一个 Gloria Notification 是有可能被显示两次的, 但我建议开发者们最好不要刻意造成这种状况. 由于这个上限值有可能在之后的版本里发生改变(甚至可能被设定成与运行的机器配置直接相关), 所以你也无需关注这个上限的具体值, 只要保证每一次的 Gloria Notification 不要更新太多新消息就行了, 如果一个消息源的更新频率高到突破了这个上限, 它很可能并不适合用 Gloria 进行通知.

鸭子类型: Gloria Notification

一个 Gloria Notification 对象或是一个由 Gloria Notification 构成的数组是 commit 函数的唯一一个参数, 它将决定你的通知显示时的形式, 对每一个任务代码都非常重要.

实际上, Gloria Notification是 Chrome NotificationOptions 类型的定制版本, 其大多数成员与NotificationOptions是一致的, Gloria Notification 放宽了 NotificationOptions 对于其成员的一些强制要求, 但也有一部分 NotificationOptions 的成员在 Gloria Notification 中被禁止使用.

一个标准的 Gloria Notification 的结构是这样的:

{
  title: String
  message: String
  iconUrl: String
  imageUrl: String
  url: String
}

这里的每一个值都不是必要的, 它们都有着自己的默认值, 所以实际上它们的初始状态是这样的:

{
  title: '',
  message: '',
  iconUrl: 'assets/images/icon-128.png', // Gloria Icon
  imageUrl: undefined,
  url: undefined
}

你返回的值只需要包含其中的一部分, Gloria 会将你返回的对象和这个初始对象合并, 最后得出一个符合 NotificationOptions 标准的对象.

Gloria Notification 的成员将怎样决定通知的显示效果, 让我们来看一个实例:

它返回的 Gloria Notification 是这样的:

{
  title: "王老菊带你石油大亨02:大股东!",
  message: "市长同志跟我讲话, 说「都决定啦, 你来当大股东」, 我说另请高明吧. 我实在我也不是谦虚, 我一个挖石油的煤老板, 怎么到市里来了呢?但是呢, 市长同志讲「大家已经研究决定了」, 所以, 我就被坑了一万多块钱, 当下了这个股东. ",
  iconUrl: "http://i1.hdslb.com/bfs/face/b55e96895608e03c3435d018b708a705ccc2bda4.gif",
  imageUrl: "http://i0.hdslb.com/bfs/archive/6a41f5a2d59513b513f944d876c83089fc3d9cf1.jpg_320x200.jpg",
  url: "http://www.bilibili.com/video/av5750678/"
}

title, message 会决定标题和信息的文字内容, 要注意的是, message 的字号是比 title 要小一号的, 而且当 message 显示不下时, 会以省略号的形式结尾.

iconUrl 会决定通知左上角的图标, 这是 Chrome 强制规定的, 如果你的 Gloria Notification 不包含 iconUrl, 则会被显示成 Gloria 自己的图标.

imageUrl 会决定通知下方的大尺寸图片, 很多消息源里是找不到这样的图片的, 如果不包含 imageUrl, 显示图片的那一块区域会被取消. 如果你使用过 Chrome 的 NotificationOptions, 会知道显示带有图片的通知必须要修改通知的 type 参数为 "image", 我认为这相当多余, 所以在 Gloria 里, Gloria Notification 是鸭子类型的, 如果包含 imageUrl, type 将被自动修改成 "image", 这些事情你都不需要在 Gloria 里操心.

url 将决定点击这个通知后打开什么页面, 如果不包含 url, 通知也仅仅是变成点击不会打开页面而已, 不过大多数消息源还是需要一个 url 的吧, 不然通知就仅仅是"通知"了对吧?

另外, Gloria 会自动设置 NotificationOptions 的 contextMessage 值为"By" + 任务名 + 创建时间的形式, 告知用户该通知的来源和推送时间.

由于 Chrome 的缘故, 如果你的 title 内容过长, 则会变成两行, 使得 message 彻底消失:

当 title 的值为空字符串时, Chrome Notification 会去掉标题, 这样你的 message 就可以显示多行, 同时因为 message 的字号会略小于 title, 所以能显示更多文字, 对于文本量较多的消息, 开发者应该只使用 message 进行显示:

事实上在 Gloria Notification 你还可以用 NotificationOptions 的 progress 和 items 成员, 但这只是因为 Gloria 目前还没有决定下这两种形式的 Notification 应该如何被组织起来, 在将来很有可能被取消, 所以你不应该在现在用它.

带有 Cookie 的内置 fetch 函数

现在你知道了如何创建一个通知, 如何将通知返回给 Gloria, 以及 Gloria 如何对待这些通知, 但也许你关注的不是这些, 而是该怎样访问 url, 取得你要的数据.

在创建网络请求这件事上, Gloria 用 fetch 取代了 XMLHttpRequest, Chrome 作为浏览器第一梯队的带头人, 它很早就内置了 fetch 函数, 你可以在 Fetch API - Web API 接口 | MDNgithub/fetch: A window.fetch JavaScript polyfill. 找到 fetch 的使用方法.

Gloria 任务代码运行环境下的 fetch 与原本的 fetch 稍有不同, 所有通过 fetch 创建的请求, 都会自动加上目标 url 的 Cookie, 这样就能利用当前 Chrome 在目标网站上的登录状态, 轻松的获取到你想要的数据, 使得那些略显私人的通知提醒也成为了可能. 在 Gloria 运行环境下的 XMLHttpRequest 并没有被加上这个特性, 所以如果你不想要自带 Cookie, 用 XMLHttpRequest 就可以了, 我认为这个需求是相当小的.

这是一个用 fetch 获取当前登录 bilibili 的用户的订阅内容的任务代码, 代码非常简单, 供你参考:

fetch('http://api.bilibili.com/x/feed/pull?ps=10&type=0&pn=1')  
.then(res => res.json())
.then(json => {
  let notifications = json.data.feeds.map(feed => {
    return {
      title: feed.addition.title
    , message: feed.addition.description
    , iconUrl: feed.source.avatar
    , imageUrl: feed.addition.pic
    , url: feed.addition.link
    }
  })
  commit(notifications)
})

当然, Chrome 原生的 fetch 目前也还存在少许问题, 比如 fetch 碍于 Promise 的设计无法取消请求, 受制于 w3c 标准使得所有自定义 header 的名称将在请求时变成小写(原因是 w3c 标准规定 header 名称大小写无关, 很多服务器做了错误的实现使得这一点变成相关的了, 进入 http2 时代后应该全部都会转为小写了). 我的选择是不去妥协 XHR, 也不去引入 SuperAgent 这类第三方库, 让 Chrome 和网站们自己解决这些问题, 作为任务代码的编写者, 你也可以尝试自己通过其他途径替代这些目前还存在问题的实现.

异步载入外部脚本 importScripts

熟悉 Web Worker 的开发者可能知道, Web Worker 的运行环境内置了一个 importScripts 函数用于同步载入外部脚本, 这意味着很强的扩展性, 但由于 Web Worker 的运行环境里是没有 window 这个 global 对象的, 很多外部脚本并不是针对这样的环境编写的, 于是强大的能力无从发挥, 有着强大的库却无法使用, 着实是一件令人难受的事情.

出于兼容性和主动缓存方面的考虑, 这个函数在 Gloria 的任务代码运行环境里被改造成了异步的, 而且会制造一个虚拟的 window 对象, 以便一些外部脚本可以正常执行. 调用这个异步的 importScripts 会返回一个 Promise 对象, Promise.then的回调函数所接收到的第一个参数就是载入的外部脚本的返回值.

我强烈推荐开发者使用 webpack 打包自己需要的外部脚本, 在使用 webpack 的情况下, 你甚至可以使用一些本来为 Node.js 环境编写的模块, 比如 cheeriojs/cheerio. 要知道在 Web Worker 下是没有 DOM 的, 所以当你要分析一个 HTML 网页时, 你会很需要一个像 cheerio 这样的模块.

从仓库 Clone 代码, 用npm install安装依赖, 用 webpack 打包代码:

webpack --target=web --entry=./index.js --output-filename=./cheerio-bundle.js --module-bind=json  

生成的 cheerio-bundle.js 就是那个可以直接用在 importScripts 上的外部脚本, 接着你可以像这样使用它, 非常优雅:

Promise.all([  
  importScripts('http://cdn.blackglory.me/cheerio-bundle.js')
, fetch('https://www.zhihu.com/noti7/stack/vote_thank?limit=10').then(res => res.json())
])
.then(([cheerio, { msg }]) => {
  let $ = cheerio.load(msg)
    , notifications = []
  $('.zm-noti7-content-item').each((i, el) => {
    let notification = {
      iconUrl: 'https://pic1.zhimg.com/2e33f063f1bd9221df967219167b5de0_m.jpg'
    , message: $(el).text().trim().replace(/\n/g, '')
    , url: ((base, href) => {
        if (!href.startsWith('http')) {
          if (href.startsWith('/')) {
            return `${base}${href}`
          } else {
            return `${base}/${href}`
          }
        }
        return href
      })('http://www.zhihu.com', $(el).find('a.question_link, a.post-link').attr('href'))
    }
    notifications.push(notification)
  })
  commit(notifications)
})

更赞的是, 新的 importScripts 会主动缓存你加载的外部脚本, 在第一次执行之后, 你的代码就可以以本地访问的速度载入该外部脚本. 当然, 这个外部脚本终归是要放到网络上的, 我正尝试开发一个自助打包的网络服务, 如果成功, 事情就会变得更加简单了.

调试

Gloria 在 Advanced 面板提供了一些方便开发者调试的功能, 你可以在其中直接执行你的任务代码, 并且这个代码并不会进入 stage, 你可以直接看到 commit 返回后的通知效果, 对于开发而言是比较实用的. 比较棘手的是, 如果你的任务代码有错误, 你必须要打开 Gloria 背景页的检查视图才能找到, 这个检查试图在 Chrome 扩展程序页面可以找到, 我会在之后的版本尝试直接在页面上输出错误, 这样就不用如此麻烦了.

另外, Advanced 面板还提供清除 Task, 清除 Stage, 清除 History的功能.