Azure Search の Index と Indexer を自動生成する

今日は、Azure Search と一日格闘したので、その成果を記録しておきたい。

![Capture.JPG](https://qiita-image-store.s3.amazonaws.com/0/3470/76b07b77-5820-76f6-2b81-2e038619ae81.jpeg)


# Azure Search を導入した理由

現在パーソナルプロジェクトを開発中なのだが、CosmosDB で前方一致のクエリをする必要がある場面があった。当初考えたソリューションでは、該当の項目の先頭文字をインデックスとして保存して、文字の長さ別にそこをサーチするという作戦を考えた。ところが周りの人は Azure Search をすすめていたので素直に従ってみました。確かにロジックでごねごねやるよりも、ずっときれいだし、何より、Query を発行すればするほど、RUの制限が近づきますが、Search を併用すると、そこを防ぐことも出来て一石二鳥です。ついでにいうと、前方一致どころか、全文検索もできますし、ほかのカラムも検索できますし、自分が作っているプロダクトの要素からするとそちらのほうが１００倍フィットしそうです。




# 導入時の課題

導入時の課題としては、むかしちょろっと触ったことがあるぐらいで、あまりちゃんと使ったことがなかったところ、いきなり本番でガチで運用するシステムに導入するので、がっつり勉強が必要でした。勉強以外の点では次の点が疑問でした。

* Infrastructure as Code. 特に Index と Indexer の作成
* クエリーの書き方
* Microsoft.Azure.Search SDK 

ちなみに、最後の SDK ですが、私が公式ドキュメントのページ [Create an Azure Search index using the .NET SDK](https://docs.microsoft.com/en-us/azure/search/search-create-index-dotnet)を見ると近くに[サンプルのページ](https://azure.microsoft.com/ja-jp/resources/samples/?service=search&sort=0)がありますが、私がたまたま見たプロジェクトが、GAのものではなく、PreRelease の `Fluent` API のもので、ドキュメントは、`Microsoft.Azure.Search` なのだけど、あまりAPIの説明もなかったので（実際は[Getting Started with Azure Search using .NET](https://azure.microsoft.com/ja-jp/resources/samples/search-dotnet-getting-started/)にサンプルがあったｗ)自分でReSharperを使ってデコンパイルしまくりながら、動作を確認していきました。

上記のことについて整理しておきたいと思います。

# Index と Indexer の生成

## Azure Search の作成

今回は Terraform でサクッと作成

```text
resource "azurerm_search_service" "test" {
  name  = "${var.environment_base_name}search"
  location                  = "japanwest"
  resource_group_name       = "${azurerm_resource_group.test.name}"
  sku  = "standard"

  tags {
    environment = "production"
    database = "${azurerm_cosmosdb_account.test.name}"
  }
}
```
## Search Management Key の取得

残念ながらここは手段がなく、まだ実装していません。REST API があります。

* [Search Management](https://docs.microsoft.com/en-us/rest/api/searchmanagement/)

これをたたけばadmin key が取得できます。Terraform にはないので貢献しようかな、、、

他には Azure CLI を使う手もあります。

```bash
az search admin-key show --resource-group some-repository-rg --service-name somesearch
{
  "primaryKey": "SOMEKEYISHERE",
  "secondaryKey": "OTHERKEYISHERE"
}
```

## Index の作成

Cosmos DB に対してインデックスをかけたい場合は CosmosDB に対してインデックスとデータソースを作って、インデクサーというもので、スケジューリングする必要があります。まずはインデックス。この３つをやれば、ポータル上で操作するのとほぼ同じような設定ができます。

### Model を作る

Cosmos DB のテーブルのうち、検索対象にしたいものにアノテーションをつけます。ちなみに、元のはこんなのだったのですが、このままではうまくいきませんでした。`Release[]` というのがあるので、実際にIndexを作ると、`Release` が見つからないとエラーになります。悔しいですが、Search の項目を表すオブジェクトを作りました。

```csharp

    public class Package
    {
        [System.ComponentModel.DataAnnotations.Key]
        [JsonProperty("id")]
        public string Id { get; set; }

        [Required]
        public string Name { get; set; }
 
        public string Description { get; set; }
        public string Author { get; set; }
        [Url]
        public string ProjectPage { get; set; }
        [Url]
        public string ProjectRepo { get; set; }
        public DateTime CreatedTime { get; set; }
        public Release[] Releases { get; set; }

        // Column for Azure Search soft delete
        public bool IsDeleted { get; set; }

        public void GenerateId()
        {
            Id = Guid.NewGuid().ToString();
        }

    }
```

_SearchPackage.cs_

`[SerializePropertyNameAsCamelCase]` をつけておくと、Public のプロパティをCamelCase でサーチの項目とみなしてくれます。ちなみに、`[JsonProperty()]` が指定しているときは、そちらが優先されます。ほかには、`Data.Annotation.Key` としてキーの値を、`[IsSearchable]` や `[IsFilterable]` は、検索したい方法について書いてあります。 これらは、[Request Body Syntax](https://docs.microsoft.com/en-us/rest/api/searchservice/create-index#request-body-syntax)を参照してみてください。アトリビュートによって、どの項目に対してサーチをかけたかを設定できます。

```csharp
    [SerializePropertyNamesAsCamelCase]
    public class SearchPackage
    {
        [System.ComponentModel.DataAnnotations.Key]
        [JsonProperty("id")]
        public string Id { get; set; }
        [IsSearchable, IsFilterable, IsSortable]

        public string Name { get; set; }
        [IsSearchable]
        public string Description { get; set; }
        public string Author { get; set; }

        public string ProjectPage { get; set; }

        public string ProjectRepo { get; set; }
        public DateTime CreatedTime { get; set; }
        public string Releases { get; set; }

        // Column for Azure Search soft delete
        public bool IsDeleted { get; set; }
    }
```

ポイントの一つとしては、インデックス側からすると、CosmosDB のデータを物理削除しても、Search が知りえないので、論理削除する必要があります。`IsDeleted` がその項目です。論理削除の項目は後で指定します。



これに対しての、index の作成についてはこんな感じです。`BuilderForType` のところで、先ほどのインデックス対象のモデルを指定します。`CreateOrUpdateWithHttpMessageAsync` は無ければ作るし、あったらアップデートするので便利な感じです。

```csharp

  _client = new SearchServiceClient(searchServiceName, new SearchCredentials(adminApiKey));
           
   :
           var definition = new Index()
            {
                Name = INDEX_NAME,
                Fields = FieldBuilder.BuildForType<SearchPackage>()
            };
            await _client.Indexes.CreateOrUpdateWithHttpMessagesAsync(INDEX_NAME, definition);
 
```

ちなみに、インデックスの名前は、小文字で、字数制限もある様子です。

## DataSource の作成

インデックスが上記のでできますが、次はデータソース、つまり、インデックスを作る元です。CosmosDB に接続します。

若干ややこしいですが、CosmosDB に接続するコネクションストリングを渡します。`HighWaterMarkChangeDetectionPolicy` は、`_ts` つまり最終更新の時間を見て、前にインデックスを作成したときより新しければ、インデックスを作成します。また、先ほど説明した、論理削除の項目も指定しています。これで、データソースも完成。

```csharp
            var dataContainer = new DataContainer("Package", null);
            var dataSourceCredentials = new DataSourceCredentials(GetCosmosDBConnectionString());
            var dataChangeDetectionPolicy = new HighWaterMarkChangeDetectionPolicy("_ts");
            
            var dataDeletionDetectionPolicy = new SoftDeleteColumnDeletionDetectionPolicy("IsDeleted", true);

            var dataSource = new DataSource(
                DATASOURCE_NAME, 
                DataSourceType.DocumentDb, 
                dataSourceCredentials, 
                dataContainer,"Strikes CosmosDB Settings", 
                dataChangeDetectionPolicy,
                dataDeletionDetectionPolicy);

            await _client.DataSources.CreateOrUpdateWithHttpMessagesAsync(dataSource);
```



## Indexer の作成

最後にこれらを合わせて、Indexer を作成します。次のコードでは、30分おきにインデックスを更新するように、インデクサーを設定しています。今まで作ったデータソースやインデックスの値、スケジュールを入れておしまい！これも、あったら更新するので気軽に使えます。


```csharp
            var indexSchedule = new IndexingSchedule(TimeSpan.FromMinutes(30),DateTimeOffset.Now);
            var indexer = new Indexer(INDEXER_NAME, DATASOURCE_NAME, INDEX_NAME, "hourly scheduled", indexSchedule);
            await _client.Indexers.CreateOrUpdateWithHttpMessagesAsync(indexer);
```

## Search 

参考までにサーチはこんな感じ

```csharp
        public async Task<IEnumerable<SearchPackage>> SearchAsync(string query)
        {
            var parameters = new SearchParameters();
            var results = await _indexClient.Documents.SearchAsync<SearchPackage>(query, parameters);
            return results.Results.Select<SearchResult<SearchPackage>, SearchPackage>(p => p.Document);
        }
```

ちなみに、サーチのクエリですが、目標だった全文検索は、ポータルだとこんな感じで実行できました。ポイントとしては、前方一致だけでよくても、`IsSearchable` が必要という感じです。

```
search=h*&searchFields=Name
```



# まとめ

このようにすれば C# の世界でポータルでやったのと同じような設定ができて、自動でリソースをデプロイして、コンフィグして環境を作れます。最近は、C# は、.Net Core だったら Linux でも動作するので、私は、.Net Core でこのプログラムを作っておいて、Environment Variables 経由で、値を引き渡してこのプログラムを実行して、自動でコンフィグみたいなことをよくやっています。 CosmosDB の初期データセットアップみたいなもの同じ方法でやっています。

ちなみに、今回のコードですが、私のプロジェクトの一部ですが、シェアしておきます。

* [Strikes repository: AzureSearchServiceContext.cs](https://github.com/TsuyoshiUshio/strikes-repository/blob/master/StrikesLibrary/AzureSearchServiceContext.cs)

参考

* [インデクサーを使用した Cosmos DB と Azure Search の接続](https://docs.microsoft.com/ja-jp/azure/search/search-howto-index-cosmosdb)
* [Simple syntax query examples for building queries in Azure Search](https://docs.microsoft.com/en-us/azure/search/search-query-simple-examples)
* [OData expression syntax for filters and order-by clauses in Azure Search](https://docs.microsoft.com/en-us/rest/api/searchservice/odata-expression-syntax-for-azure-search)