起因
在更换主题之前本站一直通过 AJAX 配合 Azure Search 来实现站内搜索,后来随着我把主题换成了自制的简陋主题『Coda』,由于懒惰以及不想使用 Javascript 等原因站内搜索也被顺便舍弃。再后来我把网站搬到了 Cloudflare Workers Sites,在感叹 Cloudflare Workers 如此好用的同时,也起了薅羊毛的歹念。这羊毛薅起来也简单,只需要通过 Workers 来处理搜索请求,然后把渲染好的网站返回即可,这样就可以在后端完成渲染从而避免在前端使用 Javascript,正所谓己所不欲……哎不对当我没说。
方案
由于 Azure Search 返回的结果是一个 JSON object,而在把它渲染成 html 的时候势必需要一个模板,虽然这个模板可以 hard code 到 workers 的代码中,不过假如前端发生了改动,那么还需要同时更新 workers 里的模板,这很麻烦,一点也不优雅。幸运的是,Cloudflare 为了方便大家薅羊毛,提供了 HTMLRewriter 这个 API。然后我们需要做的事情也很简单了,只需要像往常一样从 Workers KV
里面获取一个页面(例如 index.html),然后再通过 HTMLRewriter
改写其中的部分内容即可。也就是分为以下几个步骤:
- 客户端请求
GET https://.../search?query=xxx
。 - Cloudflare Workers 负责(可同时进行)。
- 从 Azure Search 获取 JSON:
fetch('https://xxx.search.windows.net/...')
。 - 从 Workers KV 获取网页作为模板:
getAssetFromKV(...)
。
- 从 Azure Search 获取 JSON:
- 根据 JSON 生成部分 HTML。
- 使用
HTMLRewriter
进行改写。 - 返回渲染好的网页。
比起前端实现的优点
比起来旧的 AJAX 前端实现,新的方案主要有以下优点:
- 不会暴露 Query Key。
- 可以实现负载均衡,前端实现很难根据访客的位置来实现负载均衡。
- 可以实现限流。
- 前端无 Javascript,倒也不是 Javascript 会导致任何问题,只是既然用不到,那么去掉多余的东西自然是极好的。
准备工作
- 参考这篇文章添加 Azure Search 索引。
- 添加 workers secret,注意使用
wrangler secret put AS_QUERY_KEY
命令添加,而不是直接在网页添加,否则下次更新 Workers 时添加的 secret 会消失。
实现
框架
首先放出基本的框架
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
其中高亮的几个主要 function/class 的作用是:
getJSONFromAS()
负责从 Azure Search 获取返回结果(JSON object)。getAssetFromKV()
负责从 Workers KV 获取网页以作为模板,这里通过使用mapRequestToAsset
把请求重定向到了/index.html
。genHTMLFromJSON()
负责使用之前获得的 JSON object 生成部分 HTML。HTMLRewriter()
负责把/index.html
中的部分内容替换为之前生成的 HTML,这里对div.content
(与 CSS Selector 语法相同,也就是对应<div class="content"></div>
) 使用InnerHTMLRewriter()
进行重写。
实现 getJSONFromAS()
其实只是个 fetch() 的 wrapper 而已,所以直接上代码:
async function getJSONFromAS(query, page, resultPerPage) {
const asAPI = new URL('https://[Service Name Placeholder].search.windows.net/indexes/posts/docs')
asAPI.searchParams.set('api-key', AS_QUERY_KEY)
asAPI.searchParams.set('api-version', '2020-06-30')
asAPI.searchParams.set('search', query)
asAPI.searchParams.set('$top', resultPerPage)
asAPI.searchParams.set('$skip', page * resultPerPage)
asAPI.searchParams.set('$count', 'true')
return await fetch(asAPI).then(res => res.json())
}
如果在 Azure Search 索引里面限制了 CORS 记得在 fetch() 里添加相应的 header。
实现 genHTMLFromJSON()
直接按照之前配置的 Azure Search 索引,举一个简单的例子,其实无非是根据当前的 Query String 以及从 Azure Search 获得的 JSON object,来生成一些奇奇怪怪的内容而已。
function genHTMLFromAS(pathname, query, page, resultPerPage, resJSON) {
if (!resJSON.hasOwnProperty('value') || resJSON.value.length == 0) {
return '<p>No Result HTML Placeholder</p>'
}
let html = ''
resJSON.value.forEach(v => {
html += '<div>'
html += `<a href="${v.url}"><h1>${v.title}</h1></a>`
html += `<time>${v.date_published}</time>`
html += `<div>${v.tags.toString()}</div>`
html += `<div>${v.description}</div>`
html += '</div>'
})
if (page > 0) {
html += `<a class="prev btn" href="${pathname}?query=${encodeURIComponent(query)}&page=${page-1}">Previous Page</a>`
}
if ((page+1) * resultPerPage < resJSON['@odata.count']) {
html += `<a class="next btn" href="${pathname}?query=${encodeURIComponent(query)}&page=${page+1}">Next Page</a>`
}
return html
}
⚠️ 注意:
由这个 function 所生成的 html 将会被直接用作最终返回的 html 的一部分,所以务必对不安全的变量进行转义 ,从而以防止 XSS 攻击。
例如代码中query
这个变量可能包含任何字符,所以要使用encodeURIComponent()
进行转义后再放入href="..."
中。
同时建议在 Content-Security-Policy Header 中对 script-src 进行限制。
实现 InnerHTMLRewriter()
这个 Class 的作用无非是把元素中的 HTML 全部重写,所以实现起来即粗暴又简单:
class InnerHTMLRewriter {
constructor(innerHTML) {
this.innerHTML = innerHTML
}
element(element) {
element.setInnerContent(this.innerHTML, { html: true })
}
}
再次强调一下由于设置了 html: true
,也就是说 innerHTML
里的内容不会经过转义,所以一定要提前过滤好 innerHTML
。
前端
访问 https://.../search?query=xxx
即可显示搜索结果。要调用的话也很简单,只需要在前端放一个使用 GET 方法的表格即可,完全用不到 Javascript。
<form action="/search" method="get">
<input class="search-input" name="query" type="text" spellcheck="false">
<button type="submit">Submit</button>
</form>
改进
负载均衡
虽然网站本身部署在 Cloudflare 的众多节点上,但是 Azure Search 却不提供负载均衡,所以会导致有时候回流很慢,这种瓶颈实在是令人不爽。幸运的是我们可以建立多个不同区域的 Azure Search 服务,然后通过 event.request.cf.colo
来判断处理请求的 Workers 所在的数据中心,最后访问相应的的 Azure Search 服务。例如我分别在日本和美国建立了索引,而我希望在亚洲数据中心的 Workers 使用位于日本的索引,而其它的 Workers 默认使用位于美国的索引。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
我选择的方式是根据收到请求的数据中心来判断,Cloudflare 的数据中心使用与机场相同的代码,可以在 cloudflarestatus 查看所有数据中心的列表。当然也可以通过访客的 IP 所属国来判断,不过有两点需要注意,首先是 IP 所属国不一定反映了访客的实际地理位置,并且 IP 位于南极的访客也可以访问美国的数据中心。其实如果不在乎资源消耗,甚至可以通过 Promise.any()
这种夸张的方式来同时请求所有索引。
保留搜索栏中的内容
也许你已经发现了,目前在搜索后存在搜索栏会自动清空这个问题。解决方法同样是使用 HTMLRewriter
来重写搜索框的 input 标签中的 value 属性即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
由于 Workers 会自动对 setAttribute()
中的内容进行转义,所以这里可以省略转义这一步骤。