AWS SQSを使ったJobQueueシステムを組んでみた
サンプル実装
モチベーション
SQSを使ったジョブキューシステムをつくってみたかった
(実装コストとかの肌感を知りたい)
実装したもの
処理の流れは以下の通り。
- Publisherがランダムな整数を生成
- SQSにジョブとしてメッセージを投げる
- Subscriberがジョブを受け取り、1で生成した整数を素因数分解
- SubscriberがDynamoDBに結果を登録
(大した計算時間になっていなくて、ジョブキューシステムの適切な例なのか?と問われるとモゴモゴ...)
簡単に動作、理解できるようにAWS環境はlocalstackで再現し、プログラムはPythonで書いた。
得られた知見
複数のSubscriberがいる場合や、バグや異常なメッセージの取り扱い
- 複数のSubscriberが二重に取得しないようにVisibilityTimeoutを設定
- エラーを起こしたメッセージはRedrivePolicyでDead Letter Queueを指定するだけ。
このあたりの複雑な機構が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
ディレクトリがあると、そこのバイナリファイルを優先的に使うらしい
更なる問題
ここで一つ問題が...
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を使うことによって構築が超絶楽になるらしいです。
仕組みとしては下の図の通りで
- VaultをCA局として設置し、その証明書を各サーバのsshdに登録
- 新しいユーザは秘密鍵と公開鍵を生成し、CA局から公開鍵への署名をもらう (これで全てのサーバへSSHが可能)
- ログイン時に公開鍵と署名を使ってサーバにSSH
- サーバは署名を検証し、検証に成功すればログインしてSSHでの操作を許可
という感じになり、新規ユーザ登録は一回数分のオペレーションで済みます。
ということで試しに docker-compose
で VaultのCA局を立ててSSHハンズオン的なものを作ってみました。
Vaultを使うことで
- 簡単にCA局がたつ
- vault-cliを使って鍵の署名オペレーションが簡単にできる
- 署名オペレーションを行う際にもポリシーを使って権限管理が可能
- HA構成が可能
といったメリットがあるかなと思っています。
逆にこの方法だと、Vault側でオンデマンドな署名の無効化ができないといった問題もあるので、そういう時はVault Login Helperなるものがあるので、そちらを使っていきましょう。
IUSCON10予選に参加してきました
お久しぶりです、へたれです。
今年も開催されました、ISUCON予選!!
ということで自分も「チーム豚キムチ2307」として社会人になって初めて参加してきたので、どんなことをしてきたのかを簡単に記事にしようと思います!
(記事を書くためにスクショ等を残していなかったので、記憶があやふやになってしまうのですが...)
ちなみに予選レギュレーションはこんな感じです。
インフラ構成
実際にやったこと
チーム内では自分がアプリケーション方面をメインに、相方のはひふへほ氏がインフラ/ミドルウェア方面をメインに作業を進めていきました。
(ちなみに時間配分については特に記録に残していないので、アヤフヤなキヲクを辿っていく...)
相方がやったこと
(今回まともに自分が動けたのも去年から相方がこの辺の準備をメチャクチャ頑張ってくれたからです...感謝感謝です :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問題はないか
- SQLの
WHERE
やORDER BY
でどんなフィールドが指定されているか 0_Schema.sql
を読んで、どんなインデックスが貼られているか
をチェックしつつ、気になるところをリストアップしていきました。
初回ベンチ回す
問題が解消され、初回ベンチが回せるようになったのでとりあえず回す。
Score: 484
スコアアップに向けた作業
searchEstateNazotteのN+1改善
ここでpprofやkataribeの結果から searchEstateNazotte
関数が一番重いということが発覚しました。
そこでコードを読むとN+1問題が発生していることができ、ここの改善に着手することに。
元は「画面内の物件を一通りSQLで取得して、一件ごとに手書き図形の中に入っているかをSQLで確認する」という方式でした。
そこで処理を「画面内の物件を一通りSQLで取得して、IN句を使って手書き図形の中に入っているかでフィルタをかけた物件ををSQLで取得する」という方式に変更する形で実装。
しかし互換性のチェックでこけ続ける形に...
(ISUCONのコード変更で一番辛いのは互換性のチェックだと思います)
ここでisuconのアプリケーションはsystemdで動作しているのですが、なぜかログが表示されず...そこを直すことに決めました。
ログの調査に難航していた頃、相方が「pprof見たらJSONのエンコーディングがめっちゃ時間かかっとるよ」との発見を共有してくれました。
正直作業が難航していて心がやつれてきたので、一旦今の作業は中断してJSONエンコーディングの高速化へ。
echoのコードを見ると、JSONのエンコーディングには標準パッケージの encoding/json
を使用していました。
そのため、JSONのエンコーディングを jonitor に変更することで、高速化を図ることに。
具体的には
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
ここで次に何に取り組もうかを考えていたところ、相方が「Nginx + AppサーバってほとんどCPUもメモリも使いきれていないけど、DBサーバのロードアベレージがずっと高いところで張り付いている」と気付きをシェアしてくれました。
(本当に相方が優秀すぎて、今回ほとんど相方のいいなりに修正しているだけな気がします...)
「DBを2台にして負荷分散を行おう」という話になったのですが、今回のソースを見ると様々なクエリでWHERE句やORDER BY句が使われていて、searchEstateNazotte
に至っては座標計算まで行われてしまっていました。
そこでRedisへのデータ移行は実質不可能と判断し、MySQLをマスタースレーブ構成にしてレプリケーションするという方針にしました。
作業としては
するといった分担にしました。
自分はmssqlxというslqx互換のライブラリを見つけ「これひょっとして運営の意図した通りじゃね!」と喜んで組み込んでいました。
一方で相方はここでも優秀で、初めての試みでちゃんとMySQLのレプリケーションを組んでくれました!
しかしベンチを回すと、Updateが頻繁に発生し、Readした際に不整合が発生するという問題が発覚しました。
この不整合をどう解決しようかと探っていたところ、タイムアップ。
最後にnewrelic infraやpprofを外し、最後にベンチを回して終了しました。
感想
最後のDBがボトルネックになっている問題が解決できなくて非常に悔しかったです、これさえクリアできれば予選突破できそう!と思っていただけに残念でした。
ですがチーム内で連携して、議論して、少しずつスコアを上げていけたので体験や学びとしては非常によかったです!
あとはちゃんと「何がボトルネックになっているのか」を見抜けるよう監視の読み方を頑張るのと、サービスを開発/運用していく上でのノウハウをいっぱい貯めていかなきゃという感じです。
ISUCON10予選参加者のみなさん、8時間お疲れ様でした!
GithubPagesでポートフォリオサイトをホスティング
これから仕事をやっていく中で、マメに記録していかないと自分の活動とかの整理がつらそうだなと思ったので作りました。
https://oriishitakahiro.github.io/portfolio/
技術的には非常に簡単で、何番煎じかも分からないGithub Pages + Hugoです。
構築環境
- Hugo
- Github Pages
- CircleCI
苦労したところ
せっかくなのでCircleCIでCiを組んだのですが、自分があまりCIに慣れていないせいか attache_workspace
を忘れたり、gh-pages
ブランチへコミットする際の認証で悩んだりしていました。
Githubへの認証はAccess Tokenを環境変数で渡して運用しています。
リポジトリはこちらです。
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.2
をjenv 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に投稿されていました。
とのことです。
自分はJavaやScala周りを詳しく知らないので、なぜJDK13を参照してしまうのか、なぜJDK8/11がsbtに必要なのかは良くわかりませんが、やるべきことは書いてくれているのでやっていきましょう。
解決手順
今回は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を利用しました。
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> ); }
完成したフロントエンド
以上の流れでフロントエンドを開発しました。 以下が完成したものになります。
そのうち開発して感じたことや感想を書きます...
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イメージを利用して環境を構成していきます。
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 でクエリを投げられます。
クエリでは以下のものが指定できます。
- q: クエリを記述
フィールド名:値 AND フィールド名:値
のように様々な演算子やフィールドを組み合わせることが可能- また
フィールド名:値^重み OR フィールド名:値^重み
とすることで、細かい検索結果の調整も可能
- fq: クエリ検索にかけるフィルタ
- 後述のファセット絞り込みなどに使用
- ファセットでの利用はqueryResultCacheへの影響を与えないため単純にANDをとるのではなく、fqを使うのが望ましい
- Solrのキャッシュについて調査したことまとめ - YOMON8.NET
- fl: 検索結果として返すフィールドの値
- sort: ソートに利用するフィールド、何も入力しないとSolrのスコアリング順
- debugQuery: クエリのデバッグが見られる、クエリに利用する値のトークナイズなどが見られて便利
- hl: 検索結果のハイライティングを利用するか否か (ヒットしたワードが強調されて表示されるやつ)
- facet: ファセットを結果に含めるか否か
フロントエンド開発編へ続く...
SolrでConflの記事を検索 - インデクシングCLI編
インデクシングCLI編
SolrでConflの記事を検索 - それでも気分は高専生の個別実装編第一弾です。
ConflではREST APIを通じて、様々な記事をJSON形式で取得することができます。
例えばページを取得したい場合、以下のようにしてページ一覧を取得することができます。
ACCOUNT
とPASSWORD
は自分の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をインデクシングするのは大変難しいです。
そこで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の環境構築編へ続く...