plenvsetup v0.06 has been released

Let’s setup your own perl environment without system-perl

Today I released plenvsetup v0.06. plenvsetup is a quick setup tool for perl environment with plenv.

In the latest version, plenvsetup uses Shoichi Kaji(skaji)‘s perl-install instead of Perl-Build. This allows, we can install perl without system-perl under plenv.

Now we can use new plenvsetup with following one-liner.

1
$ curl -sL https://is.gd/plenvsetup | bash  

You may zsh instead of bash.

Build on a Docker container

plenvsetup works on a Docker container it based on latest alpine linux image too.

For example, following Dockerfile setups the perl-5.30.0 with plenvsetup on container.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM alpine  
WORKDIR /root

### install required dependencies (perl not required!)
RUN apk add bash libc-dev gcc make patch git curl

### download plenvsetup
RUN curl -sL https://is.gd/plenvsetup > plenvsetup && chmod +x plenvsetup

### use plenvsetup on bash
ENV SHELL=/bin/bash
RUN bash plenvsetup

### install perl-5.30.0 with plenv
RUN .plenv/bin/plenv install 5.30.0
RUN .plenv/bin/plenv global 5.30.0
RUN .plenv/bin/plenv rehash

CMD /bin/bash

Then, you can build the Dockerfile, and run perl-5.30.0 within the container.

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
$ docker build -t ytnobody/plenvsetup .  
Sending build context to Docker daemon 2.048kB
Step 1/10 : FROM alpine
---> cc0abc535e36
Step 2/10 : WORKDIR /root
---> Using cache
---> 5a63e9728352
Step 3/10 : RUN apk add bash libc-dev gcc make patch git curl
---> Using cache
---> 0fed43f11239
Step 4/10 : RUN curl -sL https://is.gd/plenvsetup > plenvsetup && chmod +x plenvsetup
---> Using cache
---> 2b8ce1c5f1fa
Step 5/10 : ENV SHELL=/bin/bash
---> Using cache
---> 6f661e8cf191
Step 6/10 : RUN bash plenvsetup
---> Using cache
---> 24f2c6c713f0
Step 7/10 : RUN .plenv/bin/plenv install 5.30.0
---> Running in 86f08ede1c38
---> Downloading https://cpan.metacpan.org/authors/id/X/XS/XSAWYERX/perl-5.30.0.tar.gz
---> Unpacking /root/.plenv/cache/perl-5.30.0.tar.gz
---> Applying Devel::PatchPerl 1.80 (patchperl-extracted 0.0.1)
---> Building perl 5.30.0
---> See /root/.plenv/build/1578556140.1/build.log for progress
---> ./Configure -des -Dprefix=/root/.plenv/versions/5.30.0 -Dscriptdir=/root/.plenv/versions/5.30.0/bin
---> make
---> make install
---> Successfully installed perl 5.30.0
Removing intermediate container 86f08ede1c38
---> 73c2b52fa025
Step 8/10 : RUN .plenv/bin/plenv global 5.30.0
---> Running in 439f41b196dd
Removing intermediate container 439f41b196dd
---> 8e819e1afd8c
Step 9/10 : RUN .plenv/bin/plenv rehash
---> Running in 31d3278595ce
Removing intermediate container 31d3278595ce
---> 28e28a126931
Step 10/10 : CMD /bin/bash
---> Running in c67a1e299db0
Removing intermediate container c67a1e299db0
---> 3ceac81cf2f4
Successfully built 3ceac81cf2f4
Successfully tagged ytnobody/plenvsetup:latest

$ docker run --rm -it ytnobody/plenvsetup
bash-5.0# perl -v
This is perl 5, version 30, subversion 0 (v5.30.0) built for x86\_64-linux
(with 1 registered patch, see perl -V for more detail)
Copyright 1987-2019, Larry Wall
Perl may be copied only under the terms of either the Artistic License or the
GNU General Public License, which may be found in the Perl 5 source kit.
Complete documentation for Perl, including FAQ lists, should be found on
this system using "man perl" or "perldoc perl". If you have access to the
Internet, point your browser at http://www.perl.org/, the Perl Home Page.

So easy.

Kichijoji.pm #20 に参加しました

2019-11-22、六本木はフリークアウト社にてKichijoji.pm #20が開催されました。大変楽しませていただきました。7年目に突入ということで非常にめでたい節目でもあったようです。

なお私は途中からの参加となったのですが、懇親会LTにて「昔作ったソシャゲーの裏側」というタイトルで発表させていただきました。資料はこちら(PDF)

当日の様子についてはHaskell入門の著者であり熟練YAPCレポーターでもある@hiratara氏がまとめてくれています。

個人的に印象に残ったものとして、@818uuuさんの発表を挙げておきます。「人間が検索結果をどのように見ているのか」という研究の引用に「検索結果の見方は6種類に大別できて、それを共通アルゴリズムで説明できるのでは?」という話を聞いて、目からウロコでした。

さて、私の発表に登場した技術たちについて、如何せん古い内容でしたので、よくわからなかったという方もいらっしゃるかと思います。そこで、雑な内容ではありますが、このエントリで少し補足をしておきます。

Catalyst

Catalystは、2005年後半~2008年ころにかけて流行したPerlのWeb App Frameworkです。

現在でもメンテナンスが継続されており、ルーティングに独自のルールがあることが特徴の一つと言えるでしょう。

gearman

gearmanはPerl製のジョブシステムです。job clientからgearmandと呼ばれるデーモンにキューを渡すと、perlのwhileループを軸に組まれたworkerプロセスに対して処理を委譲することができます。

動作が軽量なのですが、workerプロセスがダウンした際、処理途中の内容が全部揮発してしまうのが難点でした。

TheSchwartz

TheSchwartzもPerl製のジョブシステムですが、その構成にMySQLが必要となり、ジョブそのものの堅牢性をMySQLに委ねることで、gearmanのような途中処理の揮発という問題が起こりづらいとされていました。

現在は@akiym氏によってメンテナンスされているようです。

UltraMonkey L7

UltraMonkey L7は、Linuxなどにインストールして利用するタイプの、オープンソースのソフトウェアロードバランサーです。

名前に含まれている通り、OSI参照レイヤのレイヤ7プロトコル(HTTPなど)に対するルールを設定できる、大変貴重なソフトウェアロードバランサとなります。

heartbeat

heartbeatは、Linux/HAプロジェクトの成果物の一つで、IPアドレスの冗長化構成を行うソフトウェアです。

これのおかげでロードバランサーやルータを多重化することができました。

iptables

Linuxカーネルに組み込まれたパケットフィルターです。よくある用途としてはファイアウォールとしての利用でしょうけど、最近ですとdockerが内部的にexposeのために利用しているようです。

フリーランスになりました

副業から本業に

もともと副業といいますか個人事業を一昨年からやっていて、これまでもいくつかチームマネジメントや技術相談などをご用命いただいていましたが、本日より完全にフリーランスとなりました。

ともあれ、これまで私を正社員として雇ってくださった会社とは、引き続き業務委託という形で(稼働日数を減らして)一緒にお仕事を続けることになっています。

動機

ちょっとした野望を内に秘めているのですが(いまはまだ明かせません・・・)、これを実現するためには、若干自分の時間が足りないな、と半年ほど前から実感していました。しかし、副業をしていたこともあり、稼ぎについて大きな不安なしにこの決断をすることができました。

この結果を得るまでにはそこそこの紆余曲折を経ましたが、とにかく、時間の使い方をもうすこし自分で選びたい、という小さな目標はこれで達成できるのかなと思っています。

どんなお仕事をやっていくのか

冒頭でリンクしたスイ・ソリューションズ(屋号)では、主に以下のようなお仕事を承っております。

  • 各種システムの現状調査
  • webシステム及びクラウドシステムに関するアドバイス・提案

ちなみに今年でITエンジニアとしてのキャリアは20年目に突入するのですが、私は下記の事項に実績があります。

  • Microsoft Azureにおける各種SaaS/PaaSを使ったシステム設計・構築・運用(特にAzure Functions)
  • 20年にわたるLinuxに関する知見
  • Perl, PHP, bash, JavaScript(Node.js), Swiftなど、10種以上の言語による開発実績(とくにAPI)
  • TDDの導入
  • MySQLにおけるクエリチューニング(あくまで開発者として。)
  • エンジニアチームの立ち上げ、マネジメント、メンタリング
  • プロジェクトのマネジメント
  • 事業におけるシステムのポジション定義と予算策定

もし興味がありましたら、メールやTwitterでご連絡ください。無論、対面でも構いません。

まとめ

装甲騎兵ボトムズ的にまとめるなら、以下のようになるところでしょうか。

変わる、変わる、変わる。この世の舞台をまわす巨獣が、クラウドの底でまた動きはじめた。 天が軋み、人々は呻く。舞台が回れば吹く風も変わる。 昨日も、今日も、明日も、時の流れに閉ざされて見えない。 だからこそ、時を操る術を求めて、褪せぬ技術を信じて求めて。 次回「急変」。 変わらぬ技術などあるのか。

WEB+DB PRESS Vol.109 に寄稿させていただきました

WEB+DB PRESS Vol.109

紹介

WEB+DB PRESSVol.109(技術評論社:gihyoより2月23日発売予定)のPerl Hackers HubコーナーにサーバレスでもPerlというテーマで寄稿させていただきました。

ぜひお買い求めください!

Perl Hackers Hub - Perl Hacker達による旬の技術の紹介

Perl Hackers HubとはWEB+DB PRESS誌内の連載のひとつであり、Perl Hacker自身が筆を執り旬の技術を紹介するというものです。

gihyoさんのPerl Hackers Hubのページには、Perl Hackers Hubについて以下のような説明がついています。

本連載は,第一線のPerlハッカーが回替わりで,Perlの旬な技術について解説していきます。

記事概要

Microsoft Azure Functionsを使ってPerlスクリプトを実行します。スクリプトの内容は、「画像URLを与えると、画像の顔認識結果(年齢と性別)をJSON形式で返すWeb API」です。

サンプルコードとして、Azure上に環境とプログラムを全自動でデプロイしてくれるスクリプトを収録してあります。Azureアカウントがあれば、最初の無料枠で試せる範囲の内容となりますので、ぜひともお試しください。

謝辞

この度機会を与えてくださった@songmuさん、Perl Hackers Hubコアメンバーの皆さん、そして一緒に原稿の仕上げをしていただいた@inaoさん、本当にありがとうございました。

特に@inaoさんには度々ご迷惑を掛けてしまったにもかかわらず、辛抱強く対応してくださいました。感謝しきりです。

また、Azureについて深く言及した内容の記事はWEB+DB PRESS誌上では初めてだそうです(本当ですか?)。私も驚きました。正直な話、流石にAzureについては過去に誰か書いているだろうと思っていたのですが…意外なところで「誌上初」だったんですね。

ともかく私としては、今回の記事がPerl Hackers Hubというコーナーを通じて、様々な技術に触れるきっかけを読者の皆様に作ることができたらいいな、と思っています。

YAPC::tokyo 2019で登壇しました

YAPC::Japan 2019にて「実演サーバレスPerl - 顔認識データを扱おう」というタイトルで登壇させていただきました。見に来てくれた方、本当にありがとうございます。

そして、結局ライブコーディングするための時間が足りず、見せ場を作れなかった点、本当にごめんなさい。あの後、見に来てくれていた同僚や@note103さんに励まされたのが本当にありがたかったです。

資料

敗因

20分で、受け手側に前知識が必要な内容をライブコーディングするのは、あまりにも無謀でした。

前知識のインストールが終わった時点で7分ほど経過しておりました。さらにクラウド側(Azure)の処理待ち時間が思った以上にかかる。

これではまずいということで、事前に用意しておいた出来上がり環境を使って説明していきましたが、それでも時間が足りず、コードを書くところをお見せできませんでした(コードそのものをお見せすることはできたのですが・・・)。

補足(というか宣伝)

今回のセッションをおさらいする内容で、Web+DB Press誌 2019年2月号のPerl Hackers Hubに寄稿させていただいております。まだ発売まで時間がありますが、より詳しく解説されているものとなりますので、興味のある方はぜひお買い求めください!

また、Web+DB Press誌ではサンプルコードも提供します。サンプルコードは、今回の顔認識システムを自動でAzure上に構築するスクリプトとなっておりますので、構築する手間が惜しいが試すだけ試してみたい、という方にもおすすめです。

前夜祭でもLTしました

本編のみ参加の方はご存じないかもしれませんが、前夜祭でもLTで発表しました。Webappにおける@INC Hooks絶対許せないマンとしてネタに振り切った内容です。

LT資料

Acme::AtIncPoliceのその後

実は昨日Acme::AtIncPoliceissueが立ちまして。なんだろうと思ってみてみると、「Testで使ってるTest::Exceptionが入ってないからTest失敗するんですが」という内容でした。

それを受け、今朝慌ててTest::Exceptionをdependencyとして登録し、shipit。なんと0.01をshipitしてから、たった2日で0.02をshipitすることとなってしまいました・・・

感想など

@songmuさんベストトークおめでとうございます!感極まって感情が溢れ出たときに、周りで何名かもらい泣きした人がいたんですが、songmuさんの情熱があるからこそなんだろうなと。

個人的には@risouさんのPerl6トークと@hitode909さんのWebVRトークがかなり良い話だと思いました。hitode909さんからは豆本の夢日記を頒布していただきました。家宝にします。

Azure Functions v1 + Node.jsなアプリケーションをAzure Functions v2に移行してみる話

このエントリはServerless2 Advent Calendar 2018の17日目のエントリとなります。

半月ほど前業務で、稼働中のAzure Functions v1 + Node.jsなアプリケーションをAzure Functions v2に移行する作業を行いました。

その際、今後も似たような移行作業があり得そうだと考え、この作業をざっくりこなしてくれるスクリプトを作りました。それがytnobody/azure-func-migrate-nodeです。ちなみにPerl製ではなくNode.js製です。

如何せんやっつけで作ったスクリプトですので、漏れ・抜けなどはあるかもしれませんが、お手すきの際にでもpull-reqしてくださると幸甚です。

移行のために最低限必要な処理

大きく分けて、以下の作業が必要となります

  • bindingsに対応したextensionsをインストールする
  • ランタイムバージョンを2.0に設定する

bindingsに対応したextensionsをインストールする

実際のextensionsインストール作業はfunc extensions installに委譲しているのですが、これまでv1で使っていたbindingsに相対するv2 extensionsを検出するロジックは作成しました。

なお、v1では"type": "documntDB"と指定していた箇所は"type": "cosmosDB"に変更する必要がありますので、その対応も行っています。

ランタイムバージョンを2.0に設定する

これもロジックを作りました。

まとめ

ざっくりと移行スクリプトを作りましたので、公開しました。が、だいぶやっつけで作ってありますので、適宜pull-reqをしてくださると幸甚です。

昔書いたGithub止まりのモジュールをほじくり返す - Test::Proc 篇

※このエントリはPerlアドベントカレンダー2018の5日目のエントリとなります。

CPAN(MetaCPAN)でモジュールを公開するということ

Perlでプログラムを書くことに慣れてくると、CPANモジュールを使うようになったり、もっと慣れてくると自分でPerlモジュールを作るようになり、最終的にはCPAN(今はMetaCPANですね)で公開するようになるかもしれません。私は過去にそのような道を歩んできました。

では私は過去にどんなモジュールを書いたのか。ひとまずMetaCPANに上がっているものを一部挙げると、 Otogiri(これはほとんど@tsucchiさんの力です), XML::Diver, Net::Azure::CognitiveService::Faceなどがあります。

PerlモジュールをMetaCPANで公開する前に

しかしながら、MetaCPANでモジュールを公開する前に気にかけておいて欲しいこととして、個人的には以下のような項目を挙げたいと思います。

  • そのモジュールを公開することで不快感を感じる人々が生じないか
  • そのモジュールはセキュリティ的に致命的な問題を抱えていないか
  • そのモジュールは新しい機能を提供してくれるか
  • そのモジュールはプログラミング体験をより幸せにしてくれるか
  • そのモジュールは従来の類似モジュールよりもパフォーマンスに優れるか
  • そのモジュールは従来の類似モジュールよりも依存性が削減されているか

もし上記について該当するかわからない場合は、ひとまずgithubに上げておきつつ、ブログやTwitter等で言及してみましょう。運が良ければ誰かが注目してくれて、レビューをしてくれるかもしれません。これらのことは、Perlモジュールの公開に及び腰になってしまわない程度に、気をつけるとよいのではないでしょうか。

CPANの歴史を少しだけ垣間見る

では、なぜこのように自律する必要があるのでしょうか。それを知るためには過去に目を向けることが重要です。

歴史を振り返ると、charsbarさんによるエントリでCPANは幼稚園児の砂場じゃないよねという、2008年当時、大変考えさせられたものがあります。若い方の中にはこのような歴史を知らない方もいると思いますが、是非とも同じ轍を踏まぬための知識として、知っておいていただければ幸いです。

また、まかまかさんによるPerl同人誌「Acme大全」には、「Acme大全2012」から毎回、巻末付録として「とあるAcmeモジュールの削除について」という章が設けられています(ブログにも同様のエントリがある)。

このように、MetaCPANへモジュールを公開する上では、ある程度その性質を律せられる側面があることは間違いないでしょうし、誰かがつらい思いをしないためにも必要なことであると私は思います。

それでも日の目を見ないモジュールもある

そんな事もあって、実際にはたくさんのPerlモジュールを作っていたとしても、実際にMetaCPANにて公開されるものはその一部でしかありません。ほかにもMetaCPANに公開するのが怖いとか、作者がそこまでの有用性を感じていないだとか、謙遜している、などの理由でMetaCPANで公開されていないPerlモジュールがGithubにはあります。

このようにあえてGithubでの公開にとどめられているPerlモジュールを、私は親しみを込めてGithub止まりモジュールと呼んでいます。

そして今回紹介するGithub止まりモジュールは、拙作のTest::Procです。

Test::Proc - プロセスを起動し、その起動状態をテストする

Test::Procは5年前にGithubにpushされています。今となっては当時の記憶を頼りに解説する他ないという点がいささか不安です。

さて、Test::Procが目指したところは、テストスクリプトでプロセスの起動状態をテストしたいというものでした。

使い方はSYNOPSISにある通りです。(done_testingが抜けていたので、補足してあります。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use Test::More;
use Test::Proc;


my $proc = test_proc 'my_task' => sub {
print 'test';
warn 'dummy';
sleep 20;
};


$proc->is_work;


$proc->stdout_like( qr/\Atest/ );
$proc->stderr_like( qr/\Adummy/ );


$proc->exit_ok;
done_testing;

あら捜し

レポジトリを見てみると、テストの少なさが気になります。github止まりモジュールにはよくある話かもしれないですが、だいぶ控えめなテストの数だなと感じますね。

そしてインターフェースが片手落ちです。というのも、このTest::Procはtest_procというDSLを提供しており、Test::Proc::Objectインスタンスを返しているのですが、test_proc側にはクロージャ以外でプログラムを起動する方法が提供されていません。Test::Proc::ObjectはProc::Simpleのサブクラスであり、24行目で引数の$codeを実行しているので、Proc::Simpleのインターフェースを見る限り、文字列で外部コマンドとオプションを渡すことができるはずです。

実験

実際に以下のようなテストを作って実験してみました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ cat hoge.sh 
sleep 3
echo "hoge"

$ cat t/12_shell.t
use strict;
use Test::More;
use Test::Proc::Object;

my $proc = Test::Proc::Object->new('sleep 3 sec. Then print "done"', 'bash ./hoge.sh');

$proc->is_work;
$proc->exit_ok;
$proc->isnt_work;

$proc->stdout_like(qr/\Adone\z/);
$proc->stderr_like(qr/\A\z/);

done_testing;

これを実行すると、以下のようになり、テストは成功します。

1
2
3
4
5
6
7
$ perl -Ilib t/12_shell.t 
ok 1 - process sleep 3 sec. Then print "done" is work
ok 2 - process sleep 3 sec. Then print "done" exit code is 0
ok 3 - process sleep 3 sec. Then print "done" is not work
ok 4
ok 5 - process sleep 3 sec. Then print "done" STDERR like (?^:\A\z)
1..5

深掘りしていく

上記のことから、test_procにコマンドを文字列渡しして起動させることができそうですが、実際に以下のようなテストを試してみると、失敗してしまいます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ cat t/13_shell-with-test_proc.t 
use strict;
use Test::More;
use Test::Proc;

my $proc = test_proc 'my_work' => 'bash ./hoge.sh';

$proc->is_work;
$proc->exit_ok;
$proc->isnt_work;

$proc->stdout_like(qr/\Adone\z/);
$proc->stderr_like(qr/\A\z/);

done_testing;

$ perl -Ilib t/13_shell-with-test_proc.t
Type of arg 2 to Test::Proc::test_proc must be sub {} (not constant item) at t/13_shell-with-test_proc.t line 5, near "'bash ./hoge.sh';"
Execution of t/13_shell-with-test_proc.t aborted due to compilation errors.

これはtest_procのインターフェースが($&)となっているために起こっている問題となります。ですのでこれを($$)としてあげると、以下のように成功します。

1
2
3
4
5
6
7
$ perl -Ilib t/13_shell-with-test_proc.t 
ok 1 - process my_work is work
ok 2 - process my_work exit code is 0
ok 3 - process my_work is not work
ok 4
ok 5 - process my_work STDERR like (?^:\A\z)
1..5

なぜgithub止まりなのか

このように、完成度の低さゆえにgithub止まりとなっているTest::Procですが、その他に大きな理由として、私個人が出くわす状況にそもそもテストスクリプトでプロセスの起動状態をテストしたいケースがあまりにも少ないという点が挙げられます。

そういうわけで、結局あまり洗練もされないまま5年もgithub止まりとして塩漬けになっていたというわけです。

Test::Procの今後

一応、ユースケース的に「いいじゃん?」って思ってくれる人がいらっしゃるのであれば、forkしてshipitするまでやってくだされば良いとおもいますが、今の所私自身がなにかをするということは無いでしょう。

コンセプトモデルということでここはひとつ、ご理解いただければ幸いです。

まとめ

ざっくりまとめると以下のようになります。

  • MetaCPANでPerlモジュールを公開する前に、いま一度そのモジュールの性質を見つめ直そう
  • Perlコミュニティの歴史から、誰かがつらい思いをしないための行いについて知ろう
  • Test::ProcというPerlモジュールを作ったけど、ユースケースに遭遇しなさすぎて結局Github止まりになった
  • Github止まりモジュールは「コンセプトモデル」として見ると良さそうかも

明日のPerlアドベントカレンダーは6日目。@yoku0825さんによる「マイエスキューエルにはPerl Mongerが必要かもしれないはなし」です。

Twitterアカウントを凍結されてしまいました。

様子です

しばらくはこちらのサブアカウント にて細々とつぶやいております。

ちなみに本日は私の誕生日でした。最高のプレゼントをありがとう、Twitter!

状況

Azure関連でちょっとした疑問があったので、 @AzureSupport に質問をしたところ、

と言われました。

言われたとおりに質問内容と、サポートに必要なID情報をDirect Messeageにて伝えたところ、suspendedとなってしまいました。

見解

今回suspendedとなってしまった原因は “Violating our rules against evading permanent suspension.” だそうです。

原因については「なるほど?」という感じですが、そもそも今回の経緯から、Twitter社はDirect Messageを覗き見しているという事がわかりました(利用規約にはその旨が書かれている)。

おそらくコンテキスト等を無視して、固有情報らしきものが流れてきたら簡単なチェックを経て、必要に応じてsuspendしているのではないでしょうか。

今後

不服申立てはすぐに行いました。が、仮にアカウントが復活しても、あまりアクティブな利用を行わず、そのままmastodonあたりに流れるかもしれません。

翌日

アカウントの解凍が行われました。Twitter社の担当部門の皆様、大変お手数をおかけしました。

Twitter社においては、認証済みアカウントとのやり取りにおける秘匿情報の取扱いについて、検閲・凍結のルールを見直していただくことを願う次第です。

200ミリ秒の検索APIをMicrosoft Azureでデザインする

200ミリ秒の検索APIとは

ここでは、httpクライアントからリクエストを受けてから全文検索を含む何らかの検索処理を行い、httpクライアントにレスポンスを返すまでの処理を、おおむね200ミリ秒(200ms、0.2秒)前後の時間でこなすWeb APIを指します。

検索という機能を考えたとき、200msという数字はまずまず軽快な応答速度ではないかと私は考えます。

今回はAzureの各種サービスを使って検索APIを作った時のノウハウを共有します。このAPIはデータサイズによって多少のばらつきはあるものの、ほぼ200ms前後の応答速度を実現することができております。

以下は、実際の処理履歴となります。おおむね200ms前後で処理が完了し、応答していることが分かると思います。

ログ

Microsoft Azureで作る

Microsoft Azure(以下Azure)で検索APIを作るにあたり、以下のようなシステムデザインを行いました。

システムデザイン

ある程度Azureに詳しい方であれば、上記の図を見ただけで概ねの構成がご理解いただけると思います。

Azure CDN

いわゆるCDN(Contents Delivery Network)サービスです。今年から動的コンテンツの高速化にも利用できるようになりました。

今回のケースでも「動的コンテンツの高速化」がその利用目的となります。

Azure Functions

実際にhttpリクエストを受け付け、httpレスポンスを返すためのアプリケーション・ロジックをデプロイし、運用するためのFaaSとなります。最近v2がリリースされましたが、私のケースではv1を利用しました。

C#やF#、PHPなどの言語に対応していますが、今回はNode.jsをつかって作ってみました。実際の実装・はまりどころについては前回のエントリにまとめてありますので、そちらも併せてご参照ください。

Apache LuceneクエリやODataフィルタを利用可能な検索エンジンサービスです。

全文検索や緯度経度をもとにした距離検索、ファセットなどにも対応しているため、複雑な検索機能を実装するにはほぼ必須となります。

また、Azure CosmosDBやAzure Table Storageなどから定期的(最短で5分おき)にデータを取り込むIndexerという仕組みがついてきますので、検索結果にリアルタイム性を求めない限り、データのインポートをする仕組みを自分で作る必要がありません。

Azure CosmosDB (または Azure Table Storage)

どちらもスキーマレスなデータストアとして利用できるサービスです。

今回はAzure CosmosDBを利用し、Azure Search向けの一次データをストックしておくデータストアとします。

まとめ

200ms前後の応答速度の検索APIをAzureで実現するシステムデザインを紹介しました。

「えっ、実際の作り方は書かないの?」という声が聞こえてきそうですが、その辺は公式ドキュメントや有志のブログに書いてあることしかやっていません。

強いてあげるとすれば、前回のエントリで書いたようなはまりどころがあるくらいですので、そちらを見ていただきたいと思います。

参考にしたドキュメント・ブログ

[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によって、定期的にデータ参照されます。

データ構造

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
"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)

普通に実装

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// function.json
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req"
},
{
"type": "http",
"direction": "out",
"name": "res"
}
],
"disabled": false
}
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 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の場合、応答パラメータ名のところにある「関数の戻り値を使用する」をチェックする)

なおしてみる

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// function.json
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req"
},
{
"type": "http",
"direction": "out",
"name": "$return"
}
],
"disabled": false
}
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 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を使った書き方では、検索結果のヒット件数を取得することができないという問題がありました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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を修正することで、取得できるようになります。(ただしインターフェイスを破壊する変更です)

1
2
3
4
5
6
7
8
9
@@ -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)
})

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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して利用しています。