それでも気分は高専生

元高専生が自分のやってきたことを記事として残すためのてきとーなブログ

AWS SQSを使ったJobQueueシステムを組んでみた

サンプル実装

github.com

モチベーション

SQSを使ったジョブキューシステムをつくってみたかった

(実装コストとかの肌感を知りたい)

実装したもの

処理の流れは以下の通り。

  1. Publisherがランダムな整数を生成
  2. SQSにジョブとしてメッセージを投げる
  3. Subscriberがジョブを受け取り、1で生成した整数を素因数分解
  4. SubscriberがDynamoDBに結果を登録

(大した計算時間になっていなくて、ジョブキューシステムの適切な例なのか?と問われるとモゴモゴ...)

簡単に動作、理解できるようにAWS環境はlocalstackで再現し、プログラムはPythonで書いた。

得られた知見

複数のSubscriberがいる場合や、バグや異常なメッセージの取り扱い

このあたりの複雑な機構がSQS + boto3だとかなりお手軽に実装できて良き。

M1 Macでterraform-provider-awsを使おうとしたらなにもしていないのに壊れました

TL;DR

己でビルドせよ

発端

生まれて初めてのM1 MBPを手にした。

意気揚々といつも通りにTerraformを使おうとしたところ、何もしていないのに壊れた。

$ tf init
│ Error: Incompatible provider version
│
│ Provider registry.terraform.io/hashicorp/template v2.2.0 does not have a package available for your current platform, darwin_arm64.
│
│ Provider releases are separate from Terraform CLI releases, so not all providers are available for all platforms. Other versions of this provider may have different platforms supported.

原因

terraform-provider-awsが依存しているterraform-provider-templateアーカイブされてしまったそう。

定義は色々と書いてあるものの、要するにこんな感じ。

  • 公開し続けるけど開発はストップするよ
  • 結構昔にストップしたから、M1 Mac向けのバイナリは提供していないよ
  • 使いたきゃ自分でビルドしな

ちなみにterraform-provider-aws自体はv3.30.0からM1 Mac向けのバイナリを提供しているみたい。

解決

# プロバイダをビルド
$ ghq get https://github.com/hashicorp/terraform-provider-template.git
$ cd ~/ghq/github.com/hashicorp/terraform-provider-template
$ make build

# ビルドしたプロバイダを配置
$ cd path/to/other/repository
$ mkdir -p terraform.d/plugins/registry.terraform.io/hashicorp/template/2.2.0/darwin_arm64
$ cp $GOPATH/bin/terraform-provider-template terraform.d/plugins/registry.terraform.io/hashicorp/template/2.2.0/darwin_arm64/terraform-provider-template_v2.2.0x5

terraformコマンド実行時に ./terraform.d/plugins ディレクトリがあると、そこのバイナリファイルを優先的に使うらしい

www.terraform.io

更なる問題

ここで一つ問題が...

terraform-provider-templateのバイナリが大きすぎて、githubリポジトリに載せられないらしい。

provider_installation.network_mirrorを設定してやれば、自分でビルドしたバイナリをS3でホストしてそこから持ってくる...なんてこともできるかもしれない。

今日は疲れたので切り上げる。

後日

$HOME/.terraform.d/plugins に置けば全然問題なかった。

locustで負荷試験を実施する際のTips

locustで負荷試験をする際に苦労したことをメモ書き程度に書き留めていく。

(locustやpythonに慣れ親しんだ人には常識かもしれないが...)

RPSを固定するときはconstant_throughput/constant_spacingを使う

想定負荷をかけたときの対照システムの挙動をみたいときなど、RPSを一定に保ちたい時がある。

sampleなどを探してもいまいち見当たらないが、constant_throughput/constant_spacingという関数があり、wait_timeを指定する際に使うと面白いほどRPSが安定する。

API — Locust 2.5.0 documentation

FastHttpUserを使う

HttpUserより早いFastHttpUserが存在し、そちらを使うことでより高いパフォーマンスを出せることがある。

リソースが余っているのにRPSが頭打ちになっている場合、FastHttpUserに切り替えることで解決することがある。

Increase performance with a faster HTTP client — Locust 2.5.0 documentation

getメソッドにパラメタを渡す際にはname引数を渡す

リクエストを投げる際にgetメソッドへリクエストパラメタを付与する場合(ex. /hoge/foo?var1=piyo&var2=bar)、locsutのstaticsはパス毎に集計されるため異なるリクエストパラメタは全て異なるパスとして集計される。

以下の例のようにname引数を指定することで、'/hoge/foo'へのリクエストはどんなリクエストパラメタを渡されても同じパスとして集計されるようになる。

self.client.get('/hoge/foo', params={'var1': 'piyo', 'var2': 'bar'}, name='/hoge/foo?[params]')

API — Locust 2.5.0 documentation

with文を使わずcatch_response=Trueを指定しない

リクエスト時にwith文を使わずにcatch_response=Trueの引数を指定するとリクエストが流れなくなる。

on_startで時間のかかる処理を実施しない

大きなファイルを読み込むなど、時間のかかる処理を行う場合はon_startではなく、initイベントに登録した関数を使う。

on_startはuserがスポーンするたびに実行されるが、initイベントはノードが起動した場合に実行される。

Writing a locustfile — Locust 2.5.0 documentation

長時間スレッドをブロックしているとworker→masterのヘルスチェックが失敗する

大きなサイズのファイルを一気に読み込もうとすると、ディスクIOがスレッドを長時間ブロックしてしまう。

locustのスレッドモデルの詳細は知らないが、スレッドが長時間ブロックされるとクラスタのヘルスチェックが失敗するので、負荷がかけられなくなる。

また何かこまったことがあったら追記する。

Hachicorp Vaultをつかって証明書でSSHの認証を行う

どうもへたれです。

皆さんはこんな経験がありませんか?

チームで複数サーバを共有していて、ログインのためのクレデンシャル情報を管理したい!

でも一々鍵登録するのめんどくさい...

そんなあなたにぴったりなのが証明書をつかったSSH鍵認証です。

しかもVaultを使うことによって構築が超絶楽になるらしいです。

www.vaultproject.io

仕組みとしては下の図の通りで

  1. VaultをCA局として設置し、その証明書を各サーバのsshdに登録
  2. 新しいユーザは秘密鍵と公開鍵を生成し、CA局から公開鍵への署名をもらう (これで全てのサーバへSSHが可能)
  3. ログイン時に公開鍵と署名を使ってサーバにSSH
  4. サーバは署名を検証し、検証に成功すればログインしてSSHでの操作を許可

という感じになり、新規ユーザ登録は一回数分のオペレーションで済みます。

f:id:takahiro0914:20200919175826p:plain

ということで試しに docker-compose で VaultのCA局を立ててSSHハンズオン的なものを作ってみました。

github.com

Vaultを使うことで

  • 簡単にCA局がたつ
  • vault-cliを使って鍵の署名オペレーションが簡単にできる
  • 署名オペレーションを行う際にもポリシーを使って権限管理が可能
  • HA構成が可能

といったメリットがあるかなと思っています。

逆にこの方法だと、Vault側でオンデマンドな署名の無効化ができないといった問題もあるので、そういう時はVault Login Helperなるものがあるので、そちらを使っていきましょう。

IUSCON10予選に参加してきました

お久しぶりです、へたれです。

今年も開催されました、ISUCON予選!!

ということで自分も「チーム豚キムチ2307」として社会人になって初めて参加してきたので、どんなことをしてきたのかを簡単に記事にしようと思います!

(記事を書くためにスクショ等を残していなかったので、記憶があやふやになってしまうのですが...)

ちなみに予選レギュレーションはこんな感じです。

インフラ構成

  • インフラ
    • サーバ3台
    • 1 core
    • 2GB
    • Ubuntu 18
  • ソフトウェア
    • mysql
    • 選択した言語はgo
      • Web App Frameworkはecho
      • ORM/SQL Builderはsqlx

実際にやったこと

チーム内では自分がアプリケーション方面をメインに、相方のはひふへほ氏がインフラ/ミドルウェア方面をメインに作業を進めていきました。

(ちなみに時間配分については特に記録に残していないので、アヤフヤなキヲクを辿っていく...)

相方がやったこと

(今回まともに自分が動けたのも去年から相方がこの辺の準備をメチャクチャ頑張ってくれたからです...感謝感謝です :bow:)

  • 環境構築
    • ツール入れ
    • コードをプライベートリポジトリ化
    • 初期環境のバックアップ
  • ssh設定
  • 秘伝のタレ流し込み
  • nginx複数台構成 (やってみたけどアプリが遊んでて意味ないからやめた)
  • DBを分離
  • MySQLのテーブルへIndexを貼る
  • User-Agentを用いたbot対策
  • レプリケーション (非同期、準同期まで、結局同期ができずにやめた)

自分がやったこと

初動

レギュレーション/マニュアル読む

ISUCON最初の作業、マニュアル通読です。

  • インフラの構成
  • アプリケーションのシナリオ (コードを読みながら処理の流れを理解するのに大事)
  • スコアの採点方法 (これを理解しないとスコアの伸ばし方が分からないので大事)
  • その他注意点 (今回ここで記載されていたUser-AgentでBotを弾くというのが非常に大きなスコアになりました)

を確認しました。

改変前にファイル分割

本当はこの前にベンチを回しておきたかったのですが、アクシデントのためベンチが回せず...

まずは相方が用意してくれていたリポジトリを手元にクローンしてファイルを分けました。

最初は main.go しかないのですが、このままではマージの際にコンフリクトの嵐となります。

したがって最初に役割ごとにファイルを分割し、手を加えるものに関しては個別のファイルに抜き出して変更するという運用にしました。

  • main.go: 初期化関数とグローバル変数
  • handlers.go: ハンドラ関数
  • types.go: 構造体とそのメソッド

newrelic infraの導入

今年はisucon参加者へnewrelicの無料アカウントが付与されていたので、newrelic infraを導入しました。

(ちなみにnewrelicのgoエージェントは「コードを差し込む」「最後のベンチ前にコードを抜く」という作業がすごく工数がかかりそうだったので採用を見送りました)

より詳細なデータは得られるようになるものの、どの処理が重いかの確認はpprofで十分確認できると判断したというのもあります。

コードの読み込み

まだベンチが回せなかったので、計測ができず...

とりあえずハンドラ関数をざっと見渡しながら

  • N+1問題はないか
  • SQLWHEREORDER BY でどんなフィールドが指定されているか
  • 0_Schema.sql を読んで、どんなインデックスが貼られているか

をチェックしつつ、気になるところをリストアップしていきました。

初回ベンチ回す

問題が解消され、初回ベンチが回せるようになったのでとりあえず回す。

Score: 484

スコアアップに向けた作業

searchEstateNazotteのN+1改善

ここでpprofやkataribeの結果から searchEstateNazotte 関数が一番重いということが発覚しました。

そこでコードを読むとN+1問題が発生していることができ、ここの改善に着手することに。

元は「画面内の物件を一通りSQLで取得して、一件ごとに手書き図形の中に入っているかをSQLで確認する」という方式でした。

そこで処理を「画面内の物件を一通りSQLで取得して、IN句を使って手書き図形の中に入っているかでフィルタをかけた物件ををSQLで取得する」という方式に変更する形で実装。

しかし互換性のチェックでこけ続ける形に...

(ISUCONのコード変更で一番辛いのは互換性のチェックだと思います)

ここでisuconのアプリケーションはsystemdで動作しているのですが、なぜかログが表示されず...そこを直すことに決めました。

JSONエンコードの高速化

ログの調査に難航していた頃、相方が「pprof見たらJSONエンコーディングがめっちゃ時間かかっとるよ」との発見を共有してくれました。

正直作業が難航していて心がやつれてきたので、一旦今の作業は中断してJSONエンコーディングの高速化へ。

echoのコードを見ると、JSONエンコーディングには標準パッケージの encoding/json を使用していました。

そのため、JSONエンコーディングを jonitor に変更することで、高速化を図ることに。

godoc.org

具体的には

c.JSON(http.StatusOK, res)

import jsoniter "github.com/json-iterator/go"

var json = jsoniter.ConfigCompatibleWithStandardLibrary
...
resStr, _ := json.MarshalToString(res)
c.String(http.StatusOK, resStr)

みたいな感じにしました。

相方の作業により既に大きくスコアを伸ばしていたため、スコアはそこまで大きく変わりませんでしたが、pprofを除くと明らかにJSONエンコードが占める時間が減っており、効果は確かでした。

Score: 992

ログが見えるように

ログが確認できるように色々と試行錯誤した結果、pprofの導入と一緒に入れたコードによる仕業だと判明したのでそちらを削除、無事ログの確認ができるようになりました。

searchEstateNazotteのN+1改善

ログが確認できるようになったことで、searchEstateNazotteのデバッグが行えるようになり、ここでバグの修正を完了してベンチの完走まで持っていくことができました。

450点ほどスコアが伸びたので、非常に嬉しかったです。

Socre: 1454

MySQLレプリケーション構成

ここで次に何に取り組もうかを考えていたところ、相方が「Nginx + AppサーバってほとんどCPUもメモリも使いきれていないけど、DBサーバのロードアベレージがずっと高いところで張り付いている」と気付きをシェアしてくれました。

(本当に相方が優秀すぎて、今回ほとんど相方のいいなりに修正しているだけな気がします...)

「DBを2台にして負荷分散を行おう」という話になったのですが、今回のソースを見ると様々なクエリでWHERE句やORDER BY句が使われていて、searchEstateNazotte に至っては座標計算まで行われてしまっていました。

そこでRedisへのデータ移行は実質不可能と判断し、MySQLをマスタースレーブ構成にしてレプリケーションするという方針にしました。

作業としては

  • 相方: Appサーバ以外の2台でMySQLクラスタを構築
  • 自分: アプリケーションにWriteとReadの向き先を追加する機構を追加

するといった分担にしました。

自分はmssqlxというslqx互換のライブラリを見つけ「これひょっとして運営の意図した通りじゃね!」と喜んで組み込んでいました。

github.com

一方で相方はここでも優秀で、初めての試みでちゃんとMySQLレプリケーションを組んでくれました!

しかしベンチを回すと、Updateが頻繁に発生し、Readした際に不整合が発生するという問題が発覚しました。

この不整合をどう解決しようかと探っていたところ、タイムアップ。

最後にnewrelic infraやpprofを外し、最後にベンチを回して終了しました。

感想

最後のDBがボトルネックになっている問題が解決できなくて非常に悔しかったです、これさえクリアできれば予選突破できそう!と思っていただけに残念でした。

ですがチーム内で連携して、議論して、少しずつスコアを上げていけたので体験や学びとしては非常によかったです!

あとはちゃんと「何がボトルネックになっているのか」を見抜けるよう監視の読み方を頑張るのと、サービスを開発/運用していく上でのノウハウをいっぱい貯めていかなきゃという感じです。

ISUCON10予選参加者のみなさん、8時間お疲れ様でした!

GithubPagesでポートフォリオサイトをホスティング

これから仕事をやっていく中で、マメに記録していかないと自分の活動とかの整理がつらそうだなと思ったので作りました。

https://oriishitakahiro.github.io/portfolio/

技術的には非常に簡単で、何番煎じかも分からないGithub Pages + Hugoです。

構築環境

苦労したところ

せっかくなのでCircleCIでCiを組んだのですが、自分があまりCIに慣れていないせいか attache_workspace を忘れたり、gh-pagesブランチへコミットする際の認証で悩んだりしていました。

Githubへの認証はAccess Tokenを環境変数で渡して運用しています。

リポジトリはこちらです。

github.com

macOSにsbt環境を入れようとしたら String.class is broken ...

sbtで動作するプロジェクトを動作させようとして、環境構築を行った際に

$ sbt run
> ...
> error: error while loading String, class file '/modules/java.base/java/lang/String.class' is broken (class java.lang.NullPointerException/null)
> ...

と出てしまい、動作しなくなる現象に直面してしまいました。

その解決方法について書いていこうと思います。

環境

現象

  • sbtenvを導入し、sbt1.2.7をインストール
  • scalaenvを導入し、scala2.13.0をインストール
  • jenvを導入し、brew install openjdkでインストールしたjava14.0.2jenv globalに指定

その状態でプロジェクトを動作させようとすると、以下のエラーに遭遇しました。

$ sbt run
> ...
> error: error while loading String, class file '/modules/java.base/java/lang/String.class' is broken (class java.lang.NullPointerException/null)
> ...

原因

調べてみると同様の質問がStack Over Flowに投稿されていました。

brewがJDK13に強く依存しており、他のバージョンのJavaがあっても、JDK13を参照しにいってしまう

とのことです。

自分はJavaScala周りを詳しく知らないので、なぜJDK13を参照してしまうのか、なぜJDK8/11がsbtに必要なのかは良くわかりませんが、やるべきことは書いてくれているのでやっていきましょう。

stackoverflow.com

解決手順

今回はJDK8を導入することにしました。

$ brew tap AdoptOpenJDK/openjdk
$ brew cask install adoptopenjdk8
$ jenv add /Library/Java/JavaVirtualMachines/zulu-8.jdk/Contents/Home
$ jenv versions
>  1.8
>  14
>  14.0
>*14.0.2

sbtのプロジェクトを動作確認するなら以下の通りです。

$ jenv local 1.8
$ jenv local
> 1.8
$ sbt run

SolrでConflの記事を検索 - 検索フロントエンド編

検索フロントエンド編

SolrでConflの記事を検索 - それでも気分は高専生の個別実装第三弾です。

前回の記事まではConflの記事を取得し、Solrにインデクシングしてクエリを投げられる状態を作るところまで来ました。

ここまでで検索システムの機能としては十分なのですが、検索結果が生のJSONとなっているため、非常に見辛いです。

そこでフロントエンドのシステムを開発して、使い勝手のいい検索システムを目指しました。

今回フロントエンドに利用したのはReact+TypeScriptです。

TypeScriptでSolrへクエリを投げる

まずTypeScriptからSolrへクエリを投げるところを作ります。

SolrへのリクエストはJSON Request API | Apache Solr Reference Guide 8.1を通じて行います。

JSON APIを使った方がクエリの取り回しが楽だったので....

ファセットの絞り込みがfqじゃなくてqにANDで繋げてしまっているという痛恨のミス...

また、クエリではフィールド毎に繋げる条件を変え、前の方の単語が高く重み付けされるというちょっとした工夫を加えてみました。

const solrEndpoint = "http://localhost:8983/solr/confl/query?sow=true";

const requestTemplate = {
  query: "*",
  filter: new Array<string>(),
  offset: 0,
  limit: 10,
  fields: [
    "id",
    "title",
    "space_name",
    "createdBy_username",
    "createdBy_displayName",
    "view",
    "URL",
  ],
  facet: {
    sort: "count",
    categories: {
      type: "terms",
      field: "labels",
      limit: 10,
    },
  },
};

function filterToCondition(filter: string[]) {
  if (filter.length === 0) {
    return "";
  }
  return " AND " + filter.map((e) => `labels:${e}`).join(" AND ");
}

function weight(idx: number) {
  return 50 * Math.exp(-1 * idx);
}

function textToCondition(query: string) {
  const terms = query.split(" ");
  const title = terms.map((e, i) => `title:${e}^${weight(i)}`).join(" OR ");
  const view = terms.map((e, i) => `view:${e}^${weight(i)}`).join(" AND ");
  const space = terms.map((e, i) => `space:${e}^${weight(i)}`).join(" OR ");
  return `((${title}) OR (${view}) OR (${space}))`;
}

export function Search(
  query: string,
  filter: string[],
  setArticles: React.Dispatch<React.SetStateAction<never[]>>,
  setFacets: React.Dispatch<React.SetStateAction<never[]>>
) {
  const request = requestTemplate;
  request.query = textToCondition(query) + filterToCondition(filter);
  console.log(request.query);
  (async () => {
    await fetch(solrEndpoint, {
      method: "post",
      mode: "cors",
      headers: {
        "Content-Type": "application/json; charset=utf-8",
      },
      cache: "no-cache",
      body: JSON.stringify(request),
    })
      .then((res) => res.json())
      .then((data) => {
        console.log(data);
        let newArticles = data.response.docs.map((e: any) => new Article(e));
        setArticles(newArticles);

        let newFacets =
          data.facets.count === 0
            ? []
            : data.facets.categories.buckets.map((e: any) => new Facet(e));
        setFacets(newFacets);
      })
      .catch(function (error) {
        console.log(error);
      });
  })();
}

ArticleとFacetの定義はこんな感じです。

Articleは各記事を表現するクラスで、FacetはSolrのレスポンスに含まれるファセットを表現します。

ファセットには、インデクシングCLIで付与されたラベル、検索結果のなかにそのラベルが何記事含まれているか、という情報が含まれています。

検索結果でArticleとFacetのリストを表示し、Facetから絞り込む対象のラベルを指定する...というイメージです。

export class Facet {
  public val: string;
  public count: Number;
  public constructor(obj: any) {
    this.val = obj.val;
    this.count = obj.count;
  }
}
export class Article {
  public id: string;
  public url: string;
  public displayName: string;
  public userName: string;
  public spaceName: string;
  public title: string;
  public view: string;
  public constructor(obj: any) {
    this.id = obj.id;
    this.url = obj.URL;
    this.displayName = obj.createdBy_displayName;
    this.userName = obj.createdBy_username;
    this.spaceName = obj.space_name;
    this.title = obj.title;
    this.view = obj.view;
  }
}

検索のアクションをUIに埋め込む

以下のタイミングで検索クエリをSolrに投げてArticleとFacetを更新します。 - Searchボタンが押されたタイミング - filterが変更を受けたタイミング

ここでfilterはユーザがページを訪れて以来、押したファセットを記憶しておくリストになります。

function App() {
  const [articles, setArticles] = useState([]);
  const [facets, setFacets] = useState([]);
  const [filter, setFilter] = useState(new Array<string>());
  const [query, setQuery] = useState("*");

  useEffect(() => {
    Search(query, filter, setArticles, setFacets);
  }, [filter]);

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <div>
          <Input
            className="QueryForm"
            color="primary"
            type="search"
            defaultValue={query}
            onChange={(e) => setQuery(e.target.value)}
          ></Input>
        </div>
        <div>
          <Button
            color="primary"
            variant="contained"
            className="Search"
            onClick={() => {
              Search(query, filter, setArticles, setFacets);
            }}
          >
            Search
          </Button>
        </div>
        <div>
          <Results
            articles={articles}
            facets={facets}
            filter={filter}
            setFilter={setFilter}
          ></Results>
        </div>
      </header>
    </div>
  );
}

export default App;

検索結果を見せる

ここでやることは、いい感じに検索結果を見せることです。

ロジックらしいところはファセットがクリックされた時に、setFilters()を実行してuseEffect()が発火、Search()が実行されるようにしたぐらいです。

また見た目を整えるのにはReact-Material-uiを利用しました。

material-ui.com

type ResultProps = {
  articles: Article[];
  facets: Facet[];
  filter: string[];
  setFilter: React.Dispatch<React.SetStateAction<string[]>>;
};

const newFacetStyle = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: "100%",
      maxWidth: 360,
      backgroundColor: theme.palette.background.paper,
    },
  })
);

const newArticleStyle = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: "100%",
      backgroundColor: theme.palette.background.paper,
    },
  })
);

export function Results(props: ResultProps) {
  const facetStyle = newFacetStyle();
  const articleStyle = newArticleStyle();
  return (
    <div className="Result">
      <Grid
        container
        direction="row"
        justify="flex-start"
        alignItems="flex-start"
      >
        <Grid item xs={2}>
          <List className={facetStyle.root}>
            {props.facets.map((e, i) => (
              <ListItem
                key={i}
                alignItems="flex-start"
                onClick={(event) => props.setFilter(props.filter.concat(e.val))}
              >
                <Button color="primary">
                  {e.val} ({e.count})
                </Button>
              </ListItem>
            ))}
          </List>
        </Grid>
        <Grid item xs={10}>
          <List className={articleStyle.root}>
            {props.articles.map((e) => (
              <ListItem key={e.id} alignItems="flex-start">
                <ListItemText
                  primary={
                    <Link href={e.url}>{`${e.title}: ${e.displayName}`}</Link>
                  }
                  secondary={
                    typeof e.view === "undefined"
                      ? "no contents"
                      : e.view.substring(0, 400) + "..."
                  }
                ></ListItemText>
              </ListItem>
            ))}
          </List>
        </Grid>
      </Grid>
    </div>
  );
}

完成したフロントエンド

以上の流れでフロントエンドを開発しました。 以下が完成したものになります。

f:id:takahiro0914:20200805004932p:plain
Webフロントの完成図

そのうち開発して感じたことや感想を書きます...

SolrでConflの記事を検索 - Solrの環境構築編

Solrの環境構築編

SolrでConflの記事を検索 - それでも気分は高専生の個別実装第二弾です。

前回の記事ではCLIを使ってConflから記事を取得し、Solrにインデクシングする仕組みを作るところまでやりました。

しかしこのままでは、Solr側がどのようなデータを受け取っていいかわかりません。

そこでSolr側の受け取り準備をしていきます。

ディレクトリ構成

最初にイメージを掴みやすくするために、ディレクトリ構成を記述しておきます。

.
|-- README.md
|-- docker-compose.yaml
`-- solr
    |-- data
    |-- managed-schema
    `-- web.xml

docker-composeの用意

今回はSolrの公式Dockerイメージを利用して環境を構成していきます。

hub.docker.com

version: '3'
services:
  solr:
    image: solr:8.5
    container_name: 'solor_sample'
    ports:
      - "8983:8983"
    volumes:
      - ./solr/data:/var/solr/data
      - ./solr/web.xml:/opt/solr-8.5.2/server/solr-webapp/webapp/WEB-INF/web.xml
    restart: always

Solrにはコアと呼ばれるインデクシングするための、データ倉庫の単位みたいなものがあります。

/var/solr/dataにはコアが格納されていて、インデクシングされたデータとそのメタデータなどが入っています。

ここではコアの格納されたディレクトリをホストにマウントすることで一度インデクシングされたデータを永続化しています。

また、opt/solr-8.5.2/server/solr-webapp/webapp/WEB-INF/web.xmlにはSolrのサーバとしての設定が記述されており、後述のフロントエンドからの利用に必要になるCORSの設定を記述しています。

managed-schemaを用意

Solrはインデクシングされるドキュメントがどんなフィールドを持っているか(タイトルや著者、作成日時、本文、etc...)?をスキーマという形で指定します。

スキーマはmanaged-schemaというXMLファイルでコア毎に管理され、/solr/data/コア名/conf/managed-schemaに保存されます。

今回定義したフィールドはこんな感じです。

...
    <field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false" />
    <!-- docValues are enabled by default for long type so we don't need to index the version field  -->
    <field name="type" type="string" indexed="false" stored="true"/>
    <field name="title" type="text_ja" indexed="true" stored="true"/>
    <field name="space_name" type="string" indexed="true" stored="true"/>
    <field name="createdBy_username" type="string" indexed="true" stored="true"/>
    <field name="createdBy_displayName" type="string" indexed="true" stored="true"/>
    <field name="view" type="text_ja" indexed="true" stored="true"/>
    <field name="url" type="string" indexed="false" stored="true"/>
    <field name="labels" type="string" indexed="true" stored="true" multiValued="true"/>
...

一部解説しておきますと、

filed.type = "string"は文字列で、インデクシング時に内部でトークナイズ(形態素解析されてバラバラの単語に分割)されないものです。

filed.type = "text_ja"は文字列で、インデクシング時に内部でトークナイズされ、その際に利用される辞書は日本語のものとなります。

他にも数値や日時など様々なfield.typeがあります。

また、field.indexed=trueはコア内でインデクシングの対象とするか否かを指定しています。

インデクシングのオーバーヘッドにも繋がるので、検索対象としたいものにだけtrueとすると良いらしいです。

stored='true'はインデクシングしたフィールドをSolr内部で保存するか否かを指定しています。

あくまで保存するだけなので、これだけでは検索対象のフィールドとしては不十分であることに注意してください。

クライアントに見せたいフィールドにつけるといいです。

最後にmutiValued="true"はそのフィールドがリストであることを示します。

ここでは一つの記事が複数のラベルを保持する可能性があるため、指定しています。

Solrの起動

では早速Solrの方を動かしていきましょう。

# Solrの起動
$ docker-compose up -d
# conflというCoreを作成
$ docker-compose exec solr bin/solr create_core -c confl
# 予め作っておいたmanaged-schemaをコピー
$ cp solr/managed-schema solr/data/confl/conf/managed-schema

ここからは管理コンソールからの作業になります。

http://localhost:8983/solr/#/~conflを開き、CoreAdmin → conflを選択 → Reloadボタンを押下することで、managed-schemaをコアに反映させます。

Core Selector → confl → Files → managed-schemaで変更が反映されていることを確認しましょう。

インデクシング

これで準備が完了しました。

SolrでConflの記事を検索 - インデクシングCLI編 - それでも気分は高専生

で開発したインデクシングCLIを使ってSolrへConflの記事を流し込んでいきましょう。

$ go run *.go import -u <Conflのユーザ名> -p <Conflのパスワード>

クエリを投げる

インデクシングが一通り終わったら、これで検索できるようになっているはずです。

管理コンソールのCore Selector → confl → Query でクエリを投げられます。

f:id:takahiro0914:20200805001157p:plain
クエリの記述画面

クエリでは以下のものが指定できます。

  • q: クエリを記述
    • フィールド名:値 AND フィールド名:値のように様々な演算子やフィールドを組み合わせることが可能
    • またフィールド名:値^重み OR フィールド名:値^重みとすることで、細かい検索結果の調整も可能
  • fq: クエリ検索にかけるフィルタ
  • fl: 検索結果として返すフィールドの値
  • sort: ソートに利用するフィールド、何も入力しないとSolrのスコアリング順
  • debugQuery: クエリのデバッグが見られる、クエリに利用する値のトークナイズなどが見られて便利
  • hl: 検索結果のハイライティングを利用するか否か (ヒットしたワードが強調されて表示されるやつ)
  • facet: ファセットを結果に含めるか否か

フロントエンド開発編へ続く...

SolrでConflの記事を検索 - インデクシングCLI編

インデクシングCLI

SolrでConflの記事を検索 - それでも気分は高専生の個別実装編第一弾です。

ConflではREST APIを通じて、様々な記事をJSON形式で取得することができます。

例えばページを取得したい場合、以下のようにしてページ一覧を取得することができます。

ACCOUNTPASSWORDは自分のConflアカウントです。

#!bin/sh
ENDPOOINT="conflのホスト名"
LIMIT=30
TYPE=page
EXPAND=space,history,body.view,metadata.labels

read -p "Account: " ACCOUNT
read -p "Password: " -s PASSWORD
read -p "from(int): " FROM

curl -X GET -m ${TIMEOUT} "https://${ENDPOOINT}?type=page&start={$FROM}&limit=${LIMIT}&expand=${EXPAND}" -u "${ACCOUNT}:${PASSWORD}" -H 'Accept: application/json'

したがって、FROMを0から始め、FROM=FROM+LIMITとページネーションを刻んで取得していくことで、いずれは全ページがインデクシングできるというわけです。

しかし、ConflのAPIドキュメントを読んでいただくと分かるのですが、様々な要素がネストしたJSONとなっており、SolrでネストしたJSONをインデクシングするのは大変難しいです。

developer.atlassian.com

そこでConflのJSONをパースするための構造体、Solrに投げるためのJSONを生成する構造体をそれぞれ定義し、インデクシングCLIの中で変換しました。

(以下ソースコードが続きますが、実際に作ったものの中から適当に抜き出したものなので、適宜想像力で補完してください)

ConflのJSONをパースする構造体

type (
    Space struct {
        Name string `json:"name"`
    }

    History struct {
        CreatedBy struct {
            UserName    string `json:"username"`
            DisplayName string `json:"displayName"`
        } `json:"createdBy"`
    }

    Body struct {
        View struct {
            Value string `json:"value"`
        } `json:"view"`
    }

    ArticleConfl struct {
        ID      string  `json:"id"`
        Type    string  `json:"type"`
        Title   string  `json:"title"`
        Space   Space   `json:"space"`
        History History `json:"history"`
        Body    Body    `json:"body"`
        Links   struct {
            WebUI string `json:"webui"` // webUI path
        } `json:"_links"`
    }

    ArticleConflList struct {
        Results []ArticleConfl `json:"results"`
        Links   struct {
            Next string `json:"next"`
            Prev string `json:"prev"`
        } `json:"_links"`
    }
)

Solrへ投げるJSONを構成する構造体

type Article struct {
    ID                   string   `json:"id"`
    Type                 string   `json:"type"`
    Title                string   `json:"title"`
    SpaceName            string   `json:"space_name"`
    CreatedByUserName    string   `json:"createdBy_username"`
    CreatedByDisplayName string   `json:"createdBy_displayName"`
    View                 string   `json:"view"`
    URL                  string   `json:url`
    Labels               []string `json:"labels"`
}

func NewArticle(a ArticleConfl, endpoint string) Article {
    art := Article{
        ID:                   a.ID,
        Type:                 a.Type,
        Title:                a.Title,
        SpaceName:            a.Space.Name,
        CreatedByUserName:    a.History.CreatedBy.UserName,
        CreatedByDisplayName: a.History.CreatedBy.DisplayName,
        View:                 a.Body.View.Value,
        URL:                  endpoint + a.Links.WebUI,
        Labels:               []string{},
    }

        // 特定の単語が含まれるものに対してラベリング
    for k, terms := range common.LABEL_TERMS {
        for _, t := range terms {
            if strings.Contains(art.Title, t) {
                art.Labels = append(art.Labels, k)
                break
            }
        }
    }

    return art
}

Conflではラベルという機能があるのですが、このラベルは全社的な約束があまりなく、著者の部署によってバラバラになってしまうので、MTGや議事録など特定の目的の単語が含まれる記事に対して、labelsというフィールドを追加する処理を施しました。

今回決めたラベリングの単語は以下のようになります。

var (
    LABEL_TERMS = map[string][]string{
        "調査":   {"調査", "サーベイ", "レポート", "まとめ", "検証"},
        "試験":   {"試験", "テスト", "検証"},
        "MTG":  {"議事録", "会議", "MTG", "ミーティング", "話し合い"},
        "運用":   {"運用方法", "オペレーション", "使用方法", "進め方", "手順"},
        "Tips": {"Tips", "tips", "幸せになれる", "ノウハウ"},
        "標準":   {"標準", "スタンダード", "規則", "規約", "基準"},
        "仕様":   {"仕様", "エラーコード", "設計"},
        "ホーム":  {"ホーム"},
    }
)

次にConflへリクエストを組み立てレスポンスを受け取ります。

func FetchArticlesStr(endpoint, expand, username, password string, from, limit int) (string, error) {

    // parse endpoint
    u, err := url.Parse(endpoint)
    if err != nil {
        return "", errors.Wrap(err, "Invalid endpoint")
    }
    u.Path = path.Join(u.Path, CONTENT_API_PATH)

    // build URL
    q := u.Query()
    q.Set("type", common.CONFL_CONTENT_TYPE)
    q.Set("start", strconv.Itoa(from))
    q.Set("limit", strconv.Itoa(limit))
    q.Set("expand", expand)
    u.RawQuery = q.Encode()

    // build request
    req, err := http.NewRequest("GET", u.String(), nil)
    if err != nil {
        return "", errors.Wrap(err, "Failed to make request")
    }
    req.Header.Add("Accept", CONTENT_TYPE_JSON)
    req.Header.Add("Authorization", toBasicAuthHeader(username, password))

    // fetch articles (string)
    client := new(http.Client)
    res, err := client.Do(req)
    defer res.Body.Close()
    if err != nil {
        return "", errors.Wrap(err, "failed to fetch data")
    } else if res.StatusCode != http.StatusOK {
        return "", errors.New(fmt.Sprintf("HTTP(%d)", res.StatusCode))
    }
    bytes, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return "", errors.Wrap(err, "failed to read response data")
    }

    return string(bytes), nil
}

ここでベーシック認証は"ユーザ名:パスワード"という形式の文字列をBase64エンコーディングしているため、以下のような形で実装できます。

RFC 7617 - The 'Basic' HTTP Authentication Scheme

func toBasicAuthHeader(username, password string) string {
    encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
    return fmt.Sprintf("Basic %s", encoded)
}

あとはJSON形式にエンコーディングして、Solrに投げれば終了です。

func fetchArticlesConfl(endpoint, expand, username, password string, from, limit int) (model.ArticleConflList, error) {

    // fetch article string from confl
    artiListStr, err := util.FetchArticlesStr(endpoint, expand, username, password, from, limit)
    if err != nil {
        return model.ArticleConflList{}, errors.Wrap(err, "Failed to featch Article string")
    }

    // convert json ArticleConflList
    var artiList model.ArticleConflList
    if err := json.Unmarshal([]byte(artiListStr), &artiList); err != nil {
        return model.ArticleConflList{}, errors.Wrap(err, "Failed to parse JSON string")
    }

    return artiList, nil
}

func indexingArticle(endpoint, core string, commit bool, articles []model.Article) error {

    bytes, err := json.Marshal(articles)
    if err != nil {
        return errors.Wrap(err, "Failed to convert to json")
    }

    return util.IndexingSolr(endpoint, core, commit, string(bytes))
}

Solrへのリクエストはこちら

(投稿するのにGETリクエストなんですねー、不思議)

func IndexingSolr(endpoint, core string, commit bool, documentsStr string) error {

    // parse endpoint/path
    u, err := url.Parse(fmt.Sprintf(PATH_TEMPLATE, endpoint, core))
    if err != nil {
        return errors.Wrap(err, "Invalid URL")
    }

    // build URL
    q := u.Query()
    q.Set("commit", strconv.FormatBool(commit))
    u.RawQuery = q.Encode()

    // build request
    req, err := http.NewRequest("GET", u.String(), strings.NewReader(documentsStr))
    if err != nil {
        return errors.Wrap(err, "Failed to make request")
    }
    req.Header.Add("Content-type", CONTENT_TYPE_JSON)
    req.Header.Add("charset", CHAR_SET)

    log.Println(req.URL.String())
    log.Println(req.Header)
    log.Println(req.Body)

    // fetch articles (string)
    client := new(http.Client)
    res, err := client.Do(req)
    defer res.Body.Close()
    if err != nil {
        return errors.Wrap(err, "failed to fetch data")
    } else if res.StatusCode != http.StatusOK {
        return errors.New(fmt.Sprintf("HTTP(%d)", res.StatusCode))
    }

    return nil
}

とまあこんな感じでインデクシングCLIを作りました。

何千、何万件も記事があると手動インデクシングなんてできないですからね... (^_^;)

次回、Solrの環境構築編へ続く...