起因

在更换主题之前本站一直通过 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 改写其中的部分内容即可。也就是分为以下几个步骤:

  1. 客户端请求 GET https://.../search?query=xxx
  2. Cloudflare Workers 负责(可同时进行)。
    • 从 Azure Search 获取 JSON: fetch('https://xxx.search.windows.net/...')
    • 从 Workers KV 获取网页作为模板: getAssetFromKV(...)
  3. 根据 JSON 生成部分 HTML。
  4. 使用 HTMLRewriter 进行改写。
  5. 返回渲染好的网页。

比起前端实现的优点

比起来旧的 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
import { getAssetFromKV } from '@cloudflare/kv-asset-handler'

class InnerHTMLRewriter {}
async function getJSONFromAS(query, page, resultPerPage) { return {} }
function genHTMLFromJSON(pathname, query, page, resultPerPage, resJSON) { return '' }

async function handleEvent(event) {
  const { origin, pathname, searchParams } = new URL(event.request.url)
  if (pathname === '/search') {
    const query = searchParams.get('query') || ''
    const page = (/^-?\d+$/.test(searchParams.get('page'))) ? Number(searchParams.get('page')) : 0
    const resultPerPage = 5    // 5 results per page
    const asPromise = getJSONFromAS(query, page, resultPerPage)
    const kvPromise = getAssetFromKV(event, {
      mapRequestToAsset: () => { return new Request(origin+'/index.html') }
    })
    const results = await Promise.all([asPromise, kvPromise])
    .catch(e => { return new Response(e.toString(), { status: 404 }) })
    const html = genHTMLFromJSON(pathname, query, page, resultPerPage, results[0])
    return new HTMLRewriter().on("div.content", new InnerHTMLRewriter(html)).transform(results[1])
  }
}

addEventListener('fetch', event => {
  event.respondWith(handleEvent(event))
})

其中高亮的几个主要 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
async function handleEvent(event) {
  ...
  if (pathname === '/search') {
    ...
    const asPromise = getJSONFromAS(query, page, resultPerPage, event.request.cf.colo)
    ...
  }
}

const asiaDCRegExp = new RegExp(/^(BLR|BKK|BWN|CEB|CTU|MAA|CGP|CKG|CMB|DAC|SZX|FUO|FOC|CAN|HGH|HAN|HNY|SGN|HKG|HYD|ISB|CGK|JSR|TNA|JHB|KHI|KTM|CCU|KUL|LHE|NAY|LYA|MFM|MLE|MNL|BOM|NAG|NNG|DEL|NBG|KIX|PNH|ICN|SHA|SHE|SJW|SIN|SZV|TPE|PBH|TSN|NRT|ULN|VTE|WUH|WUX|XIY|RGN|EVN|CGO|CSX)$/)
async function getJSONFromAS(query, page, resultPerPage, cfColo) {
  let asAPI
  if (asiaDCRegExp.test(cfColo)) {
    asAPI = new URL('https://[Service Name JP].search.windows.net/indexes/posts/docs')
    asAPI.searchParams.set('api-key', AS_QUERY_KEY_JP)
  } else {
    asAPI = new URL('https://[Service Name US].search.windows.net/indexes/posts/docs')
    asAPI.searchParams.set('api-key', AS_QUERY_KEY_US)
  }
  asAPI.searchParams.set('api-version', '2020-06-30')
  ...
  return await fetch(asAPI).then(res => res.json())
}

我选择的方式是根据收到请求的数据中心来判断,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
async function handleEvent(event) {
  ...
  if (pathname === '/search') {
    ...
    return new HTMLRewriter()
      .on("div.content", new InnerHTMLRewriter(html))
      .on("input.search-input", new InputValueRewriter(query))
      .transform(results[1])
  }
}

class InputValueRewriter {
  constructor(inputValue) {
    this.inputValue = inputValue
  }
  element(element) {
    element.setAttribute('value', this.inputValue)
  }
}

由于 Workers 会自动对 setAttribute() 中的内容进行转义,所以这里可以省略转义这一步骤。