[Azure + javascript]Functionsのhttp triggerな関数でSearchの検索結果を返す

暖簾に腕押し

私がAzure Functionsに提供してほしいと切に願っている機能の一つに、Search Bindings(仮称)があります。なお、私がこの機能実装のリクエストを出しました。

しかし、現時点ではこのような機能が提供される気配は全くなく、付帯するロジックを記述するしかありません。暖簾に腕押し、というやつです。

実際、このようなロジックを書くことすら億劫なのですが、嘆いていても仕方がないですので、javascriptからAzure Searchへ問い合わせを行うnpmライブラリazure-searchを利用し、レスポンスを返すところまで実装することにしました。

役割

  • クライアント : webAPIを利用し、HTTP Responseに含まれた検索結果を受け取ります。

  • Azure Functions : クライアントからHTTP Requestを受け取り、Azure Searchに問い合わせをし、結果をクライアントに返します。

  • Azure Search : クエリをFunctionsから受け取り、検索結果を返します。また、Indexerという機能を利用し、定期的にTable Storageからデータを同期します。

  • Table Storage : 検索対象となるデータがストックされています。Indexerによって、定期的にデータ参照されます。

データ構造

検索対象となるデータ構造は以下のようなものです。

[
  {
    "PartitionKey": "2018-08-29:myroom",
    "RowKey": "ecd53616-e756-41fb-98d2-fe2b387e0c8a",
    "id": "ecd53616-e756-41fb-98d2-fe2b387e0c8a",
    "channel": "myroom",
    "body": "5000兆円 欲しい!!!",
    "author": "ytnobody",
    "visible": true,
    "timestamp": 1535508299
  },
  ...
  ...
]

PartitionKeyおよびRowKeyはいずれもTable Storageで必須の項目です。(参照:Azure ストレージ テーブルの設計ガイド: スケーラブルな設計とハイパフォーマンスなテーブル

Search側のスキーマ構造

インデックスmessageには、Table Storageに格納されているデータ構造を、ほぼそのまま持ってきています。Table Storageで利用していたPartitionKeyおよびRowKeyはここでは使いません。

  • id Edm.String (key, retrievable, searchable)
  • channel Edm.String (retrievable, filterable)
  • body Edm.String (retrievable, searchable)
  • author Edm,String (retrievable, filterable)
  • visible Edm.Boolean (retrievable, filterable)
  • timestamp Edm.Int64 (retrievable, filterable, sortable)

普通に実装

最初、以下のように実装しました。

// function.json
{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "disabled": false
}
// index.js

module.exports = function (context, req) {
    // Searchクライアント初期化
    const AzureSearch = require("azure-search");
    const client = AzureSearch({
        url: process.env.SEARCH_URL, 
        key: process.env.SEARCH_KEY
    });

    // 検索ワードをスペースで切って配列にする
    const words = req.query.word ? req.query.word.split(' ') : [];

    // ページング指定
    const page = req.query.page ? parseInt(req.query.page) : 1;
    const top  = req.query.size ? parseInt(req.query.size) : 10;
    const skip = top * (page - 1);

    // 検索オプション
    const search = words.map(w => `body:${w}`).join(' AND ');
    const filter = 'visible eq 1';
    const searchOptions = {
        queryType:  "full",
        searchMode: "all",
        top:        top,
        skip:       skip,
        search:     search,
        filter:     filter
    };

    // 問い合わせ
    client.search('message', searchOptions, (err, results) => {
        context.res = err ? {
            status:  500,
            headers: {"Content-type": "application/json"},
            body:    {"message": `Internal Server Error: ${err}`}
        } :
        {
            status:  200,
            headers: {"Content-type": "application/json"},
            body:    results
        };
        context.done();
    });
};

察しの良い方なら気づいたかもしれませんが、このロジックは期待通りには動かず、502エラーを返してしまいます。

何がダメなのか

期待通りに動かない原因は、client.search(...)の結果を受け取る前にmodule.exports自体が処理を終えてしまうからです。

レスポンスらしいレスポンスを設定しないまま処理が終わってしまうので、502エラーを返す、ということです。

対応方法

結論から書くと、以下の2点を直すと良いです。

  1. module.exportsを、Promisereturnするように変更する。
  2. function.jsonにて、http output bindingsのname$returnにする。(portalの場合、応答パラメータ名のところにある「関数の戻り値を使用する」をチェックする)

なおしてみる

なおした後の実装がこちら。

// function.json
{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "$return"
    }
  ],
  "disabled": false
}
// index.js

module.exports = function (context, req) {
    // Searchクライアント
    const AzureSearch = require("azure-search");

    // 検索ワードをスペースで切って配列にする
    const words = req.query.word ? req.query.word.split(' ') : [];

    // ページング指定
    const page = req.query.page ? parseInt(req.query.page) : 1;
    const top  = req.query.size ? parseInt(req.query.size) : 10;
    const skip = top * (page - 1);

    // 検索オプション
    const search = words.map(w => `body:${w}`).join(' AND ');
    const filter = 'visible eq 1';
    const searchOptions = {
        queryType:  "full",
        searchMode: "all",
        top:        top,
        skip:       skip,
        search:     search,
        filter:     filter
    };

    // 問い合わせ
    const promise = Promise.resolve(AzureSearch({
        url: process.env.SEARCH_URL, 
        key: process.env.SEARCH_KEY
    }))
    .then(client => client.search('message', searchOptions))
    .then(results => {
        return {
            status:  200,
            headers: {"Content-type": "application/json"},
            body:    results
        };
    })
    .catch(err => {
        return {
            status:  500,
            headers: {"Content-type": "application/json"},
            body:    {"message": `Internal Server Error: ${err}`}
        };
    });

    return promise;
};

function.jsonでは、http output bindingsのname$returnとなっており、functionの戻り値をレスポンスに使う設定となっています。

そしてindex.jsではPromise.resolve(...).then(result => ...).catch(err => ...)の形式でSearchに問い合わせを行った後の処理をハンドリングするよう定義し、promiseそのものをreturnするロジックへと書き換えられました。

本当はドキュメントに書いておいて欲しかった、もしくは・・・

実はPromiseをreturnすることで解決できるという事について、公式ドキュメントには書かれていないようです(ソース。openになっているし、書こうとはしてる模様)。

今回の解決法は、上記issueを辿ってようやく見つけることができたものでした。

この手の手間をなくすためにも、Search Bindingsが欲しいな、と思うのでした。

まとめ

  • Search Bindings欲しいですね。

2018-08-30 追記

node-azure-searchでPromise/thenを使った書き方では、検索結果のヒット件数を取得することができないという問題がありました。

const AzureSearch = require('azure-search');
const word = '5000兆円';
AzureSearch(...)
    .then(client => search({
        queryType:  "full",
        searchMode: "all",
        top:        20,
        skip:       0,
        search:     `message:${word}`,
        filter:     'visible eq 1',
        orderby:    'timestamp desc',
        count:      true // <--- @odata.countをレスポンスに含めるための指定
    }))
    .then(rows => {
        // rowsは検索結果(オブジェクト)が入った配列。
        // ここで検索結果のヒット件数である@odata.countを利用したいができない!!
    })
    .catch(err => { ... });

これはnode-azure-searchのindex.jsを修正することで、取得できるようになります。(ただしインターフェイスを破壊する変更です)

@@ -492,7 +492,7 @@ module.exports = function (options) {
            return new Promise(function (resolve, reject) {
              args.push(function (err, value, data) {
                if (err) reject(err)
-               else resolve(value)
+               else resolve(data) // resolve(value) not contains '@odata.count'
              })
              fn.apply(self, args)
            })

利用する側は以下のようになります。

const AzureSearch = require('azure-search');
const word = '5000兆円';
AzureSearch(...)
    .then(client => search({
        queryType:  "full",
        searchMode: "all",
        top:        20,
        skip:       0,
        search:     `message:${word}`,
        filter:     'visible eq 1',
        orderby:    'timestamp desc',
        count:      true // <--- @odata.countをレスポンスに含めるための指定
    }))
    .then(result => {
        const count = result['@odata.count'];  // 検索結果のヒット件数。
        const rows  = result['value'];         // rowsは検索結果(オブジェクト)が入った配列。 
        ...
    })
    .catch(err => { ... });

破壊的な変更であるため、forkして利用しています。