エンジニアとして成長するための副業という選択肢について
TerraformでFargateのSidecarを実現しよう
Drone 1.0 Release Candidateが出ました
この記事は リクルートライフスタイル Advent Calendar 2018 の3日目の記事です。
はじめに
新規サービスの開発を行なっている @akechi です。
現在行なっているサービスの開発では、CI/CDツールにDroneを採用しています。
先月 1.0 Release Candidateがアナウンスされたので、どういったバージョンアップなのか追ってみました。
アップグレード時の注意事項
0.8から1.0への自動アップグレードはできません。
データベースに互換性のない変更があるため、アップグレードにはマイグレーションユーティリティが必要になります。
このユーティリティは11月16日までに用意するよという記述がドキュメントにありますが、まだないようです。
注目機能
YAMLフォーマットの大幅な機能強化
パイプラインのシンタックスは、1.0で大きく変わっています。
新しいシンタックスは、Kubernetesにインスパイアーされたものになっています。
Droneコミュニティの大部分がKubernetesをすでに採用しており、よく知られていることが理由みたいです。(わたしもその1人です)
古いフォーマット
pipeline:
build:
image: golang
commands:
- go build
- go test
services:
redis:
image: redis:latest
新しいフォーマット
kind: pipeline
name: default
steps:
- name: build
image: golang
commands:
- go build
- go test
services:
- name: redis
image: redis:latest
Multi-Machineパイプライン
ビルド時間を減らすために、複数のマシーンにビルドタスクを分散したい時に便利な機能です。
Multi-Machineパイプラインは、複数のYAMLドキュメントで設定します。
下記は2つの並列パイプラインを実行する例です。
ビルドステータスは、両方のパイプラインの結果で決まります。
kind: pipeline
name: frontend
steps:
- name: build
image: node
commands:
- npm install
- npm test
---
kind: pipeline
name: backend
steps:
- name: build
image: golang
commands:
- go build
- go test
services:
- name: redis
image: redis
Multi-Platformパイプライン
複数のOSやアーキテクチャでビルドやテストが必要な時に便利な機能です。
1つのパイプラインはLinux/ARMで実行されて、もう1つのパイプラインはLinux/AMD64で実行される例です。
kind: pipeline
name: amd
platform:
os: linux
arch: amd64
steps:
- name: build
image: golang
commands:
- go build
- go test
---
kind: pipeline
name: arm64
platform:
os: linux
arch: arm64
steps:
- name: build
image: golang
commands:
- go build
- go test
署名付きYAMLファイル
信頼性を検証したり勝手に書き換えられることを防ぐために、YAMLファイルに署名することができます。
これはパブリックリポジトリで承認されていない変更を防ぐために特に便利な機能です。
もしユーザが変更して署名の検証に失敗すると、パイプラインは承認されるまでペンディングになります。
署名はYAMLファイルにsignature
リソースとして記述します。
---
kind: pipeline
name: default
steps:
- name: build
image: golang
commands:
- go build
- go test
---
kind: signature
hmac: F10E2821BBBEA527EA02200352313BC059445190
Jsonnetによる設定
Jsonnetファイルで動的にYAMLファイルを生成する機能です。
例えば、Rubyの複数バージョンでテストを実行したい時に使えます。
---
kind: pipeline
name: ruby-2-4
steps:
- name: test
image: ruby:2.4
commands:
- bundle install --jobs=3 --retry=3
- rake
---
kind: pipeline
name: ruby-2-3
steps:
- name: test
image: ruby:2.3
commands:
- bundle install --jobs=3 --retry=3
- rake
.jsonnetで書くと以下のようになります。
local Pipeline(name, image) = {
kind: "pipeline",
name: name,
steps: [
{
name: "test",
image: image,
commands: [
"bundle install --jobs=3 --retry=3",
"rake"
]
}
]
};
[
Pipeline("ruby23", "ruby:2.3"),
Pipeline("ruby24", "ruby:2.4"),
]
ネイティブサポート
エージェントを複数のOSとアーキテクチャにインストールできます。
- Linux AMD64
- Linux ARM
- Linux ARM64
- Windows 1803
ユーザインターフェースの改装
その他
- cronジョブによるスケジューリング
-
drone fmt
コマンド - Matrix の非推奨
まとめ
Drone 1.0 Release Candidateの機能を見てきました。
バージョンが1.0になったことで、今まで欲しいなと思っていた機能が着実に追加されてきています。
現在は0.8を使っているので、GAになったら本格的にアップグレードしたいなと思っています。
GoogleAppEngineで作る、SQLをGitHubにpushするとBigQueryを定期実行するマン
この記事はリクルートライフスタイル Advent Calendar 2018の4日目の記事です。
こんばんは
CETというチームの @mihirat です。
最近ではいくつかの新規サービス開発で、ちょこちょこフロントやサーバー書かせてもらったり、ちょっとしたSRE的な役回りをしていたりします。
また、現在「Jupyterだけで機械学習が実サービス展開できる基盤」(slideshare・blog)の開発などをやってます。ぜひご一読ください。
今回は、社内で活用しているBQ定期実行アプリのご紹介です。
背景
弊社では分析や可視化にTableauを利用することが多いのですが、激重なクエリが描画のたびに発行されて待ち時間が長い・BQの計算資源を使いまくるという問題がありました。
そこで、「プログラムを書かなくても、よしなにBQに定期実行してテーブルを自動更新してくれるアプリケーションがあればなぁ」という話になったので突貫で作ってみたのですが、だんだん利用が増え、現在では100クエリ以上がこのアプリケーション上に登録されています。
「いやいや、スケジュールクエリ機能ってBQにありますよ?」と思われた方もいると思いますが、次のようなメリットがあります。
- クエリのレビュー・共有
- 強い分析官やデータサイエンティストの方から直接レビューを受けることができます。溜まりにくいSQLの知見が共有でき、チームのSQL力が向上します。また、「この前処理ってどう書いたらいいのかな?」な場合のリファレンスになります。
- 野良クエリの撲滅
- BQのスケジュールクエリは大変便利ですが、野良定期実行クエリとは管理側からすると恐ろしい機能です。このアプリがあれば、簡単に定期実行ができかつ管理下におけるので、懸念が減ります。
作ったもの
処理の流れの概要としては、
- GAEを定期的にkick
- kickされたGAEは、GCSに配置されたSQLや設定ファイルを取得し、キューに積む
- キューのConsumerとなるGAEが別に存在し、積まれたキューに従ってクエリを実行し、もろもろ処理する
というシンプルなものになっています。
ポイント1. TaskQueue
TaskQueueとは、GAE付属のキューの機能です(doc)。適当なyamlを書くだけで、キューの最大待ち長さ、消化速度、リトライ回数などが指定できます。
queue:
- name: bqcultivator
rate: 1/m # 消化速度
bucket_size: 100 # キューをためておける数
retry_parameters:
task_retry_limit: 1
max_concurrent_requests: 5 # 同時実行数
これにより、クエリ発行の速度を絞れるほか、リトライの自動化などもできます。
キューに積む処理は非常に簡単で、
task := taskqueue.NewPOSTTask("/bqcultivator/maketable",
url.Values{
"sql_file_name": {sc.SqlFileName},
"project_id": {sc.ProjectID},
"target_project_id":{sc.TargetProjectID},
"dataset_id": {sc.DatasetID},
})
taskqueue.Add(ctx, task, "bqcultivator")
のように書くだけでキューに積まれます。キューの管理がいらないのでとても楽です。
ポイント2. CronJob
CronJobは最近CloudSchedulerとしてスピンアウト?しましたが、GAEのエンドポイントを定期的に実行してくれる機能です(doc)。こちらもyamlを書くだけで設定できます。
- description: hourly check
url: /sqlenqueuer/task/hourly # app endpoint
schedule: every 1 hours from 00:00 to 23:00
timezone: Asia/Tokyo
これだけで、該当するGAEのエンドポイントを定期的に実行してくれます。hourly, daily, weeklyなどを用意しています。
ポイント3. dispatch
GAEは1プロジェクトあたり複数のGAEアプリケーションをデプロイでき、それらはURLベースで振り分けができます(doc)。
dispatch:
- url: "*/sqlenqueuer/*"
service: sqlenqueuer
- url: "*/bqcultivator/*"
service: bqcultivator
これで、CronJobがkickするエンドポイントで、該当するGAEを結びつけます。
アプリケーション部分
sqlenqueuer
CronJobで叩かれるエンドポイントを持ち、叩かれるとGCSにあるファイルをキューに積みます。
設定ファイルには、クエリのオプション(テーブル名、実行時間、Truncateするかどうか)が記載されてます。
bqcultivator
キューを消化して、BQにクエリを発行します。キューは勝手にPOSTされるので、特にキューについての処理を書く必要はなく、POSTに対しての処理だけ書いてあればOKです。
実行結果はslackに連携され、失敗していた場合はリトライリンクもセットにし、メンション付きで以下のようにユーザーに通知します。
2週間くらいで作ったときの超初期バージョンのコードです、もし興味があればご参照ください。クオリティ。。。
このアプリの使い方
- 利用者は、SQLと設定ファイルのyamlをGithub(Enterprise)にpushし、レビューを受ける
- マージされると、GCSにSQLと設定ファイルがCI/CDでアップロードされる
- GAEによって、GCSに配置されたSQLを定期実行
- 設定ファイルに従ってデータマートが作成されたり、結果をS3にファイルをおいたりする(後述)
追加機能:BQへのクエリの結果をそのままAPI化
CETチームでは、汎用APIと呼ばれる基盤を構築・運用しています。
2カラムのCSVを指定のS3にアップロードすると、そのCSVの1カラム目がkey, 2カラム目がvalueとなって、HTTPリクエストでkeyで引くとvalueが返却されるAPIを構築してくれる基盤です。valueには文字列はもちろんのこと、配列やJSONも埋め込み可能です。
詳しくはこちら。より詳細についてはTechBlogに書く予定です。(放置しててすみません)
今回のアプリは当初データマート作成だけしていたのですが、数ヶ月前にこのAPI基盤に連携する機能をリリースしました。
例えばBigQueryにしか置いてないデータを集計した結果や、BQ MLを使った簡単な機械学習の結果をS3に配置することで、そのままAPIにすることができます。
イメージとしては
SELECT user_id, some_json FROM `awesome_data.awesome_mart.awesome_table`
と書くと、 https://endpoint/key/
を叩くと some_json
が返却されるAPIが自動で作成されるものです。
awesome_tableを定期的に見に行くので、APIのレスポンスも日次などで更新されます。主にデータサイエンティストの方から好評の機能で、ABテストの高速化に貢献しています。
終わりに
話は変わり、最近Argoというプロダクトを検証しています。
- ワークフローエンジン (argo)
- CI/CD (argo-ci, argo-cd)
- イベントトリガー処理基盤 (argo-events)
が全部お互いによしなに云々してくれる(まだ何もわかっていない)らしく、バッチ処理など全てをこれに寄せるととてもハッピーな気がしています。
argo化すると、Helmよりも複雑なシステム全体をパッケージ化して共有・展開できそうなので、今回紹介したような基盤もargo化していけると良さそう?と期待しています。検証記事もそのうち書けるよう頑張ります。
よいお年を!
Kubernetes の CronJob/Job の仕組みをひもとく
これは リクルートライフスタイル Advent Calendar 2018 の5日目の記事です。
前日 に引き続き CET チーム から、本日は @tmshn がお送りします。
はじめに
- データベースのバックアップを定期的に取る
- npm audit を定期的に実行する
- 放置されている issue/pull request を定期的に Slack に通知する
などなど、日常の中で何かを定期実行したくなることはよくあります。
そんなとき、素朴なソリューションとして真っ先に思いつくのは、ジョブ用のサーバーを用意してその中で cron を実行するというやり方でしょうか。
でも、Kubernetes(以下 k8s)をお使いなら、CronJob というリソースを使うことができます。 1
K8s CronJob ではコントローラーがスケジュールを管理し、実行ごとに Pod を作成して、終了したらそれを破棄します。おかげでリソース効率もよいですし、コンテナを stateless, immutable, ephemeral に保てます。cron の k8s 版というわけですね!
本記事の目的
CronJob は cron の k8s 版と書きましたが、cron にはない自動リトライや重複実行の制御などの機能があり、Rundeck や Airflow など同様「ジョブスケジューラー」と呼ぶのが正しいです。 1
そのため、その付加機能を正しく理解せずただの cron だと思っている と、まれに「あれ? ジョブが実行されない!」となることがありえます。
本記事では CronJob のスケジューラーを実装からひもときながら、どんなときにジョブが実行されないのかを明らかにしていきます。
他のジョブスケジューラー
余談。
世の中には、「ジョブスケジューラー」と呼ばれるツールはいっぱいあります。
Ref: cronの代替になりそうなジョブ管理ツールのまとめ - Qiita
私たちのチームでも、時期や用途によって Rundeck や Airflow を運用したり、AWS の CloudWatch Event + Lambda や GCP の GAE Cron などの cloud-native なソリューションを使ったりしています。 2
そして、k8s の CronJob がそれらと比べてすごく目新しかったり高機能だったりするわけではありません。また、K8s クラスター自体の運用は必要になるので、完全にサーバーーレスというわけにもいきません。
でも、すでに k8s クラスターを運用しているチームには親和性が高いでしょうし、機能が比較的シンプルな実装なのでとっつきやすいと思います。
CronJob の役割
先ほど
K8s CronJob ではコントローラーがスケジュールを管理し、実行ごとに Pod を作成して、終了したらそれを破棄します
と書きましたが、これは嘘です、ごめんなさい。
実際には、CronJob は Job というリソースを作成します。そしてこの Job が Pod を作成し、処理を実行します。
役割としては、
- CronJob
- スケジューリング
- スケジュールまたがっての重複実行の制御
- Job
- リトライ処理(Pod の再作成)
- 並列実行(複数 Pod の作成)
- タイムアウト管理(Pod の強制終了)
という分担になっています。
┌─ CronJob ─────────────────────────────────────────────┐
│ │
│ ┌─ Job ────────────────┐ │
│ │┌─ Pod ─┐ ┌ Pod ─────┐│ │
│ │└────── ☓ └───────── ✓│ │
│ │┌ Pod ────────────┐ │ │
│ │└──────────────── ✓ │ │
│ └──────────────────────┘ │
│ ┌─ Job ──────────────┐ │
│ └────────────────────┘ │
│ ┌─ Job ───────────────┐ │
│ └─────────────────────┘ │
│ ┌─ Job ───────────────┐ │
│ └─────────────────────┘ │
│ (time) │
│ ───┴───────┴───────┴───────┴───────┴────── …… ──→ │
└───────────────────────────────────────────────────────┘
Job とはつまり、「終わりがくるもの」のために Pod を管理するコントローラーなんですね(ちょうど Deployment が web サーバーのような「実行し続けるもの」のために Pod を管理するのと対応しています)。
なので、Job は必ずしも CronJob と合わせて使う必要はなく、webhook から作成したりなどイベントドリブンな使い方も想定されています。
前提知識
閑話休題、CronJob のスケジューラーの話に戻りましょう。
今回は、Kubernetes の最新リリースである v1.13.0 のソースコードを見ていきます。 3
なお、今回はソースコードベースで話をしていくため、細かい挙動は将来的に変更になる可能性が大いにあることをご承知おきください。
kubernetes/kubernetes at v1.13.0
具体的には、下記のファイルが今回ターゲットです。
また、API ドキュメントの CronJobSpec の項から、必要な項目とその拙訳を記載しておきます。
Field | Description |
---|---|
concurrencyPolicy string |
Specifies how to treat concurrent executions of a Job. Valid values are: - "Allow" (default): allows CronJobs to run concurrently; - "Forbid": forbids concurrent runs, skipping next run if previous run hasn't finished yet; - "Replace": cancels currently running job and replaces it with a new one |
同時実行をどのように取り扱うか。選択肢は以下のいずれか:「許可」(既定値)…同時実行を許可する。「禁止」…同時実行を禁止する。以前のジョブがまだ完了していなければ、今回の実行をスキップする。「置換」…現在実行中のジョブをキャンセルし、新しいジョブで置き換える | |
schedule string |
The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron. |
Cron 書式のスケジュール。 https://ja.wikipedia.org/wiki/Crontab 参照 | |
startingDeadlineSeconds integer |
Optional deadline in seconds for starting the job if it misses scheduled time for any reason. Missed jobs executions will be counted as failed ones. |
なんらかの理由でジョブがスケジュール通り実行できなかった場合、何秒後までは実行してよいかという期限(オプション)。最終的に実行できなかったジョブは失敗とみなされる | |
suspend boolean |
This flag tells the controller to suspend subsequent executions, it does not apply to already started executions. Defaults to false. |
このフラグを指定すると、今後の実行を中止する(すでに開始されているものは中止されない)。既定値は false |
引用元: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#cronjobspec-v1beta1-batch
ジョブがスケジュールされないのはどんなときか
以下の文で「実行する」と書かれている場合、それは「Job リソースを作成した」という意味だと思ってください。
ケース1: concurrentPolicy: forbid
ドキュメントに書いてあるとおりなので、比較的分かりやすいでしょうか。
concurrentPolicy: forbid
を設定した場合、実行時間が来てもまだ前の時間のジョブが終わっていなかったら、今回分は実行しないということです。
たとえば1時間に1回の CronJob の場合、10時の分が終わらないまま11時になってしまったら、11時にはジョブが実行されません。
┌─ Job ───┐
└─────────┘
┌─ Job ─────────┐
└───────────────┘
┌ ─ ─ ─ ─ ─
(time) ─ ─ ─ ─ ─ ┘
✓ ✓ ☓
────┴───────────┴───────────┴───────────┴─── …… ──→
9:00 10:00 11:00 12:00
pkg/controller/cronjob/cronjob_controller.go#L280-L292
if sj.Spec.ConcurrencyPolicy == batchv1beta1.ForbidConcurrent && len(sj.Status.Active) > 0 {
// (コメントは引用者が省略)
klog.V(4).Infof("Not starting job for %s because of prior execution still running and concurrency policy is Forbid", nameForLog)
return
}
では問題です。10時分のジョブが終わったあと、ジョブが実行されるのはいつでしょうか?
答えは、「10時分のジョブが終わった直後」です。たとえば10時分のジョブが11:30に終わった場合、次のジョブは12:00の回を待たず、11:30にすぐ始まります。
正 🙆
┌─ Job ───┐
└─────────┘
┌─ Job ───────────┐
└─────────────────┘
┌─ Job ───┐
(time) └─────────┘
✓ ✓ ✓
────┴───────────┴───────────┴───────────┴─── …… ──→
9:00 10:00 11:00 12:00
誤 🙅
┌─ Job ───┐
└─────────┘
┌─ Job ───────────┐
└─────────────────┘
┌─ Job ───┐
(time) └─────────┘
✓ ✓ ✓
────┴───────────┴───────────┴───────────┴─── …… ──→
9:00 10:00 11:00 12:00
というのも、実行しなかった場合に「この回をスキップしましたよ」というのを記録するわけではなく、実行した回のみを記録し、10秒おきに未実行のスケジュールがないかを確認しているからなのです。
ちなみに、未実行のスケジュールが複数あったとき、実行されるのは最新の1件のみです。
pkg/controller/cronjob/cronjob_controller.go#L259-L263
if len(times) > 1 {
klog.V(4).Infof("Multiple unmet start times for %s so only starting last one", nameForLog)
}
scheduledTime := times[len(times)-1]
つまり、10時分の実行が12:30までかかってしまった場合、11時分は実行されることなく、12時分が12:30から実行されるというわけです。
┌─ Job ───┐
└─────────┘
┌─ Job ─────────────────────┐
└───────────────────────────┘
┌─ Job ───┐
(time) └─────────┘
✓ ✓ ✓
────┴───────────┴───────────┴───────────┴────── …… ──→
9:00 10:00 11:00 12:00
なお「何時実行分のつもりで作成された Job なのか」は Job 名を見ればわかります。これが、 scheduledTime
の UNIX タイムスタンプになっているのです。
pkg/controller/cronjob/utils.go#L156
name := fmt.Sprintf("%s-%d", sj.Name, getTimeHash(scheduledTime))
ケース2: suspended
お次は suspended
オプション。こいつが true
になっていたら、ジョブは実行されません。
というかまぁ、ジョブを実行「しないため」の機能なので、当たり前ですね。
pkg/controller/cronjob/cronjob_controller.go#L243-L246
if sj.Spec.Suspend != nil && *sj.Spec.Suspend {
klog.V(4).Infof("Not starting job for %s because it is suspended", nameForLog)
return
}
ユースケースとしては、DB のメンテナンス中はバックアップを止めておくとか、年末年始は放置 issue の通知を止めておくとか、「一時停止」用途で使うものです。
suspended: true
を解除した場合、やはりすぐに溜まっていたスケジュール(の最新分)がすぐに実行されます。
ケース3: startingDeadlineSeconds
ドキュメントに記載されている説明を再掲します。
Optional deadline in seconds for starting the job if it misses scheduled time for any reason. Missed jobs executions will be counted as failed ones.
(拙訳)なんらかの理由でジョブがスケジュール通り実行できなかった場合、何秒後までは実行してよいかという期限(オプション)。最終的に実行できなかったジョブは失敗とみなされる
https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#cronjobspec-v1beta1-batch
「なんらかの理由でジョブがスケジュール通り実行できなかった場合」というのは、まさにこの記事で説明しているような状況が起きた場合、ということです。
先ほどの毎時実行の例を思い出してください。10時実行分が11:30までかかってしまった場合、11:30にすぐ11時分が実行されると書きました。しかしジョブの内容や後続ジョブとの関係で、11:30になっていまさらジョブを始めても無駄だったり、むしろ問題が出てしまう場合もありえるでしょう。
そんなとき、「実行開始があんまり遅れるくらいなら、いっそ実行しなくていいよ」というのを指定するのが startingDeadlineSeconds
です。
pkg/controller/cronjob/cronjob_controller.go#L264-L279
tooLate := false
if sj.Spec.StartingDeadlineSeconds != nil {
tooLate = scheduledTime.Add(time.Second * time.Duration(*sj.Spec.StartingDeadlineSeconds)).Before(now)
}
if tooLate {
klog.V(4).Infof("Missed starting window for %s", nameForLog)
recorder.Eventf(sj, v1.EventTypeWarning, "MissSchedule", "Missed scheduled time to start a job: %s", scheduledTime.Format(time.RFC1123Z))
// (コメントは引用者が省略)
return
}
たとえば startingDeadlineSeconds: 1200
(20分)を指定したとします。10時実行分が11:10までかかった場合はすぐに11時分を実行しますが、11:30までかかってしまった場合は11時分を諦めて12時分まで待つようになります。
11:20 に間に合った場合
┌─ Job ───┐ 11:20
└─────────┘ :
┌─ Job ───────┐ :
└─────────────┘ :
┌─ Job ───┐
(time) └─────────┘
✓ ✓ ✓ :
────┴───────────┴───────────┴───────────┴─── …… ──→
9:00 10:00 11:00 12:00
11:20 に間に合わなかった場合
┌─ Job ───┐ 11:20
└─────────┘ :
┌─ Job ──────────┐
└────────────────┘
: ┌─ Job ───┐
(time) : └─────────┘
✓ ✓ : ✓
────┴───────────┴───────────┴───────────┴─── …… ──→
9:00 10:00 11:00 12:00
なお、
Missed jobs executions will be counted as failed ones.
とありますが、前掲のソースから分かる通り、別に "failed" ステータスの Job が11時分として作成されるわけではなく単に "MissSchedule" というイベントが記録されるだけです(果たしてこれが実装者の意図した通りなのかは分かりませんが……)。
ケース4: "Too many missed start time"
最後のケースです。
ここまで、スケジュールどおりに実行されないケースをいろいろと見てきました。そうして実行されなかったスケジュールがどんどんたまっていくと、ある日
Cannot determine if job needs to be started. Too many missed start time (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew.
というエラーが出るようになります。
これは実行されなかったスケジュールが多すぎる(100件以上ある)ときに、クラスター(の時計)に異常がある場合を考慮して警告してくれるエラーです。
たとえば毎分実行のジョブで、実行日時を誤ってエポック時間(1970年1月1日)で記録してしまったとします。次回スケジューラが実行要否を判断しようとして未実行のスケジュールをリストアップしようとすると、合計でおよそ250万件のリストを計算するハメになります。それは CPU・メモリを食いつぶしてしまう可能性があり困るので、リストアップの途中で100件を超えたら諦めよう、というわけです。
リストアップは古い方から順に行われ、その途中で諦めるので、もっとも最近いつ実行を逃したかは分かりません。そういうわけで、リストすら取得できないので「実行すべきか判断できない」"Cannot determine" というエラーになるわけです。
pkg/controller/cronjob/utils.go#L126-L146
// An object might miss several starts. For example, if
// controller gets wedged on friday at 5:01pm when everyone has
// gone home, and someone comes in on tuesday AM and discovers
// the problem and restarts the controller, then all the hourly
// jobs, more than 80 of them for one hourly scheduledJob, should
// all start running with no further intervention (if the scheduledJob
// allows concurrency and late starts).
//
// However, if there is a bug somewhere, or incorrect clock
// on controller's server or apiservers (for setting creationTimestamp)
// then there could be so many missed start times (it could be off
// by decades or more), that it would eat up all the CPU and memory
// of this controller. In that case, we want to not try to list
// all the missed start times.
//
// I've somewhat arbitrarily picked 100, as more than 80,
// but less than "lots".
if len(starts) > 100 {
// We can't get the most recent times so just return an empty slice
return []time.Time{}, fmt.Errorf("Too many missed start time (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew.")
}
さて、しかしこの100という数字はけっこう曲者です。コメントを読むと「毎時実行のジョブが3連休中実行できないくらいなら問題ない数字」として100を選んだと書いてあります。
でも、4連休だとギリギリアウトです。5分に1回のジョブだったら、一晩でアウトです。
しかし、これには回避策があります。
スケジュールをリストアップするとき、startingDeadlineSeconds
を過ぎてる分はリストアップ対象になりません(現在のstartingDeadlineSeconds
秒前からリストアップをはじめる)。
pkg/controller/cronjob/utils.go#L112-L119
if sj.Spec.StartingDeadlineSeconds != nil {
// Controller is not going to schedule anything below this point
schedulingDeadline := now.Add(-time.Second * time.Duration(*sj.Spec.StartingDeadlineSeconds))
if schedulingDeadline.After(earliestTime) {
earliestTime = schedulingDeadline
}
}
ということは、startingDeadlineSeconds
が十分短ければ、"Too many missed start time" のエラーに悩まされることはなくなります。
たとえば先ほどの毎時実行の例でいえば、「スケジュール間隔は1時間で開始期限は20分」なので、リストアップ対象はかならず1件以下になります。
また開始期限に意思がない場合も、この問題を避けるため、個人的にはスケジュール間隔×100を設定しておくことをおすすめします(たとえば毎時実行なら100時間、など)。ついでにいうと、そもそも同時実行を抑制している(concurrentPolicy
を forbid
か replace
にしている)場合は、startingDeadlineSeconds
をスケジュール間隔より長くする意味はありません。
ケース5: スケジュール書式が間違っている
根本的なやつですが、スケジュール書式が誤っていると動きません。
pkg/controller/cronjob/utils.go#L95-L98
sched, err := cron.ParseStandard(sj.Spec.Schedule)
if err != nil {
return starts, fmt.Errorf("Unparseable schedule: %s : %s", sj.Spec.Schedule, err)
}
おわりに
以上、k8s の CronJob のスケジューラーをソースからひもとき、ジョブが実行されないケースを5つほど見てきました。
思ったより長文になってしまいましたが、どなたかのお役に立てば幸いです。
最後に、本文中で参考リンクをまとめておきます
- ドキュメント
- ソースコード
- API ドキュメント
-
偶然にも 前日の記事 でも CronJob の話をしていますが、そちらは GAE のもので、今日話すものとは別物です ↩
-
そういえば GCP でマネージド Airflow である Cloud Composer がリリースされましたね ↩
-
途中まで v1.11.5 で記事を書いていたのですが、最新版がリリースされたので切り替えました。とはいえ大きな差分はないので、少し前のバージョンのコードを読んでも問題ないと思います。 ↩
10年物システムの裏側
この記事はリクルートライフスタイル Advent Calendar 2018の6日目の記事です。
@tunanosuke です。今年リクルートに入社し、今はホットペッパービューティーの開発をしています。
ホットペッパービューティーはサービスリリースされてから10年以上たつプロダクトです。ビジネスとしては大きな成功をしている一方で、技術的には負債だらけです。これまで社内のエンジニアが少ない中プロダクトの成長を優先してきたという背景もありますが、少しずつ整備をしている段階です。そんな10年物システムが今こうなってるんだという裏側と今どう整備しているかを少しご紹介します。
現状
テーブル、カラム名がローマ字
結構な頻度で出てくるローマ字。そしてたまに省略されている。たとえば 予約
というワードに対して
YOYAKU
YYK
といったような表現方法が存在していて、さらにアプリまでそのまま返されてるとかある。
ローマ字ならまだ理解できるから許容?できても省略はなかなか厳しい。
ただ、もうここを変えるのは大きなコストとリスクを伴うので、変えられるものと変えられないものを切り分けて、変えられないものについてはできるだけ近いところで塞き止めすることが大事ですね。今そのように整備しています。
1テーブルのカラムの数がすごい
横に長い、とにかく長い、、
ただ、RDB考える時まずは横にどう持つかってことから考えることが多いと思うので気持ちはわかる。機能を継ぎ足し継ぎ足しで拡張してきた成果が見えます。
フラグがたくさんあり、1つのテーブルでいくつもの状態を管理しているので、それに引きづられてアプリケーションコードも複雑になっている状態です。一方で「えっ? ここは正規化するんだ」ってのもあり、いろいろ意図を推測しながらやっています。
結構この推測できることには救われることは多くて、細かい視点でツッコミどころはあるにせよ、なんでそうしたんだろうとか、なんでそうなったんだろうとか、なんとなーく意図は読み取れます。もちろん当時の設計者やドキュメントがあればそれに越したことはありませんが、これまで社員にエンジニアがいない、少ない中やってきたのでそれはできないです。なので、「まぁそうしたくなるよね」って感じられる状態なのはまだ救いだなと感じてます。
サブクエリ ジョイン ジョイン サブクエリ ジョイン ユニオン
縦に長い、とにかく長い、、そりゃ1回で取れたら楽、やるやる
ホットペッパービューティーは様々な方々が関わってきて機能が拡張されてきました。結果、場当たり的な設計になってしまっていて統一性がありません。そのため、O/Rマッパー使ってマッピングして ok ってなるようなデータ構造ではないし、かと言って SQL 1回で取るにもなかなか無理がある状態です。結果スロークエリとして1つの負債になっています。
おとなしく SQL 分割してアプリケーション側で自分達に合った構造にマッピングしてあげるレイヤーをアプリケーションに持たせることが今のプロダクトには適切で、ここも1つ1つ整備しているところです。
大量のバッチ
バッチチームがあります。つまりそれくらいあります。
バッチ大好き!
もうできてしまったのは仕方ないとして、一番難しいのはどのバッチがどのデータに影響あるのか把握すること。参照ならまだ良いにしろ、なにをトリガーにどのデータに対して更新があるかは見えにくい状態です。更新処理の走るバッチが Master DB のテーブルをしばらくロックしてしまうなんてことも、、んっ?
API 設計
まず前提としてホットペッパービューティーは大きく2つのサービスからなっています。
- ヘアサロン
- 美容室の検索や予約ができる
- キレイサロン
- エステやリラクゼーション、まつげなどのサロンの検索や予約ができる
このヘアとキレイは性質が異なっていてドメインが全く違います。それを1つのサービスとして展開しています。
それに引きずられるように API のエンドポイントもヘアとキレイが区別されていないことが多いです。性質が違うので必要なデータやレスポンスデータもヘアとキレイで異なるので、アプリケーション内には多くとヘアキレイ分岐があります。美容のサロンという意味では同じ属性も多く存在しますが、異なるドメインを1つで表現することは今後を見据えたときに厳しいですね、、
今はここを明確に分離するように設計をしている段階です。
リソース取得時に POST メソッド
リプレイスなど過去の負債を解消するためには、プロダクトの歴史やそうなった経緯など把握することはとても大切ですしそれをしないと改善はできませんが、ときには敢えて深追いしないという判断をすることも必要だったり。これはそういったケース。
今後の展望を少しだけ
これまでのモノシリックなアーキテクチャから BFF - Backend 構成にリプレイスを進めています。Backend はこれまで通り Java、BFF は Kotlin を採用しています。
Backend に変えられない負債をできるだけ閉じ込めて各プロダクトからシンプルに見えるようにすることが肝です。
このあたりの詳細については今後弊社エンジニアブログでも発信していこうと思います。
まとめ
ここに挙げたのはほんの一部です。こういったことがたくさんあります。一方でプロダクトととしてものすごい成長を遂げてきたのはすばらしいですし、それに感謝しつつ今後を見据えたアーキテクチャに変化させていこうと思います。
technewsを支える技術
この記事は リクルートライフスタイル Advent Calendar 2018 7日目の記事です。
こんにちは。フロントエンドエンジニアの @YuG1224 です。
技術ノウハウやナレッジの共有、どうやっていますか?
Slack の technews チャンネルに技術系記事を投稿することありますよね?
SNS で良い記事を見つけた時、「technews に簡単に共有したい!」と思いませんか?
今回は「Pocket と Microsoft Flow を使って、Slack に自動投稿する方法」を紹介します。
Pocket とは
Pocket とは今見ているWebページを保存してあとで読むことができるWebサービスです。
はてなブックマーク などと同じソーシャルブックマークの1つですね。
Pocket は RSS フィードを作ってくれる
この Pocket で保存した記事は、サイトかアプリで見るのが基本なのですが、RSS フィードも用意してくれているので、マッシュアップに使うことができますね。
Can I subscribe to my list via RSS?
http://getpocket.com/users/USERNAME/feed/all
上記のフィード URL をそのまま Slack の /feed
コマンドで登録して終了…
それでもまったく問題ないのですが、自分はひと手間加えて Microsoft Flow で加工するようにしてみました!
Microsoft Flow とは
Microsoft Flow はアクションやトリガーを使って、タスクを自動化することができるWebサービスです。
似たようなものに IFTTT や Zapier などがあります。
Pocket のフィード URL をフィードトリガーに設定することで、新着のタイトルとリンクが自動で投稿されるようになりましたね。
/feed
や IFTTT の場合、投稿者が Bot になってしまいますが、 Flow ならオプション設定でユーザとして投稿することできるので、修正や削除も可能になります。
さらに、一見すると人が投稿しているように見えるので、機械的で冷たい雰囲気を出さずに自動化することができるのもメリットかなと思います。
さらにひと手間
また、条件のアクションを追加すれば、フィードのタイトルやリンクなど、特定の値によってその後の処理を変えることができます。
これを使えば、フィードの内容によって投稿するチャンネルを変更することもできそうですね。
Pocket に保存するショートカット
実際に SNS 等で記事を見つけて Pocket に保存するときは、普通に Pocket のアプリを開いて保存してもいいのですが、さらにさらにひと手間加えて、はてブも一緒にできるように iOS のショートカットを作って保存するようにしています。
はてブは Twitter と連携しているので、 空き時間にさっと、一連の操作で Slack と Twitter に自動投稿できるのが良いですね!
「はてブしたものを Slack に流せばいいじゃん!」と思う人がいるかもしれませんが、流したいものとそうでないものがあるので、仕分けするために手間をかけている感じです。
以上、ぜひ皆さんもやってみてください!
フロントエンドでTDDを実践する(理論編)
この記事は リクルートライフスタイル Advent Calendar 2018 8日目の記事です。
2018/12/10更新:続編で フロントエンドでTDDを実践する(react-testing-libraryを使った実践編)を書きました。
はじめに
自分のフロントエンドチームでは、TDDでの開発フローを実施することでフロントエンド開発の課題に向き合っていきます。
今回は、一般的に難しいとされるフロントエンドでのテストについて、どんな方針でテストを書けばいいかについて書いてみたいと思います。
フロントエンド開発の課題
プロジェクトによりますが、テストに関連するものでは以下のようなものが挙げられます。
- 実装する仕様について、プロジェクト内でどう認識合わせするか?
- 開発工程のリライアビリティをどう担保するか?
- テストの精度、粒度をどう考えるか?(クロスブラウザ、ユーザーの操作等の副作用、コストメリットなど)
EX. バリデーション付きTextFieldのテストを考える
「ユーザーがテキストフィールドに文字を入力して、バリデーションエラーが表示される」という一連の流れを5つのステップに分けると下記のようになります。
細かく書くなら①〜⑤の各ステップでテストを書くことができますが、全て書いていたらどんなに工数があっても足りないですよね。我々は意味のあるテストを取捨選択して書く必要があります。
ではどういう指針でテストを書けばよいのでしょうか。
指針: The Testing Trophy
via: https://blog.kentcdodds.com/write-tests-not-too-many-mostly-integration-5e8c7fff591c
テスティングトロフィーはテストピラミッドに似た概念ですが、各種テストのコスト高さ(実行速度、開発/保守工数)に加えて、各テストの効果(どれほど大きな問題を解決できるか)を示した図です。
上に行けば行くほど解決できる問題が大きく、横に広ければ広いほどカバーできる範囲が広いことを示しています。
- Static
- FlowやTypeScriptなどの静的型解析を導入することで、タイポや型エラーをチェックする
- Unit
- 単純なコンポーネントや1関数の振る舞いをチェックする
- Integration
- 各コンポーネントや関数、ライブラリを組み合わせたときの振る舞いをチェックする
- End to End
- サーバーのAPIやブラウザといった実際の環境でアプリケーションを動かして、シナリオ通りに動くかをチェックする
フロントエンド特有の課題
Write tests. Not too many. Mostly integration. - Guillermo Rauch
現在のフロントエンドでは、多様なコンポーネントを組み合わせて複雑なアプリケーションを構築していくという前提があります。
unit testを書きすぎない
そのため、コンポーネント単体のUnit Testはテストコードが実装同等になってしまったり、ロジックのないところに書くことになってしまうことがあり注意が必要です。
例えば、「primaryボタンは緑色であること」というテストケースは、実装そのものの(implementation details)のテストになってしまいます。これはまるで、「シロクマは白い」ことを確認するくらい意味がありません。
また同様に、「コンポーネントはComponentDidMountでItemList APIをfetchすること」は、内部実装に依存しすぎていて、仕様そのままにリファクタしても簡単にテストが壊れてしまいます。
より多くのIntegration testを書く
様々なコンポーネントが複雑に絡み合うフロントエンドにおいては、ユニットテストよりもインテグレーションテストのほうがより解決する範囲が広くなることが多いです。
例えば、「コンポーネントを表示すると、APIの返り値の数だけItemListが表示される」というテストはビジネスロジックを的確にテストできます。
テスティングトロフィー考案者のKent C Doddsは、トロフィー図の比重を意識するとコストメリットのバランスが良くなるとしています。
まとめると、ロジックを持った単一モジュールにはユニットテストを書き、それ以外の機能仕様についてはインテグレーションテストでカバーする という方針が良さそうです。
EX. 改めてバリデーション付きTextFieldのテストを考える
テスティングトロフィーの概念を踏まえた上で、冒頭の例を見てみましょう。
Unit Test
①、③、⑤についてはロジックを持った単一モジュールと言えるので、ユニットテストを書く対象となります。特に③については書いておくべきです。
一方で②、④についてはロジックというよりも実装そのものであり、ユニットテストを書く対象外となります。
Integration Test
図の①→⑤の一連の流れが、フィールドに半角数字以外の文字列を入力すると「半角数字で入力してください」というメッセージが出る というインテグレーションテストです。
Congratulations! これでどのテストを書けばいいかが明確になりましたね。
後者のインテグレーションテストはまさにアプリケーションの機能仕様そのものです。つまりIntegration Testの1ケースはアプリケーションの機能仕様と対となり、各Integration Testのケースを束ねるとアプリケーションの機能仕様ドキュメントにもなります。大規模開発の場合はプロジェクト内でエビデンスとして共有することもできます。
続編:Integration Testをどう書くか?
さて、テスティングトロフィーの指針に則り、テストの方針が整いましたがここで重要なテーマが残っています。「どうやってコンポーネントのテストを書くか?」です。
自分のフロントエンドチームでは、react-testing-libraryとうい新しいテストライブラリを使ってTDDを実践しています。
長くなってしまうので、続編として「react-testing-libraryでTDDを実践する」を別の記事で書きたいと思っていますが、簡単に触れておきます。
react-testing-library
テスティングトロフィーの提唱者として本記事で触れたKent C Doddsがenzymeのリプレイスを意図して作った、Reactのための新しいテストユーティリティです。
設計思想として以下のようなものを掲げています:
The more your tests resemble the way your software is used, the more confidence they can give you
一言で言うと、ユーザーが実際に使うようにテストされているべきだという話で、react-testing-libraryの提供するAPIを使うと、ユーザーがアプリケーションを操作するのを模倣するようにテストコードを書くことができます。
詳しくは続編の記事で紹介したいと思います★
まとめ
フロントエンドのテストは何をどこまで書くか分かりづらいことが多いですが、従来のテストピラミッドに加えて、テスティングトロフィーの概念を元に整理すると書くべきテストが見えてきます。
個人的には、 ロジックを持った単一モジュールにはユニットテストを書き、それ以外の機能仕様についてはインテグレーションテストでカバーする という方針をおすすめします。
今回は簡単な例で紹介しましたが、これはより複雑な機能についても適用できるものなので、ぜひ実践してみてください。
Ktor on Android
この記事は リクルートライフスタイル Advent Calendar 2018 の9日目の記事です。
本日は、ホットペッパービューティーのAndroidアプリ開発を担当している @oxsoft が、KtorをAndroidで使ってみた話を書いてみたいと思います。
Ktorとは?
Kotlin製のWebフレームワークで、DSL形式で簡単に書くことができます。先日Ktor 1.0がリリースされました!
https://ktor.io/
Welcome Ktor 1.0, a connected applications framework built by the Kotlin team! Create asynchronous, high-performing, and lightweight web servers and build non-blocking multiplatform web clients, all in one language with idiomatic APIs.
— Kotlin (@kotlin) 2018年11月19日
https://t.co/tb1G6zefYI pic.twitter.com/bxzqXsNopG
公式のFAQにも書かれていますが、KtorはKotlinで書かれているので、Androidでも動きます。(ただし、API24以降)
https://ktor.io/quickstart/faq.html#android-support
今回作るもの
今回はKtorをAndroid上で動かして、簡単なモックサーバアプリを作ってみたいと思います。通常の開発用サーバに接続する場合と比較して、モックサーバアプリを作る利点としては以下のようなことが挙げられます。
- 特殊なレスポンスも簡単にモックできる
- エンジニア以外もGUIで簡単にレスポンスを変更できる
- 端末単位で環境を用意することができる
もちろんKtorを使わなくてもAndroid上にHTTPサーバを立てる方法はいくつかありますが、今回はKtorをAndroidで使ってみたいという気持ちがあるのでKtorを使います
環境構築
先述した通り、KtorはAPI24以降でしか動作しないので、まずはminSdkVersionを24以上にします
android {
defaultConfig {
minSdkVersion 24
}
}
そして、以下の依存関係を追加します。
dependencies {
implementation 'io.ktor:ktor-server-netty:1.0.0'
implementation 'org.slf4j:slf4j-android:1.7.25' // ログ出力用
}
また、重複ファイルのエラーが出るので、以下のファイルを除外します。
android {
packagingOptions {
exclude 'META-INF/io.netty.versions.properties'
exclude 'META-INF/INDEX.LIST'
}
}
当然、インターネットアクセスが必要となるため、以下のパーミッションも追加します。
<manifest>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
サーバーの起動
以下のコードをバックグラウンドスレッドから呼び出すことで、8080ポートにHTTPサーバを起動することができます。
embeddedServer(Netty, 8080) {
routing {
get("/") {
call.respondText("<h1>Hello world!</h1>", ContentType.Text.Html)
}
}
}.start(wait = true)
Chromeを開いてhttp://localhost:8080/
にアクセスすると、無事にHello world!
を表示する事ができました
また、CallLogging
を設定することにより、アクセスログを出力することができます。
embeddedServer(...) {
install(CallLogging) {
level = Level.INFO
}
...
}
I/ktor.application: 200 OK: GET - /
I/ktor.application: Unhandled: GET - /favicon.ico
リクエストの受付
今回はassets/response/
以下に用意したファイルを返却するようにします。URLのパスとassetsフォルダを対応させたいので、URLのパスをハンドリングします。パスのハンドリングは以下のように書くことで簡単に行うことができます。
get("/{path...}") {
val path = call.request.path()
}
ルーティングのパラメータ名をpath...
としていますが、今回は使っていないので実際には何でも大丈夫です。
もちろん、パラメータとしても受け取っているので、以下のようにしてもパスを取得することができます。(※スラッシュの有無などが、上記のパターンと異なります)
get("/{path...}") {
val path = call.parameters.getAll("path").orEmpty().joinToString("/")
}
ルーティングの詳しい文法は、以下のドキュメントに書いてあります。
https://ktor.io/servers/features/routing.html
レスポンスの返却
あとは、このパスに応じてassetsファイルを返却するだけです。
以下のような関数を用意しておいて、
fun readAsset(path: String) = BufferedReader(InputStreamReader(assets.open(path))).use { reader ->
reader.readLines().joinToString("\n")
}
レスポンス時に呼び出します。
call.respondText(readAsset("response$path"), ContentType.Application.Json)
これでモックサーバアプリは完成しました
ホットペッパービューティーのデバッグ版アプリでは、OkHttp3のInterceptorによってドメインを任意のものに差し替えるデバッグ機能があるので、それをlocalhost:8080
に指定することで接続できました
実際のモックサーバアプリでは、以下のような感じでAPIごとにモックするレスポンスを変更することができるようにしましたが、特に記事にするほどのことをしていないので、今回は割愛します。
まとめ
ほとんどつまづくことなく、簡単にHTTPサーバを立てることができました
今年も残りわずかとなりましたが、来年もまたAndroid/Kotlin界隈がより一層盛り上がっていくのが楽しみですね!それではみなさん良いお年を
Logstashを使ったElasticsearchの無停止インデックス更新の運用を考える
はじめまして!
リクルートライフスタイルで新規事業の開発を担当している@taikitです。
今回のアドベントカレンダーを機に初めて記事を書いてみます。
はじめに
現在、Elasticsearchを用いた検索システムの構築を行っています。Indexの設定を更新する場合、更新以前のデータには適用されません。更新以前のデータにも適用させるためには、インデックスを作り直す必要があります。様々な運用方法が考えられますが、今回はなるべく楽に運用できる方法を考えてみました。
考えるケース
検索対象のデータはMySQLに溜められたのちに、LogstashでElasticsearchへ同期します。ユーザーの検索リクエストをRailsで受け取、Elasticsearchで検索を行います。Railsの場合、公式のGemでElasticsearchへデータを保存することもできますが、Railsを介さずMySQLへ直接データが保存されることも想定してLogstashを使っています。なお、ElasticsearchはマネージドサービスであるElastic Cloudを利用しています。
無停止インデックス更新の主な方法
調べた範囲では主に大きく分けて3つの方法がありました。
Index Aliasesを利用する方法
公式ブログで紹介されている方法です。アプリケーション側で直接Indexを参照するのではなく、Aliasを参照するように設定しておきます。これにより新しいIndexを参照させたいときは、Aliasの更新だけすれば良いのでアプリケーションのコードを更新する必要はありません。
クラスタごと切り替える方法
リクルートの他部署でも行われている方法1です。運用中のクラスタとは別に新しいインデックスが適用されたクラスタを作り、Blue/Greenデプロイ等で新旧のクラスタを入れ替えます。比較的大掛かりになりますが、Elasticsearchのバージョンアップ等の運用面も考慮すると一度仕組みを構築してしまえば運用は楽だそうです。
アプリケーションレイヤーで切り替える方法
こちらの記事で紹介されている方法です。アプリケーション側で参照するインデックスを切り替えます。アプリケーション側の検索クエリの変更を伴う場合やIndexのFieldを大きく変更する場合は、アプリケーションのコードで切り替える必要があるケースもあるかもしれません。
適用した方法
今回はIndex Aliasesを利用する方法を採用しました。クラスタごと切り替える方法に関しては、今回はマネージドサービスを利用しておりElasticsearch自体の更新は自動的に無停止で行われるため、バージョンアップ時の心配はありません。また検索性能を向上させるためにAnalyzerを頻繁に更新することが予想されるため、毎回アプリケーションを更新するのでは煩雑になってしまいます。
処理の概要としては、Logstashでタイムスタンプが含まれたIndexを新規作成し、定期実行されるスクリプトでIndex名のタイムスタンプを読み取り、新しいIndexがあればAliasの切り替えを行います。
Logstash の設定
Logstashをデプロイするだけで新たな設定のIndexがElasticsearchに追加されデータが同期されるような設定を行います。
input {
jdbc {
jdbc_driver_library => "mysql-connector-java-8.0.11/mysql-connector-java-8.0.11.jar"
jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
jdbc_connection_string => "jdbc:mysql://${MYSQL_HOST}:3306/${DB_NAME}"
jdbc_user => "${MYSQL_USER}"
jdbc_password => "${MYSQL_PASSWORD}"
schedule => "* * * * *"
statement_filepath => "/usr/share/logstash/sql/items.sql"
use_column_value => true
tracking_column => "updated_at"
tracking_column_type => "timestamp"
}
}
output {
elasticsearch {
hosts => ["${ELASTICSEARCH_HOST}"]
user => "${ELASTICSEARCH_USERNAME}"
password => "${ELASTICSEARCH_PASSWORD}"
manage_template => true
template => "/usr/share/logstash/template/items.json"
template_name => "items"
template_overwrite => true
index => "items-${INDEX_TIMESTAMP}"
document_type => "_doc"
document_id => "%{id}"
}
}
デプロイ時に環境変数のINDEX_TIMESTAMPに20181209045112のように現在時刻がセットされるようにしてください。これによりIndex名の末尾にタイムスタンプが入るようにしています。Indexの設定はなるべくLogstashに寄せるためにIndex Templatesを使っています。Templateのindex_patternsのタイムスタンプの部分にはワイルドカードを指定しておきます(上記の例の場合"items-*")。
tracking_columnを設定し、前回同期時点以降に更新されたレコードのみを同期するようにしています。Logstashをデプロイする際に、この最終同期日時をリセットすることで新しいIndexにすべてのレコードが同期されます。Logstashをコンテナで運用する場合などは最終同期日時を記録しているファイルが引き継がれないため、このリセットに関して操作は必要ありません。
Aliasを更新するスクリプト
Index名のタイムスタンプを読み取り最新のIndexにAliasを入れ替え、古くなったIndexを削除します。このスクリプトはクックパッドさんのこちらの記事を大変参考にさせていただきました。
require 'elasticsearch'
require 'uri'
require_relative '../index_manager'
ES_URL = ENV.fetch('ES_URL')
ES_USER = ENV.fetch('ES_USER')
ES_PASSWORD = ENV.fetch('ES_PASSWORD')
TABLE_NAME = ENV.fetch('TABLE_NAME')
uri = URI.parse(ES_URL)
uri.user = ES_USER
uri.password = ES_PASSWORD
client = Elasticsearch::Client.new(url: uri.to_s)
index_manager = IndexManager.new(TABLE_NAME, client)
index_manager.switch_alias_to_latest
index_manager.delete_old_indexes
class IndexManager
def initialize(name, client)
@name = name
@client = client
end
def switch_alias_to_latest
latest_index_cache = latest_index
return if indexes_in_alias == [latest_index_cache]
switch_alias(latest_index_cache)
end
def delete_old_indexes
old_indexes.map do |index|
@client.indices.delete(index: index)
end
end
private
def alias_name
"#{@name}-latest"
end
def indexes
@client.indices.get(index: "#{@name}-*").keys
end
def indexes_in_alias
@client.indices.get_alias(index: alias_name).keys
rescue Elasticsearch::Transport::Transport::Errors::NotFound
[]
end
def latest_index
latest_date = indexes.map { |index| index_timestamp(index) }.max
"#{@name}-#{latest_date}"
end
def old_indexes
latest_timestamp = index_timestamp(latest_index)
indexes.select { |index| index_timestamp(index) < latest_timestamp }
end
def switch_alias(new_index)
actions = []
indexes_in_alias.each do |old_index|
actions << { remove: { index: old_index, aliases: alias_name } }
end
actions << { add: { index: new_index, aliases: alias_name } }
@client.indices.update_aliases(body: { actions: actions })
end
def index_timestamp(index)
index.match(/#{@name}-(\d{14})/)[1]
end
end
このスクリプトをCron等で定期実行させることで、新しいIndexが作成された際に自動的にAlias先のIndexが切り替わり、古いIndexは削除されます。万が一に備えて、切り戻しを想定する場合はIndexの削除は行わない方が良いかもしれません。
同期が完了していない場合もAliasを更新してしまう
上記のスクリプトを定期実行する場合、対象のデータが多いとLogstashの同期が完了する前にAliasを切り替えてしまいタイミングによっては検索結果が不完全になる可能性があります。スクリプト内で新旧のドキュメント数を比較したり、旧インデックスの最後のIDが新インデックスに含まていることを確認したりする処理が必要になります。(できれば追記したいと思います)
Railsの設定
gemのelasticsearch-modelを利用している場合の設定になります。index_nameにスクリプトで設定したAlias名を設定します。
class Item < ApplicationRecord
include Elasticsearch::Model
index_name 'items-latest'
document_type '_doc'
end
終わりに
Logstashと組み合わせて無停止インデックス更新の方法を考えてみました。まだ一部、試行錯誤の途中ではありますが記事にしてみました。こんな運用の仕方もあるよ等ありましたらコメントで教えていただけるとうれしいです。
【障害報告】ログイン直後APIを甘く見てたらアプリ利用不能な障害が起きた話
こんにちは。鹿島(@kashitaka)です。リクルートでサーバーサイドエンジニアをやってます。
今年も色々ありましたが、中でも社内向けのLT会で発表して、バックエンド・フロントエンド・ネイティブ関係なく面白いと好評だった「ある障害の振り返りと学び」をこの場にも書こうと思います。
TL; DR
ログイン直後に叩く状態取得系のAPIの設計に気をつけよう
- ログイン直後APIはログインAPIと同じくらい重要
- ログイン直後APIに多くの処理、複雑なロジックは盛り込まない
- ログイン直後APIではトランザクションデータはなるべく扱わない
障害内容
一部ユーザーでアプリが全く利用できない
私が担当しているプロダクトで「あるユーザーがiOSアプリでログインするとフリーズして全く利用できないようだ」という報告を受けました。一部ユーザーとはいえアプリが完全に利用不能な状態というのは緊急事態です。
システムについて
障害が起きたプロダクトは社外向けの有料サービスで、iOS・ブラウザアプリのクライアントアプリとAPIサーバーからなる一般的なアプリです。
コードの品質もよく、障害も軽微なものが年に数回起きる程度だったため「完全に利用できない」レベルの障害は意外でした。
原因の調査
調べると特定のユーザーでログイン直後にリクエストされる設定取得API(以下、ログイン直後APIと呼ぶ)でエラーしていました。
また、このシステムでは予約
というEntityを扱うのですが、障害が発生しているユーザーはその予約
の登録件数が平均的なユーザーの数百倍という使われ方をしていました。
要因①: ログイン直後API
ログイン直後APIは、クライアントアプリの初期化と初期画面をレンダリングするために必要な情報:
- ユーザーの設定状態
- ユーザーの権限
- 現在のユーザーの状態
などを返すAPIです。サーバーでHTMLをレンダリングしていた時代と違い、SPAやネイティブアプリのクライアントアプリとAPIサーバーからなる昨今のシステムでは、ログイン直後に設定取得系のAPIを叩いて情報を取得するのは一般的な作りだと思います。で、このログイン直後APIがコケると初期化処理に失敗して利用不能になるわけです。
これは当然といえば当然なのですが意外と盲点でした。 ログインAPIは絶対にコケちゃいけないという認識があるので入念にテストしますが、ログイン直後APIは他APIと同じ扱いでアグレッシブに変更していました。
APIの名称も「ログイン直後API」などでは当然なく「ユーザー情報取得API」的なものなので、重要度は直感的に理解しにくいものでした。
要因②: 肥大化したログイン直後API
さらに調査のためにこのログイン直後APIの仕様を調べると、レスポンスパラメーターの項目数が30以上。オブジェクトの中の項目も含めると超膨大な数になっていました。
度重なる機能追加で初期画面のレンダリングに必要な情報がどんどん増えてこうなったのだと思いますが、ここまで多くの情報を詰め込んでいると
- 影響範囲がわかりづらい・把握できない
- 多くの情報をかき集めるため、実行するコード行数が多い
となり、どこか1つでもエラーすると全部共倒れしてしまい、クライアントアプリに大きな影響を出してしまいます。
要因③: トランザクションデータを扱う複雑なロジック
また、今回の障害にエラーになった箇所を調べると、上記のパラメーターのうち
-
予約ステータス
がタイプA かつ タイプB
となる予約
Entityの配列
なるものを取得する箇所で予約
レコードを数千件取得しようとしてタイムオーバーしていました。これは上の条件に一致する状態の予約
レコードが大量に作られないと負荷がかからないので、簡単な負荷試験では気が付きにくいと思います。
少なくとも、使い方やレコード数による負荷が予測しにくいトランザクションデータは、ログイン直後APIに入れるべきではありませんでした。
悲しいことに、取得に失敗していたパラメーターは画面では割と目立たない位置に表示する情報で、「これがないとアプリのUXを大きく損なう!」というレベルの重要情報ではありませんでした。
まとめと対策
- ログイン直後APIの重要性を軽んじた
- 情報が多すぎて影響範囲がわかりにくいAPIになっていた
- 使い方次第で負荷が予測できないトランザクションデータを使っていた
という地雷は想定しないレコード数で発動し、アプリ利用不能という重い障害が起きました。
もし、何か1つでもちゃんとやっていれば想定以上のレコード数だとしても軽い機能不備くらいの障害で済んだはずです。悔しい!!
対策としては下記を実施しました。
- アプリで絶対使えて欲しい主要な機能を定義
- その機能を動かすために最低限必要な情報を洗い出し
- ログイン直後APIはその情報のみにスリム化する
- その他の情報は別APIで取得。もしどれかが取得失敗しても主要機能が動くようにフロント実装する
で、見直してみると、主要機能を動かすために必要な最低限の情報って設定系のマスタデータくらいだったわけです。
マスタデータだと基本的にはDBから取得し、多少の加工をして返すだけの簡単な処理になるはずなのでテストも容易だし、レコード数の影響も受けにくくなります。
感想
- あまり知られてない(?)けど重要なAPI設計のアンチパターンを見つけられてよかったです。
- アプリによってはログイン直後APIが一つでなく、複数APIの結果が全て正常に揃わないと初期化できないケースがあるはずです。そのような場合には本当にその設計で良いのか見直した方がいいと思います。
- BFFでAPIをアグリゲーションするトレンドもありますが、アグリゲーションのしすぎは危険。APIの適切な情報量がありそうだなあと思いました。
そんなわけで、この失敗が読んでいただいた皆さんのシステム設計の参考になればと思います。参考になった方は👍を押していただけると。ではでは〜👋
ReactでForm作るの辛い問題を何とかしたい
これは リクルートライフスタイル Advent Calendar 2018 の12日目の記事です。
こんにちは!リクルートライフスタイルでエンジニアをやっている @roronya です。
ここ半年ほどReactでアプリケーションを書いていました。
噂通りFormで苦しみましたが、ReactにもRailsのFormオブジェクトのようなものを導入してみるとスッキリしたので、そのことについて書きます。
Formの辛いところ
Form作るのは辛いです。どのへんが辛いかというと、この4つくらいかなと思います。
- Formのために作り込まなければならないものが多い
-
<select>
で表示する選択候補とか - フォーム用に
{label: "hoge", :value: "hoge"}
の形に変換する処理とか
-
- Formに入力された値をアプリケーションとして持ちたい形に変換しなければならない
- 検索フォームに入れた結果を加工してstateに入れて保持したいとか
- Formに入力された値をもとにAPIを叩く場合、API用に変換しなければならない
- camelCaseをsnake_caseに変換するとか
- APIで要求されているインタフェースに合わせるとか
- ↑のような処理を書いておく適した場所がない
抽象的なので、具体的に考えてみます。
HotPepperBeautyの検索フォームを実際に作ってみる
PC版のHotPepperBeautyの「日付からサロンを探す検索フォーム」が例として良い感じなのでこれを実装してみます。
たったこれだけのフォームでもフォームあるあるの箇所が3つくらい見えていて、「あ〜若干嫌ダナー」という感じがします。
- 今日・明日・土曜・日曜の候補日の算出どこでやろう
- 開始時刻は現在時刻の区切りの良い時間っぽいけどどこに書こうかなー
- 「指定なし」か〜
こういう仕様だとします。(※例です。実際の仕様とは異なります!)
- 初期条件
- 日付: 今日
- 開始時刻の始まり: 直近の時刻
- 開始時刻の終わり: 指定なし
- 日付の候補は今日・明日と次の土日
- 「指定した条件で探す」を押すとAPIを叩く
- APIは以下のパラメータをGETで送る
- date: 日付
- dateFrom: 開始時刻の始まり 送らなければ「指定なし」になる
- dateTo: 開始時刻の終わり 送らなければ「指定なし」になる
なので画像のような条件で検索するときは
https://api.beauty.hotpepper.jp/search?date=12/9&dateFrom=11:00
にGETするイメージです。「指定なし」の dateTo
はパラメタに付ません。
素直に作ってみる
コードはGitHubにあげています。各例ごとにブランチを切っています。
GitHub - roronya/advent-calendar-2018 at feature/naive
記事では一部抜粋します。
Formikを気に入って使っているので、この例でもFormikを使います。
InnerSearchForm
まず <Formik />
の render
に渡す InnnerSearchForm
コンポーネントはこんな感じになります。普通のFormですね。
import React from "react";
export default ({ values, candidates, handleChange, handleSubmit }) => (
<form onSubmit={handleSubmit}>
<div style={{ display: "flex", justifyContent: "center" }}>
{candidates.map(c => (
<div key={`date${c.value}`}>
<input
type="radio"
id={c.value}
name="date"
defaultChecked={values.date === c.value}
value={c.value}
onChange={handleChange}
/>
<label htmlFor={c.value}>{c.label}</label>
</div>
))}
</div>
<div>
<label>開始時刻</label>
<select value={values.timeFrom} name="timeFrom" onChange={handleChange}>
<option value="null">指定なし</option>
{[...Array(24).keys()].map(t => (
<option key={`dateFrom${t}`} value={`${t}:00`}>
{t}:00
</option>
))}
</select>
~
<select value={values.timeTo} name="timeTo" onChange={handleChange}>
<option value="null">指定なし</option>
{[...Array(24).keys()].map(t => (
<option key={`dateTo${t}`} value={`${t}:00`}>
{t}:00
</option>
))}
</select>
</div>
<input type="submit" value="指定した条件で探す" />
</form>
);
SearchForm
ここからが問題のもろもろの変換処理です。 <Formik />
のinitialValues
や onSubmit
、 render
から辛い感じが伝わってきます。上述した3点がそれぞれ実装されています。
- 今日・明日・土曜・日曜の候補日の算出どこでやろう =>
render
- 開始時刻は現在時刻の区切りの良い時間っぽいけどどこに書こうかなー =>
initialValues
- 「指定なし」か〜 =>
onSubmit
(日付の処理はMoment.js使えって言われそうだけどとりあえずナイーブに…)
import React from "react";
import { Formik } from "formik";
import InnerForm from "./InnerSearchForm";
export default () => (
<Formik
initialValues={(() => {
// 開始時刻は現在時刻の区切りの良い時間
const now = new Date();
const month = now.getMonth() + 1;
const day = now.getDate();
const date = `${month}/${day}`;
const hour = now.getHours() + 1;
return {
date: date,
timeFrom: `${hour}:00`,
timeTo: "null"
};
})()}
onSubmit={values => {
// 「指定なし」か〜
const params = {
date: values.date,
time_from: values.timeFrom === "null" ? null : values.timeFrom,
time_to: values.timeTo === "null" ? null : values.timeTo
};
// APIを叩くActionCreatorを呼び出すべきだが、簡単のためalertする
// axios.get(endpoint, {params: params})
alert(JSON.stringify(params));
}}
render={({ values, handleSubmit, handleChange }) => (
<InnerForm
values={values}
candidates={(() => {
// 今日・明日・土曜・日曜の候補日の算出
let now = new Date();
const today = `${now.getMonth() + 1}/${now.getDate()}`;
now.setDate(now.getDate() + 1);
const tomorrow = `${now.getMonth() + 1}/${now.getDate()}`;
while (now.getDay() !== 6) {
now.setDate(now.getDate() + 1);
}
const sut = `${now.getMonth() + 1}/${now.getDate()}`;
now.setDate(now.getDate() + 1);
const sun = `${now.getMonth() + 1}/${now.getDate()}`;
return [
{ label: `今日${today}`, value: `${today}` },
{ label: `明日${tomorrow}`, value: `${tomorrow}` },
{ label: `土曜${sut}`, value: `${sut}` },
{ label: `日曜${sun}`, value: `${sun}` }
];
})()}
handleSubmit={handleSubmit}
handleChange={handleChange}
/>
)}
/>
);
レンダリングするとこんな感じのFormになります。
なんとか作れはしますが読みづらいコードができました。
もし検索フォームに変更があった場合、 initialValues
・ onSubmit
・ rende
に書かれたロジックに悩まされそう、という気がします。
また、ロジックがComponentに書き込まれているのもロジックのテストがしづらく嫌な感じです。
これらの処理だけうまく取り出せるとテストもしやすく良さそうです。
Formオブジェクトを作る
GitHub - roronya/advent-calendar-2018
そこでRailsでよく使われるFormオブジェクトのようなものを用意します。
Formオブジェクトの解説は下の記事が詳しいですが、簡単に説明すると、入出力のための前処理などを担当するオブジェクトです。
参考:
Railsアプリケーションでフォームをオブジェクトにして育てる - クックパッド開発者ブログ
[Rails][Reform]Formオブジェクト使い方まとめ - Qiita
SearchForm
https://github.com/roronya/advent-calendar-2018/tree/master
というわけで、Formオブジェクトを使って、検索条件に関わる処理をカプセル化して、SearchForm
をこんな見た目にしたいです。
-
initialValue
での検索条件の初期化とForm用の変換 =>new SearchCondition().toForm()
-
render
での候補日の算出 =>SearchCondition.getCandidates(new Date())
-
onSubmit
でのAPIのための変換 =>SearchCondition.fromForm(values)
で一度SearchCondition
インスタンスにしてから.toAPI()
import React from "react";
import { Formik } from "formik";
import InnerForm from "./InnerSearchForm";
import SearchCondition from "./SearchCondition";
export default () => (
<Formik
initialValues={new SearchCondition().toForm()}
onSubmit={values => {
const params = SearchCondition.fromForm(values).toAPI();
// APIを叩くActionCreatorを呼び出すべきだが、簡単のためalertする
// axios.get(endpoint, {params: params})
alert(JSON.stringify(params));
}}
render={({ values, handleSubmit, handleChange }) => (
<InnerForm
values={values}
candidates={SearchCondition.getCandidates(new Date())}
handleSubmit={handleSubmit}
handleChange={handleChange}
/>
)}
/>
);
スッキリしました。
SearchCondition
この SearchCondition
クラスがFormオブジェクトです。
Form用のデータの加工処理は SearchCondition
に持たせてしまします。
このようにしてしまえば普通のクラスなのでテストを書くのも簡単です。
const toDateString = date => {
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}/${day}`;
};
export default class SearchCondition {
constructor(date = new Date(), timeFrom = null, timeTo = null) {
this.date = date;
this.timeFrom = timeFrom;
this.timeTo = timeTo;
}
toForm() {
return {
date: toDateString(this.date),
timeFrom: `${this.date.getHours() + 1}:00`,
timeTo: this.timeTo ? `${this.timeTo}:00` : "null"
};
}
toAPI() {
return {
date: toDateString(this.date),
time_from: this.timeFrom,
time_to: this.timeTo
};
}
static fromForm({ date, timeFrom, timeTo }) {
return new SearchCondition(
new Date(date),
timeFrom === "null" ? null : timeFrom,
timeTo === "null" ? null : timeTo
);
}
static getCandidates(inputDate) {
let date = new Date(inputDate);
const today = `${date.getMonth() + 1}/${date.getDate()}`;
date.setDate(date.getDate() + 1);
const tomorrow = `${date.getMonth() + 1}/${date.getDate()}`;
while (date.getDay() !== 6) {
date.setDate(date.getDate() + 1);
}
const sut = `${date.getMonth() + 1}/${date.getDate()}`;
date.setDate(date.getDate() + 1);
const sun = `${date.getMonth() + 1}/${date.getDate()}`;
return [
{ label: `今日${today}`, value: `${today}` },
{ label: `明日${tomorrow}`, value: `${tomorrow}` },
{ label: `土曜${sut}`, value: `${sut}` },
{ label: `日曜${sun}`, value: `${sun}` }
];
}
}
Reduxを使っているとき
GitHub - roronya/advent-calendar-2018 at feature/redux
Reduxを使っている場合はこんな見た目になります。かなりシンプルでいい感じに見えます。
SearchForm(Container)
import { connect } from "react-redux";
import SearchForm from "../components/SearchForm";
import SearchCondition from "../forms/SearchCondition";
const mapStateToProps = state => {
const searchCondition = new SearchCondition();
return {
searchCondition: searchCondition.toForm(),
candidates: SearchCondition.getCandidates(new Date())
};
};
const mapDispatchToProps = dispatch => ({
handleSubmit(values) {
const params = SearchCondition.fromForm(values).toAPI();
// APIを叩くActionCreatorを呼び出すべきだが、簡単のためalertする
// axios.get(endpoint, {params: params})
alert(JSON.stringify(params));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(SearchForm);
SearchForm(Component)
import React from "react";
import { Formik } from "formik";
import InnerForm from "./InnerSearchForm";
export default ({ searchCondition, candidates, handleSubmit }) => (
<Formik
initialValues={searchCondition}
onSubmit={values => {
handleSubmit(values);
}}
render={({ values, handleSubmit, handleChange }) => (
<InnerForm
values={values}
candidates={candidates}
handleSubmit={handleSubmit}
handleChange={handleChange}
/>
)}
/>
);
検索条件を保持したい
GitHub - roronya/advent-calendar-2018 at feature/save-condition
検索条件を保持したければStoreに SearchCondition
をそのまま入れてあげればOKです。
後はいつもどおりReducerを書いて handleSubmit
でactionをdispatchします。
コードを張ると長くなるので、詳しくはリポジトリを見てください。
終わりに
BFFを用意できるのであれば、BFFでフロントとやり取りしやすい形式のインタフェースを提供してしまったほうが楽かなーとは思います。
ですが、関心事でオブジェクトにしているので、検索条件保持などは、flattenなpropsを扱うよりも書きやすいかな、と思います。
マサカリ募集中です!コメントください!!
よいお年を〜。
RubyでHipHopする ~ Sonic Piのチュートリアル後の遊び方 ~
これは リクルートライフスタイル Advent Calendar 2018 の13日目の記事です。
バックエンドエンジニアをやっている@pchatsuです。
今回は、個人的趣味の話で恐縮ですが、Rubyで書けるプログラマブルなシーケンサ&ライブパフォーマンスツールSonic Piについて書きます。
難しい技術の話は出てこないので、アドベントカレンダー折返し地点の箸休めにどうぞ。
Sonic Piとは何か
簡単にいうと、Rubyのコードで作曲やライブ演奏ができるソフトウェアです。
百聞は一見に如かず。以下の動画を見てみてください。
Daft PunkのAround the worldをSonic Piでプログラミングした動画になります。
https://www.youtube.com/watch?v=7W4yTXsTavQ
また
https://vimeo.com/132813228
こんな感じでライブ演奏の楽器としても使えます。
実行環境は
https://sonic-pi.net からダウンロードできます。
チュートリアルについて
すでにいろいろな方が記事を書いているようです。ドットインストールもありました。
- https://qiita.com/santa_sukitoku/items/6585a84eeb01d2b8d3a3
- https://dotinstall.com/lessons/basic_sonicpi
Sonic Piをダウンロードすると同梱されているチュートリアルがかなりよくできていて、さらに日本語翻訳も充実しているので、まず最初にやることをおすすめします。
チュートリアルを終えたら...
本家チュートリアルでも口酸っぱく言われているように
間違いはない、あるのはただ可能性だけ
とあるように完全に自由なのですが、今回は個人的におすすめの楽しみ方を紹介したいと思います。
メロディーを楽しむ
私の音楽理論の知識はほぼ関ジャム完全燃SHOWという番組から仕入れているので、専門家には程遠いですが、
簡単な約束事だけでそれっぽい曲やメロディーに聞こえるパターンがあるのでやってみましょう。
沖縄音階
C E F G B C
ドミファソシド と弾くと沖縄っぽい響きになります。
これを利用して、ランダムにメロディーを決めてやると、沖縄風BGMを作曲することができます。
use_bpm 90
live_loop :melody do
use_synth :pluck
okinawan_scale = [:C4, :E4, :F4, :G, :B, :C5]
play okinawan_scale.choose
sleep 1
play okinawan_scale.choose
sleep 0.5
play okinawan_scale.choose
sleep 0.5
play okinawan_scale.choose
sleep 1
play okinawan_scale.choose
sleep 1
end
live_loop :drums do
drumline
end
define :drumline do
sample :bd_fat, amp: 3
sleep 0.5
sample :drum_cymbal_closed, amp: 0.7
sleep 0.5
sample :bd_fat, amp: 3
sample :sn_zome, amp: 0.7
sleep 0.5
sample :drum_cymbal_closed, amp: 0.7
sleep 0.5
end
リズムを楽しむ
ループが主体のジャンルである、HOUSE, EDM, HipHopはSonic Piと相性がよく、簡単にそれっぽい雰囲気になるので、チュートリアルから次に進むのによいお題だと思います。
さきほど例にあげた、Daft PunkのAround the worldのコードを写経してドラムとベースを抜き出して見ましょう。
先程の演奏動画のコードは
https://github.com/luiscript/SonicPi-Music/blob/master/songs/Daft%20Punk%20-%20Around%20the%20world.rb
にアップされています。
4つ打ち
use_bpm 128
live_loop :base do
funkyBaseline
end
live_loop :drum do
drumline
end
define :funkyBaseline do
with_fx :lpf, cutoff: 80 do
play_pattern_timed [:e2, :d3, :e3, :e2, :d3, :e2, :d3, :e2, :d3, :e3, :c2, :c3, :c2, :c3, :c2, :b1, :b1, :a2, :b2, :g2],
[0.5, 0.5, 0.5, 0.5, 0.5, 0.25, 0.5, 0.25, 0.25, 0.25, 0.5, 0.5, 0.25, 0.25, 0.5, 0.25, 0.5, 0.25, 0.5, 0.5],
release: 0.0 , sustain: 0.3
end
end
# 何かうわものを追加していく
live_loop :melody do
4.times do
play sample :guit_e_slide
sleep 8
end
end
define :drumline do
sample :bd_fat, amp: 3
sleep 0.5
sample :drum_cymbal_closed, amp: 0.7
sleep 0.5
sample :bd_fat, amp: 3
sample :sn_zome, amp: 0.7
sleep 0.5
sample :drum_cymbal_closed, amp: 0.7
sleep 0.5
end
このコードにうわもののシンセを好きに足してあげるだけで、なんちゃって4つ打ちEDMとして遊べます。
余談ですが、EDMのテンポは、コンピュータで扱いやすいからなのか、BPM=128が基本になっていておもしろいです。
HipHop
個人的に一番好きなジャンルです。
アーメンブレイクという一番オーソドックスなビートのループは
use_bpm 90
live_loop :beat do
sample :loop_amen, beat_stretch: 4
sleep 4
end
live_loop :scratch do
sleep 15
2.times do
sample :vinyl_scratch, beat_stretch: 0.5
sleep 0.5
end
end
で簡単に書けるので、これもいろいろと音を重ねていって楽しめます。
あるいは、先程使ったDaft Punkのドラムを改変して、ちょっとHipHopっぽくして見るのも良かったです。
スネアのタイミングを少しずらしていって、心地よいと感じるグルーブ感を探ってみるというのをやってみたのですが、これがなかなかおもしろかったのでおすすめです。
use_bpm 90
live_loop :base do
funkyBaseline
end
live_loop :drum do
drumline
end
define :funkyBaseline do
with_fx :lpf, cutoff: 80 do
play_pattern_timed [:e2, :d3, :e3, :e2, :d3, :e2, :d3, :e2, :d3, :e3, :c2, :c3, :c2, :c3, :c2, :b1, :b1, :a2, :b2, :g2],
[0.5, 0.5, 0.5, 0.5, 0.5, 0.25, 0.5, 0.25, 0.25, 0.25, 0.5, 0.5, 0.25, 0.25, 0.5, 0.25, 0.5, 0.25, 0.5, 0.5],
release: 0.0 , sustain: 0.3
end
end
define :drumline do
laid_back = 0.01
sample :drum_cymbal_closed, amp: 0.7
sample :bd_fat, amp: 4
sleep 0.5
sample :drum_cymbal_closed, amp: 0.7
sleep 0.25
sample :bd_fat, amp: 4
sleep 0.25
sample :drum_cymbal_closed, amp: 0.7
# スネアを少しジャストからずらす
sleep laid_back
sample :sn_zome, amp: 0.7
sleep 0.5 - laid_back
sample :drum_cymbal_closed, amp: 0.7
sleep 0.5
sample :drum_cymbal_closed, amp: 0.7
sleep 0.5
sample :drum_cymbal_closed, amp: 0.7
sleep 0.25
sample :bd_fat, amp: 4
sleep 0.25
sample :drum_cymbal_closed, amp: 0.7
# スネアを少しジャストからずらす
sleep laid_back
sample :sn_zome, amp: 0.7
sleep 0.5 - laid_back
sample :drum_cymbal_closed, amp: 0.7
sleep 0.5
end
まとめ
今回はSonic Piの紹介と、チュートリアル後のおすすめの楽しみ方を紹介しました。
本当に簡単なので、ぜひ実際に鳴らして遊んで見てください!
突撃!隣のキーボード
Hyperion-iOSをもうちょっと便利にする
こんにちは、nirazoです。
Airメイトという飲食店の経営支援サービスのiOSアプリエンジニアをやっています。
iOSエンジニアの皆さん、Hyperion-iOSは使っていますか?
iPhoneの実機やSimulator上でデザインチェックができるという素晴らしいツールです
今年に入って記事も書かれてきて、徐々にその便利さに気付き始めた人が増えてきているかと思います。
機能の説明は公式のReadmeや、以下の記事にお任せするとして、もうちょっと便利にできたら…と思うところがあったのでちょっといじってみました。
今回、2種類の方法でHyperion-iOSをちょっと便利にしてみたのでお付き合いください。
デバッグメニューの出し方変更
Hyperionのデバッグメニューは、画面右端からスワイプするか、シェイクジェスチャー(これはこの記事を書くために調べてて初めて気づきましたw) を行うことで表示できます。
ただ、特に横スクロールを行う画面やUITableViewCellを削除するような画面だと、普通に操作していたのにデバッグメニューが表示されてしまいストレスを感じてしまうことがあるかと思います。
そんなあなたに朗報です!!
デバッグメニューの出し方は簡単に変更できるのです
やり方は非常に簡単で、専用の設定ファイルを作成してプロジェクト内に配備するだけです。
公式のReadmeにも記載があるので、それに則ってやってみましょう
ファイル名、パス
ファイル名はHyperionConfiguration.plist
とします。
配備するディレクトリについては特に指定はありませんが、作成した設定ファイルをBuild PhaseのCopy Bundle Resources
に指定しておくことだけお忘れなく。
ファイルの中身
設定ファイルはこんな感じになっています。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Default</key>
<dict>
<key>Triggers</key>
<array>
<string>ThreeFingerSingleTap</string>
</array>
</dict>
<key>Simulator</key>
<dict>
<key>Triggers</key>
<array>
<string>TwoFingerDoubleTap</string>
</array>
</dict>
<key>Device</key>
<dict>
<key>Triggers</key>
<array>
<string>Shake</string>
</array>
</dict>
</dict>
</plist>
Default
, Simulator
, Device
のそれぞれのキーの中に、デバッグメニューを表示するためのトリガーとなるジェスチャを記載します。
Default
ここに記載したTriggerは実機、シミュレーターの両方で有効になります。
Simulator, Device
これらの配下に記載したTriggerは、それぞれシミュレーター、実機のみで有効になります。
Trigger
デバッグメニューを表示するためのジェスチャを記載します。
指定できるアクションはHYPActivationGestureOptions.hファイルに記述してあります。
typedef NS_OPTIONS(NSUInteger, HYPActivationGestureOptions) {
/**
* Represents a two finger double tap gesture.
*/
HYPActivationGestureTwoFingerDoubleTap = 1 << 0,
/**
* Represents a three finger single tap gesture.
*/
HYPActivationGestureThreeFingerSingleTap = 1 << 1,
/**
* Represents a right edge swipe gesture.
*/
HYPActivationGestureRightEdgeSwipe = 1 << 2,
/**
* Represents a shake gesture.
*/
HYPActivationGestureShake = 1 << 3
};
キー名とコメントのまんまですが、それぞれ2本指ダブルタップ、3本指シングルタップ、画面右端スワイプ、シェイクジェスチャの4種類です。
先程の設定ファイルで、Simulatorでは2本指ダブルタップでデバッグメニューが開くようになりました!
これでストレス無くHyperionが使えますね!
プラグインを作ってみる
Hyperion-iOSは、記事執筆時点では3種類の標準プラグインのみ用意されています(Android版はもう少し多くのプラグインがあります)。
どれも便利なのですが、「こんなプラグインあったら良いな」と考える気持ちもありますね。しかしReadmeには
The plugin creation guide is a work in progress, but if you are feeling ambitious you can reference the plugins we have already created along with our documentation.
とあります。プラグイン開発手順のドキュメントはwork in progress...
いつ開発手順ドキュメントが公開されるのもわからないが、リファレンスはある…OSSなので当然実装も見れる…
作れますね
と、いうわけで簡単なプラグインを作ってみましょう!
今回作るもの
イメージしやすいように、先に作るものを。
ビューをタップするとクラス名を表示するという、シンプルなプラグインです。
開発手順の概要
こちらの記事の中でHyperion-Androidプラグインが開発されていたので、拝読して実装を見てみたところ、iOSもプラグインの構成としてはAndroidと似たような感じだということがわかりました。
- HYPPlugin protocolに準拠したクラスを作成する
- HYPPluginModule protocolに準拠したクラスを作成し、1. で作ったクラス内でinit
- HYPSnapshotInteractionViewのサブクラスを作成し、プラグインの中身を書く
(3. については後述するHYPSnapshotPluginModuleの場合です)
たったこれだけ!何だかいける気がしてきましたね!
ということでやっていきましょう!
なお、今回は既存のプロジェクト内で実装をしています。
Frameworkとして切り出した方が良いとは思いますがご容赦ください
HYPPlugin protocolに準拠したクラスを作成する
早速ソースコードから。
import Foundation
import HyperionCore
class ClassNamePlugin: NSObject, HYPPlugin {
static func createPluginModule(_ pluginExtension: HYPPluginExtension) -> HYPPluginModuleProtocol {
return ClassNamePluginModule(with: pluginExtension)
}
static func pluginVersion() -> String {
return "1.0.0"
}
}
HYPPluginプロトコルの必須メソッドは上記の2つのみです。
とりあえずバージョンは適当に入れています。
NSObjectを継承するのをお忘れなく!
HYPPluginModule protocolに準拠したクラスを作成
こちらもソースから。
import Foundation
import HyperionCore
class ClassNamePluginModule: HYPSnapshotPluginModule {
var currentPluginView: ClassNamePluginInteractiveView?
required init(with ext: HYPPluginExtension) {
super.init(with: ext)
}
override func pluginMenuItemTitle() -> String {
return "Class Name"
}
override func pluginMenuItemImage() -> UIImage {
return UIImage(named: "classNamePluginIcon")!
}
override func activateSnapshotPluginView(withContext context: UIView) {
super.activateSnapshotPluginView(withContext: context)
currentPluginView?.removeFromSuperview()
currentPluginView = ClassNamePluginInteractiveView(with: `extension`)
currentPluginView?.translatesAutoresizingMaskIntoConstraints = false
context.addSubview(currentPluginView!)
currentPluginView?.topAnchor.constraint(equalTo: context.topAnchor).isActive = true
currentPluginView?.leadingAnchor.constraint(equalTo: context.leadingAnchor).isActive = true
currentPluginView?.bottomAnchor.constraint(equalTo: context.bottomAnchor).isActive = true
currentPluginView?.trailingAnchor.constraint(equalTo: context.trailingAnchor).isActive = true
}
}
このクラスの中に、Hyperionのデバッグメニュー上の表示と、該当のプラグインが選択された際の挙動を記述していきます。
pluginMenuItemTitle()
デバッグメニュー上に表示する、プラグインの名前を記述します。
pluginMenuItemImage()
デバッグメニュー上に表示する、プラグインのアイコン画像(UIImage)を指定します。
なお、ここで指定した画像は、表示されるときはグレースケール画像になるのでご注意ください。
activateSnapshotPluginView(withContext context: UIView)
プラグインが選択された際の挙動をここに記述します。
このメソッドは、シンプルなHYPPluginModule
プロトコルではなく、HYPSnapshotPluginModule
のサブクラスが選択された際に呼ばれるものです。
標準プラグインであるAttribute InspectorやMeasurementsのように、デバッグメニューを表示した際のスナップショットを撮っておき、その上にオーバーレイ表示させる際に使用するPluginModuleです。
Slow Animationのようにオーバーレイ表示をさせないプラグインの場合は、HYPPluginModuleとHYPPluginMenuItemDelegateに準拠する必要があるようです。
そちらについては今回実装しませんが、Slow Animationプラグインのソースコードを参照すると良いかと思います。
先程掲載したNewPluginModule.swiftでは、後述のNewPluginInteractionViewをスナップショットのUIViewに乗せる処理を行っています。ここまで、やっていることはかなりシンプルですね!
HYPSnapshotInteractionViewのサブクラスを作成し、プラグインの中身を書く
HYPSnapshotPluginModuleプラグインの場合、やりたいことは主にこちらに記述することになるかと思います。
import Foundation
import HyperionCore
class ClassNamePluginInteractiveView: HYPSnapshotInteractionView {
let highlightView = UIView(frame: .zero)
let classNameView = ClassNamePluginPopupView()
override init(frame: CGRect) {
super.init(frame: .zero)
setup()
}
override init(with ext: HYPPluginExtension?) {
super.init(with: ext)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup() {
backgroundColor = UIColor(red: 50.0/255.0, green: 50.0/255.0, blue: 50.0/255.0, alpha: 0.3)
let tapGr = UITapGestureRecognizer(target: self, action: #selector(viewTapped(_:)))
addGestureRecognizer(tapGr)
highlightView.backgroundColor = UIColor(red: 43.0/255.0, green: 87.0/255.0, blue: 244.0/255.0, alpha: 0.4)
highlightView.isHidden = true
addSubview(highlightView)
classNameView.translatesAutoresizingMaskIntoConstraints = true
classNameView.isHidden = true
addSubview(classNameView)
}
@objc func viewTapped(_ sender: UITapGestureRecognizer) {
guard let ext = `extension` else { return }
let attachedWindow = ext.attachedWindow
let location = sender.location(in: self)
let selectedViews = HYPPluginHelper.findSubviews(in: attachedWindow(), intersecting: location)
print("selectedViews: \(String(describing: selectedViews?.description))")
if let v = selectedViews?.firstObject as? UIView {
viewSelected(v)
}
}
override func interactionViewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator?) {
super.interactionViewWillTransition(to: size, with: coordinator)
`extension`?.snapshotContainer().dismissCurrentPopover()
}
override func interactionViewDidTransition(to size: CGSize) {
super.interactionViewDidTransition(to: size)
}
}
extension ClassNamePluginInteractiveView: HYPViewSelectionDelegate {
func viewSelected(_ selection: UIView!) {
guard let selection = selection, let ext = `extension` else { return }
highlightView.isHidden = !highlightView.isHidden
let f = selection.convert(selection.bounds, to: ext.attachedWindow())
highlightView.frame = f
classNameView.frame = CGRect(x: f.minX, y: f.maxY, width: 300, height: 40)
classNameView.isHidden = !classNameView.isHidden
classNameView.configure(text: NSStringFromClass(type(of: selection)))
}
}
コード内に出てくるClassNamePluginPopupView
は、クラス名表示用のただのUIViewのサブクラスのため説明は割愛します。
大体は普通のUIViewを作るのと同様ですが、いくつかピックアップして説明します。
ビューの検索
viewTapped(_:)
メソッド内、タップされたビュー(正確には、NewPluginInteractionView内のタップされた座標の下にあるオリジナルのビュー)を特定し、そのビューに対しての処理を行う場合、下記の様に行います。
let selectedViews = HYPPluginHelper.findSubviews(in: attachedWindow(), intersecting: location)
if let v = selectedViews?.firstObject as? UIView {
viewSelected(v)
}
HYPPluginHepler
クラスのfindSubviews(in:, intersecting:)
メソッドが、ビューを検索してくれます。
その中から好きなUIViewを取り出して、HYPViewSelectionDelegate
のviewSelected(_:)
を呼びましょう。
ビューに対する処理
ビューの座標を使用して何か処理を行う場合、CGRectの変換を行う必要があります。
convert(_:, to:)
メソッドを使いますが、toの引数にはextension.attachedWindow()
を指定しましょう。
普通に使う分にはselfでも問題無いのですが、HYPSnapshotInteractionViewはピンチイン、アウトでの拡大・縮小が可能で、その状態でビューをタップすると座標がずれます。
extension.attachedWindow()
を指定しておけば、拡大、縮小をしても正しく選択したビューの座標を取得してくれます。
あとは説明を割愛したClassNamePluginPopupViewを実装すれば、先にお見せした動画のプラグインの完成です
割とシンプルに実装できました
ソースコードはこちらに上げているので、ぜひお手元で試してみてください!
おわりに
Hyperion-iOSは最近はコミットが活発でなく、本記事執筆時点での最終コミットは4月(feature/log_overlay_viewブランチ)となっていますが、プラグイン開発手法が公開されればたちまち活発に開発が行われるのではないか期待しています。
今回試しにプラグインを作ってみてわかりましたが、決して難解なものでは無かったので、ひとまず自分用に気軽に作ってみたり、carthageなどで公開したりして少しずつでも広まって欲しいなと思っています。
公式がプラグイン開発手順を公開したら本記事の後半は価値を失う気がしますが、そうなればHyperion界隈がもっと活発になるはずなので私は決して悲しむことなど無いでしょう
それでは、Enjoy your Hyperion life!
KtorのFeaturesの実行Phaseがどう定義されているのかを知る
はじめに
こんにちは。リクルートライフスタイルでAndroidアプリを開発をしている@ykoyanoです。
KtorでCustom Featureを作ろうとした際、そもそもFeaturesの実行されるタイミングがどこで定義されているのかを把握したくてKtorの実装について調べたのですが、今回はそこで得たものをメモ代わりに少し整理して紹介したいと思います。
Ktor
KtorとはJetbrains社製のWebアプリケーションフレームワークです。
KtorはPrinciplesにもあるように、Unopinionated, Asynchronous, Testableを原則とした軽量かつ拡張性の高いフレームワークです1
KtorでどのようにWebアプリケーションが実装できるのか知りたい方は、以下のレポジトリを覗いてみると参考になるかもしれません。
この記事の目的
自分でKtor用に特定の機能を追加するライブラリなどを実装する場合、Custom Featureを実装することが多いと思うのですが、Custom Featureを実装する際には、普段Ktorを使う場合にはあまり意識しないようなPipelineの細かい実装などの知識も必要となる場合があります。
そこで本記事では、Featuresの実行されるタイミングがどう定義されているのかを、PipelinePhaseを通して理解し、Custom Feature実装に役立てたいと思います。
ちなみに、今回は触れる内容は、機能としてはserver, clientに限らないものですが、実際に例として取り上げるコードとはserverのものを中心としています。
Features
Ktorでは、AuthenticationやRoutingなど一般的なWebアプリケーションには必須な機能から、
Velocity TemplatesやWebjars supportといった拡張機能まで、アプリケーションの実行やリクエストの処理のために行う多くの機能が、プラガバブルなFeatureという機能単位で提供されています。
そして、上記の様な具体的なFeaturesの実装を、非同期処理アプリケーションフレームワーク本体の実装から分離することで、Ktorのコア部分の実装を軽量にしています。
ktor-serverでは、アプリケーションに導入するFeatureはApplicationFeatureを実装している必要があります。
interface ApplicationFeature<in TPipeline : Pipeline<*, ApplicationCall>, out TConfiguration : Any, TFeature : Any> {
...
fun install(pipeline: TPipeline, configure: TConfiguration.() -> Unit): TFeature
}
具体的なFeatureの例を見てみましょう。
ktor-serverにはDefaultHeadersというHTTP レスポンスのヘッダーに任意の値を付与することができる比較的シンプルな処理を行うFeatureが存在します。このFeatureをApplicationに導入してみましょう。
fun Application.main() {
/**
* これによって全てのレスポンスに X-Developer ヘッダーが付与される
* Application#install 内では DefaultHeaders#install が呼び出されている
*/
install(DefaultHeaders){
header("X-Developer", "John Doe")
}
}
このDefaultHeadersはどのように実装されているのでしょうか。
class DefaultHeaders(config: Configuration) {
... // DefaultHeadersの実装
// レスポンスヘッダーに値を付与する処理
private fun intercept(call: ApplicationCall) { ... }
companion object Feature : ApplicationFeature<Application, Configuration, DefaultHeaders> {
override val key = AttributeKey<DefaultHeaders>("Default Headers")
// Application#install 内で呼び出されるメソッド
override fun install(pipeline: Application, configure: Configuration.() -> Unit): DefaultHeaders {
... // DefaultHeaders の設定を行う
val feature = DefaultHeaders(config) // 設定を反映させた DefaultHeaders feature
pipeline.intercept(ApplicationCallPipeline.Features) { feature.intercept(call) } // pipelineにinterceptorを与える
return feature
}
}
}
さてここで、 pipeline.intercept(ApplicationCallPipeline.Features){...}
という処理が出てきました。
この呼び出しこそが、このFeatureがどのアプリケーションやリクエストのライフサイクルのどのタイミングにinterceptさせるかを指定している箇所になります。
ではこのFeatureをinterceptさせるには、どのような種類の実行タイミングを指定できるのでしょうか。
Pipeline
pipeline.intercept(ApplicationCallPipeline.Features){...}
にも出てくるPipelineとは、非同期の拡張可能な計算の実行パイプラインを表わすものです。
このPipelineがKtorの拡張性や非同期な動きを支える重要な要素となっています。
Ktorでは、リクエストを受け取ってからレスポンスのを返すまでに行う一連の様々な処理が、Pipeline上の非同期計算として行われます。Featureによって実行される計算もそのひとつです。
ktor-serverで既に定義されているPipelineには例えば以下のようなものがあります。
- ApplicationCallPipeline : リクエストを受け付けてからレスポンスを返すまでのパイプライン
- ApplicationReceivePipeline : 受信したデータを処理するためのパイプライン
- ApplicationSendPipeline : レスポンスを返すためのパイプライン
さらに、これらのPipelineが、パイプライン中での計算の実行順序を決定づけるためのPipelinePhaseという定義しています。
- Setup : Call Phaseに対する準備やAttributesの処理を行う
- Monitoring : Call PhaseをトレースするためのPhase。ロギング、メトリクス集計、エラーハンドリング時に有用なPhase
- Features : 認証を始め、多くのFeatureがこのPhaseにinterceptする
- Call :Routingを行い、レスポンスを返すPhase
- Fallback : 例外ハンドリング時などに呼び出されるPhase
といった5つのPipelinePhaseを持っています。それらは下図のように実行する順序が決められています。
ここでいったん、各PipelineがどんなPipelinePhaseを定義しているのかを整理してみます。
さて、ではここでDefaultHeadersの実装をもう一度見返してみましょう。
DefaultHeadersというFeatureをインストールする際に
pipeline.intercept(ApplicationCallPipeline.Features) {
feature.intercept(call)
}
という処理が呼び出されていました。
これによって
DefaultHeadersというFeatureで実行したい計算処理が
ApplicationCallPipelineのFeaturesというPipelinePhaseで実行されるようになります。
しかしPipelinePhaseの種類が多くてイメージがいまいちわかないので
標準に容易されている各FeatureがどのPipelinePhaseで
実行するようにinterceptされているのかまとめてみました。
(ここに載せられていないものもあります。)
これによって、各PipelinePhaseの使い分けがだいぶハッキリ見えてくるようになったと思います。
例えば自分でCustom Featureを作る場合も、実現したい計算はどのレイヤーのどのフェイズで行うのが適切なのかを判断する参考になりそうです。
まとめ
ざっくばらんではありましたが、Ktorにおいて、リクエストを受け付けてからレスポンスを返すまでの一連の流れに対して、FeaturesがどのようにPipelinePhaseに紐付けられているのか、の全体像を追ってみました。
もしコメントやツッコミなどありましたらぜひぜひお願いします〜!
また、ボリュームが多すぎてこの記事では紹介できなかったのですが
- 任意のPipelinePhaseの前後に、自分で定義した別のPipelinePhaseを追加する
- 異なるPipeline同士でmergeしてで、PipelinePhaseに紐づくinterceptを統合する
- 特定のRoutingのみPipelinePhaseに対してのみ処理をinterceptさせる
- PipelineTestにもあるような、複数のあるいはネストしたPipeline内では処理
- v1.0.1からのstructured concurrencyをサポートによる、複雑なパイプラインの分岐や制御
など、PipelinePhaseに関してはできることはまだまだたくさんあります!
Ktorはまだまだ発展途上でドキュメントや情報が少ないので、そういった情報もまとめられたらよいなと思っております!
ちなみに今回直接はふれなかったktor-clientに関しては、弊社アドベントカレンダーで@oxsoftがKtor on Androidでモックサーバアプリの作り方を紹介していますので、興味がある方はこちらもぜひ読んで見て下さい!
ハイブリッドクラウドについて思うところ
はじめに
この記事は リクルートライフスタイルアドベントカレンダー2018 の17日目です。
ホットペッパービューティーでエンジニアをしています。shaseです。
現在開発中のプロジェクトが、大人の事情によりハイブリッドクラウド構成になっているので、開発をしている中で思ったことをつらつら書いてみたいと思います。(ネットワークエンジニア視点ではなく、開発者視点です。)
プロジェクトでの自分の役割はプロジェクトマネジメント業が主になってしまったので、今回使っているテクノロジの詳細は、誰かが会社のブログに書いてくれると信じて概ね端折ります。
今回はAWS前提の話になります。
TL;DL
- 特別な事情がない限りハイブリッドクラウドはやめましょう。
- 専用線(DX)を使わないことも検討しましょう。
- 現在普及しているテクノロジを用いればそれなりにアジリティの高いオンプレミス(ただし運用コストは支払う必要がある)環境が用意できるはずです。オンプレミスに寄せるか、クラウドに寄せることができないか考えましょう。
構成概要
- 今回は、モノリシックなAPIのリプレイスプロジェクトです。ざっくり下記のようなものをつくっています。
- BackendAPIがオンプレミス側にあり、アグリゲーションするBFFがAWS側にあります。APIの通信はHTTPSです。(BFF/BackendはL7で連携しています。)
- AWS側の会社共通部分を管理するチーム、オンプレ側の共通部分を管理するチームが、自分たち開発チームとは別に存在しています。
ハイブリッドクラウドにするときのコツ
専用線(DX)は使わない、もしくは使ったとしてもアドレッシングやNATには注意
専用線を使わない構成をまず考えましょう。使う場合でも、仕組み上パフォーマンス的にLAN内と同じようなクオリティは期待できない(期待してはいけない)ので、ステートフルなプロトコル(JDBC等)を使う際は特に注意です。
また、専用線を使った場合、ネットワークがオンプレミス側に引きずられます。(IPアドレスの設計等)。クラウド側のメリットを殺す結果にしかならないので、可能であれば専用線を使わない構成を検討しましょう。
オンプレミス側のアドレスが潤沢な場合(あまり経験がないですが...)は、アドレッシングも容易ですが、そうでない場合はNATを使わざる得ないケースも多いかと思います。
仮にNATを使った場合でも、オンプレミス->クラウドの通信がなく、クラウド->オンプレミスだけの通信であれば、クラウドN対オンプレミス1のNATにし、クラウド側のIPアドレス体系の柔軟性を確保するようにしましょう。(オンプレミス->クラウド側の通信は非private通信を検討しましょう)。
今回のプロジェクトでは、当初ネットワークレイテンシを気にして、専用線(DX)を使う構成を準備していました。が、性能試験の結果、使わなくても今回のユースケースであれば問題のない範囲に落ち着きそうということで、使わない方向で最終調整しています。
オンプレミス側/クラウド側で揃えられる部分を揃える
ただでさえ複雑な複雑になりがちなので、なるべく扱う人間のコンテキストスイッチを小さくする努力はしました。
- AWS側でAmazonLinuxを使い、オンプレ側でCentOSを使うように(近いディストリビューションを使う)。
- システムのモニタリングはAWS側/オンプレ側どちらもDatadogで統一。
- ログの取扱は、ホットペッパービューティーのログ集約している外部システムにオンプレミス/クラウド共に集約。
- 分散トレーシングはAWS X-Rayで統一。
などなど
ハイブリッドクラウドのいいところ
部分的にでもクラウドの恩恵が受けることができる
部分的とはいえ、クラウドを使うことができるようになった恩恵はかなり大きいものでした。この1点をもって、部分的にでもプロジェクトでAWSを使ってよかったなと感じています。
- 開発チームが環境などを自由にすぐつくれる。
- CodeBuildなどの開発支援ツールがすぐつかえる。(開発環境コンテナをECRで管理できる等々)
- 全部オンプレミス側で用意するより圧倒的に下がるコスト。
などなど
ハイブリッドクラウドのだめなところ
リリース方式を統一するのが難しい
例えばCodeDeployにはオンプレミス版もあるので、CodeDeployでクラウド側/オンプレミス側のデプロイ方式を統一させることができるのでは、と事前に考えて検証してみたのですが、現実的にそれは難しいと考えました。
- CodeDeployのオンプレミス版ではIAMの管理が非常に煩雑になってしまう。
- リリース作業というのは、それに付随するオペレーション(ロードバランサー配下のものをinactiveにしたり等)が発生するが、それらすべてをCodeDeployのオンプレミス版でコントロールするのは非現実的だった。(AWS側はリソースのコントロールがすべてAPI化されているので大丈夫)。
クラウド側/オンプレミス側それぞれでリリース方式は考えたほうがいいと思います。
障害ポイントが増える
単純に障害ポイントが増えます。問題の切り分けも、オンプレミス側の知識とクラウド側の知識が要求されるので、難易度が上がります。
オンプレミス側のスケーリングが難しい
今回の構成でいうと、Backendのスケーリングが難しいです(あたりまえ)。バックエンドのスケーリングが難しいということは、BFF側だけオートスケーリングするような構成にしてもしょうがないということです。クラウド側の恩恵は部分的なものにならざるを得ません。クラウド側だけ攻めた構成というのも難しいです。
おわりに
ネガティブなことも書きましたが、アジリティの低いオンプレミスしか使えないような環境であれば、ハイブリッドクラウドも十分検討に値すると思います。
ただ、いろいろ注意するべき点は多いので、PoCなどを通して、落とし穴を回避するのは大事だと思いました。
それでは、めりーくりすます!
Kubernetes Operator
この記事は リクルートライフスタイル Advent Calendar 2018 の18日目の記事です。
はじめに
CETチーム 兼 新規サービス開発を担当している @shotat です。
先週シアトルで KubeCon + CloudNativeCon North America 2018 (以下KubeCon) が開催されました。自分も現地シアトルへ向かい、KubeConに参加してきました。なお、KubeConはキューブ(kjúːb
)コンと発音するようです。
KubeConの規模は年々増加傾向にあり、今回は8000人以上のattendeeがいたようで、Kubernetes Communityの盛り上がりを肌で感じることができました。
KubeConではService meshやMachine Learning等様々なテーマが語られていたのですが、中でも CRD(CustomResourceDefinition)・Custom Controller・Operator 等、KubernetesのExtensibilityについての言及が非常に多く、キーノートや様々なセッションで繰り返し登場していたのが印象的でした。
本記事では、 上述の Kubernetes Operator についての概要について簡単にまとめ、実際に動かして見ます。
Kubernetes Operator の概要
Operator とは
Kubernetes OperatorはCoreOSによって2016年に提唱された概念で、以下のブログに詳細が記述されています。
- CoreOS Blog - Introducing Operators: Putting Operational Knowledge into Software
- CoreOS - Kubernetes Operators
Operatorを一言でまとめると、 "アプリケーション特化型のKubernetes Controller" です。
複雑になりがちなStatefulアプリケーションの運用(scaling, backup, failover, upgrade 等)を自動化することがOperatorの役割です。
何故 Operator が必要なのか
StatelessなアプリケーションであればKubernetesのDeployments等である程度十分に実用レベルの運用が可能といえます。
しかし、Statefulなアプリケーションはより複雑な運用タスクが必要であり、アプリケーション毎に最適な運用方法は異なります。
Statefulなアプリケーションを正しくスケーリング・アップデートするにはアプリケーション固有の運用知見が必要になります。
こういったアプリケーション固有の運用を人間の手で行うのは非常に骨が折れるため、運用知見をコード化してKubernetes上の仕組みに乗せてしまおう!という思想がOperatorに繋がります。
仕組み
Kubernetes Control Loop
前提としてまずKubernetesの Control Loop について簡単に復習しておきます。詳細は Kubernetesの公式ドキュメント をご参照下さい。
Kubernetes内ではDesired Stateと実際のCurrent State(Actual State, Observed State表記もあり)を一致・更新させる処理のループを回しており、これをControl Loopと呼びます。
またDesired StateとCurrent Stateの乖離を埋める働きをReconciliationと呼びます。
Desired Stateは kubectl apply ...
コマンド等でKubernetesユーザがAPI経由で直接登録・更新することができ、実際にどうやってCurrent StateをDesired Stateに近づけていくかはKubernetesユーザはほとんど意識しなくて良いインタフェースになっています。
つまり、ユーザはKubernetesに対して宣言的に What
を伝えるだけで良く、 How
の部分(Reconciliation Logic)はKubernetes内部に隠蔽されています。
Operator
Operatorは Custom Resource
と Costom Controller
から成り立ちます。
Custom ResourceはKubernetes上に登録可能な独自リソースであり、上述の What
部に相当します。
Custom Resourceを作成するには CRD (CustomResourceDefinition) という機能を利用します。
CRDに独自のリソース(Kind: MySQL等)とそのスペックを定義してKubernetes上に登録することで、Custom Resourceの利用が可能になります。
一方Custom Controllerは上述の How
に相当し、どのようにCurrent StateとDesired Stateを一致させるか(Reconciliation Logic)を担います。Operatorの文脈ではCustom ResourceをCustom Controllerが扱う対象とします。
つまり、Custom Resourceに定義されたDesired Stateを実現するのがCustom Controllerであり、これらを一括りにOperatorと呼びます。
Operator を動かしてみる
理解を深めるために実際にOperatorを利用し、動作を観察してみます。
KubeConのキーノートではUberが M3DB Operator について紹介していたので、今回はそちらを使ってみようと思います。
なお、M3DBはUberが開発している時系列データベースです。
また、以下のGitHub Repositoryに他にもいくつかのOperatorが公開されています。
GKE クラスタの作成
https://github.com/m3db/m3db-operator のREADMEに記述されている以下の制約に従ってGKEクラスタを立てます。
- Kubernetes 1.10 and 1.11
- Kubernetes clusters with nodes in at least 3 zones
$ gcloud container clusters create m3db-example \
--zone us-central1-a \
--node-locations us-central1-a,us-central1-b,us-central1-c
クラスタが構築できたらkubectlを先程構築したクラスタに向けましょう。
$ gcloud container clusters get-credentials m3db-example --zone us-central1-a --project <PROJECT_NAME>
Fetching cluster endpoint and auth data.
kubeconfig entry generated for m3db-example.
GKEのドキュメント にも記載されているように、RoleBindingを設定が必要です。
$ kubectl create clusterrolebinding cluster-admin-binding --clusterrole=cluster-admin --user=<name@domain.com>
clusterrolebinding.rbac.authorization.k8s.io/cluster-admin-binding created
Operatorのデプロイ
ドキュメントに従い、以下のコマンドでM3DB Operatorをデプロイします。
$ kubectl apply -f https://raw.githubusercontent.com/m3db/m3db-operator/v0.1.1/bundle.yaml
bundle.yaml
の中身を覗いてみます。すると、m3db-operatorは quay.io/m3db/m3db-operator:v0.1.1
というImageを使ったStatefulSetであるということが分かります。
また、必ずしもStatefulSetを使う必要はなく、他のOperatorの例を見ると普通のDeploymentだったりします。
# ...中略
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: m3db-operator
namespace: default
spec:
serviceName: m3db-operator
replicas: 1
selector:
matchLabels:
name: m3db-operator
template:
metadata:
labels:
name: m3db-operator
spec:
containers:
- name: m3db-operator
image: quay.io/m3db/m3db-operator:v0.1.1
command:
- m3db-operator
imagePullPolicy: Always
env:
- name: ENVIRONMENT
value: production
serviceAccount: m3db-operator
この時点でOperatorのログを確認してみると、CustomResourceDefinitionが生成されていることが分かります。
$ kubectl logs pod/m3db-operator-0 | grep CRD
{"level":"error","ts":1544814329.1473405,"caller":"k8sops/crd.go:67","msg":"could not get CRD","error":"customresourcedefinitions.apiextensions.k8s.io \"m3dbclusters.operator.m3db.io\" not found","stacktrace":"github.com/m3db/m3db-operator/pkg/k8sops.(*k8sops).GetCRD\n\t/go/src/github.com/m3db/m3db-operator/pkg/k8sops/crd.go:67\ngithub.com/m3db/m3db-operator/pkg/k8sops.(*k8sops).CreateCRD\n\t/go/src/github.com/m3db/m3db-operator/pkg/k8sops/crd.go:75\ngithub.com/m3db/m3db-operator/pkg/controller.(*Controller).Init\n\t/go/src/github.com/m3db/m3db-operator/pkg/controller/controller.go:223\nmain.main\n\t/go/src/github.com/m3db/m3db-operator/cmd/m3db-operator/main.go:187\nruntime.main\n\t/usr/local/go/src/runtime/proc.go:198"}
{"level":"info","ts":1544814329.6673043,"caller":"k8sops/crd.go:113","msg":"CRD created"}
kubectlコマンドでCRDが生成されていることも確認してみます。
$ kubectl get crd
NAME CREATED AT
m3dbclusters.operator.m3db.io 2018-12-14T19:05:29Z
$ kubectl describe crd m3dbclusters.operator.m3db.io
Name: m3dbclusters.operator.m3db.io
Namespace:
Labels: <none>
Annotations: <none>
API Version: apiextensions.k8s.io/v1beta1
Kind: CustomResourceDefinition
Metadata:
Creation Timestamp: 2018-12-14T19:05:29Z
Generation: 1
Resource Version: 3077
Self Link: /apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/m3dbclusters.operator.m3db.io
UID: 38d89036-ffd3-11e8-9a7e-42010a800140
Spec:
Group: operator.m3db.io
Names:
Kind: M3DBCluster
List Kind: M3DBClusterList
Plural: m3dbclusters
Singular: m3dbcluster
Scope: Namespaced
Version: v1alpha1
Status:
Accepted Names:
Kind: M3DBCluster
List Kind: M3DBClusterList
Plural: m3dbclusters
Singular: m3dbcluster
Conditions:
Last Transition Time: 2018-12-14T19:05:29Z
Message: no conflicts found
Reason: NoConflicts
Status: True
Type: NamesAccepted
Last Transition Time: 2018-12-14T19:05:29Z
Message: the initial names have been accepted
Reason: InitialNamesAccepted
Status: True
Type: Established
Events: <none>
このCustomResourceDefinitionでは M3DBCluster
というCustom Resourceが定義されていることが分かります。
M3DB Operator(Controller)はこの M3DBCluster リソースを扱うことになります。
M3DB クラスタの構築
M3DBのクラスタを構築してみます。
M3DBはクラスタトポロジやメタデータを etcd に保存するため、まずはetcdを構築します。
$ kubectl apply -f https://raw.githubusercontent.com/m3db/m3db-operator/v0.1.1/example/etcd/etcd-basic.yaml
CustomResourceDefinitionに従い、M3DBCluster リソースを作成してみましょう。
以下のファイルを m3db-cluster.yaml
として保存し、applyします。
apiVersion: operator.m3db.io/v1alpha1
kind: M3DBCluster
metadata:
name: simple-cluster
spec:
image: quay.io/m3db/m3dbnode:latest
replicationFactor: 3
numberOfShards: 256
isolationGroups:
- name: us-central1-a
numInstances: 1
- name: us-central1-b
numInstances: 1
- name: us-central1-c
numInstances: 1
podIdentityConfig:
sources:
- PodUID
namespaces:
- name: metrics-10s:2d
preset: 10s:2d
$ kubectl apply -f m3db-cluster.yaml
m3dbcluster.operator.m3db.io/simple-cluster created
上記のmanifestをapplyして少し待つとKubernetes上にM3DBが構築されます。
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
etcd-0 1/1 Running 0 ...
etcd-1 1/1 Running 0 ...
etcd-2 1/1 Running 0 ...
m3db-operator-0 1/1 Running 0 ...
simple-cluster-rep0-0 1/1 Running 0 ...
simple-cluster-rep1-0 1/1 Running 0 ...
simple-cluster-rep2-0 1/1 Running 0 ...
$ kubectl get m3dbcluster
NAME CREATED AT
simple-cluster ...
無事Operatorを使ってクラスタを立てることができました。
Operator を作成する
今まで、OperatorがCustom ControllerとCustom Resourceによって動作することを確認してきましたが、実際にはどうやってOperatorを作成すれば良いのでしょうか。Custom ControllerにはKubernetes APIを利用してReconciliation Logicを記述する必要がありますが、フルスクラッチで処理を書くのは相当厳しいと思います。
Operator Framework
そこで、Operator Frameworkの利用が可能です。
中でもOperator SDKを使うことで、Kubernetes APIの詳細を知らずともCRDの生成やReconsilication Logicの実装に集中できるようになります(Golangがサポートされています)。
Operator Frameworkを利用することでKubernetes APIを詳細に理解せずともReconcile ロジックの実装に集中できるようになります。
(実際にOperator Frameworkで何かOperatorを作る...というところまでやりたかったのですが、それはまた別の機会に。)
まとめ
本記事ではKubernetes Operatorを紹介しました。
今後はメジャーなStatefulアプリケーション運用のベストプラクティスを吸収したOperatorがOSSとして急速に発展していくのではないかと期待しています。
Google Colaboratory と Altair で描く君の知らないグラフの物語
この記事はリクルートライフスタイル Advent Calendar 2018の19日目の記事です。
はじめに
はじめまして。社内で分析基盤を構築している@_snow_narcissusです。
リクルートライフスタイルではデータ分析を用いた施策提案を積極的に行なっており、誰もが簡単にデータを扱える環境を整えています。
現在社内でのデータの可視化の際にはTableauを用いる場面が多いですが、ライセンスの問題から扱うことが困難な場面があります。
安価で簡単に、そして社内での情報共有がしやすいデータ可視化ツールを探していたところ、Google Colabratoryという素晴らしいツールに巡り会うことができました。
弊社ではGoogleスプレッドシート、Googleスライドを始めとするG Suiteを積極的に活用しており、Colaboratoryはこれらと同様にデータビジュアルを簡単に社内に共有することができます。
ちなみにこのColaboratoryですが、なんと無料です。その他にも「簡単にPython環境が手に入る」「BigQueryに簡単に接続できる」「GPUも無料で使える」「TensorFlowが事前インストールされている」といった特性をもつ機械学習入門に最適なツールになっております。
Colabratoryを用いた機械学習のお話は後々の投稿にて紹介するとし、今回はデータの可視化に焦点を当ててお話をさせて頂きたいと思います。
Colaboratoryを使ってみよう
それでは実際にColaboratoryを使ってみましょう。Googleアカウントがあれば誰でも使用することができます。
上図のようにGoogle Driveの「新規」ボタンから「その他」→「Colaboratory」の順に辿ってみてください。しかし、おそらく初期段階では「Colaboratory」の項目がないかと思いますので、その場合には「アプリを追加」から「Colaboratory」を検索し追加をしてください。
Colaboratoryを起動しますと、上記のようにJupyter Notebookによく似た画面が立ち上がり、この中でPythonを書くことができます。
データ分析に必要なパッケージはほぼ事前インストールされているのでimport pandas
import tensorflow
といったimport文は初めから使用することができます。
その他Colaboratoryの環境については【秒速で無料GPUを使う】深層学習実践Tips on Colaboratoryが詳しいので是非ご参照ください。@tomo_makesさん、いつもありがとうございます!
グラフ可視化ツールAltairとは
AltairとはVegaおよびVega-Liteを基にしたPython可視化ライブラリです。個人的には星の名前なのが気に入っています。
VegaおよびVega-Liteは、JSON形式でデータとプロットの形式を指定するとWebブラウザ上でインタラクティブにグラフが描画できるJavaScriptライブラリであり、AltairはこれのPythonバインディングにあたります。
Example Galleryを見ていただければ分かるように、色合いも豊かで様々なグラフを単純な構文で描くことができます。また、描画したグラフをそのままSVGおよびPNGで保存することもできます。
そして重要なのがColaboratoryではAltairを初期設定なしに使うことができます。これに関しては後の節で詳しくご説明したいと思います。
実際にグラフを描いてみよう
Colaboratory上でAltairを用いたグラフを描画してみます。
上図のボタンをクリックし、開いたメニューの中から「コードスニペット」を選択し、フィルターにて「Altair」と絞るとAltairに関連したスニペットの一覧が表示されます。
このうちから一つを選択し矢印ボタンを押すと、下図のようにコマンドが自動的に書き込まれますので、後はそれを実行するだけで簡単にサンプルのグラフを得ることができます。
他にも多数スニペットが用意されておりますので、是非逐一試してみて下さい。Altairが描く綺麗なグラフを見ることができるかと思います。
また、先に触れたように描画されたグラフを簡単に保存することができます。
上図のようにグラフの右上に表示されるボタンをクリックすることで、保存形式を指定してのダウンロードが可能です。
Altairで任意のデータを描画してみよう
もちろん実際の作業ではサンプルではなく、実際にどうグラフを描くかが大切になってきますので、その部分についてもご紹介します。
Altairではデータの指定として、Pandas DataFrame
CSV
JSON
の3つを主に選ぶことができます。CSV
JSON
については、pandas.read_csv等で Pandas DataFrame
に簡単に変換することができるので、ここでは Pandas DataFrame
による描画についてご紹介します。
ここでは一般的によく用いられる株価の情報を図示してみます。是非下記のコマンドをColaboratory上で実行しグラフを描いてみて下さい。
import pandas as pd
stock_price = pd.DataFrame(
{'Date': ['2007-10-01', '2007-11-01', '2007-12-01',
'2007-10-01', '2007-11-01', '2007-12-01',
'2007-10-01', '2007-11-01', '2007-12-01'],
'company': ['AAPL', 'AAPL', 'AAPL',
'AMZN', 'AMZN', 'AMZN',
'GOOG', 'GOOG', 'GOOG'],
'price': [189.95, 182.22, 198.08,
89.15, 90.56, 92.64,
707.00, 693.00, 691.48]})
print(stock_price)
上のようにpandas
をimportし、Pandas DataFrame
を作成します。
Date company price
0 2007-10-01 AAPL 189.95
1 2007-11-01 AAPL 182.22
2 2007-12-01 AAPL 198.08
3 2007-10-01 AMZN 89.15
4 2007-11-01 AMZN 90.56
5 2007-12-01 AMZN 92.64
6 2007-10-01 GOOG 707.00
7 2007-11-01 GOOG 693.00
8 2007-12-01 GOOG 691.48
後はこのデータを用いてAltairで描画するだけです。mark_line()
が描きたいグラフの種類を表し、ここでは折れ線グラフを使います。また、ここで列の指定の後に続く T
Q
N
はそれぞれ temporal
quantitative
nominal
の略で、時間データ、量的データ、カテゴリデータであることを表します。
import altair as alt
alt.Chart(stock_price).mark_line().encode(
x='Date:T',
y='price:Q',
color='company:N'
)
上記コマンドをColaboratory上で実行すると、下図のようなグラフが得られます。
他にも色々な素敵なグラフが得られますので、是非Example Galleryをみながら、Colaboratory上で色々と試してみて下さい。
おわりに
一般にデータを可視化する際には下記のような流れを辿ることが多いのですが、Colaboratoryには全てが用意されております。
- データソースをBigQueryやCSVファイルからインポートしデータフレームに変換
- データフレーム上で集計やNULL値埋めといった処理を行う
- 処理を施したデータを可視化ツールを用いた描画
- 可視化した内容をコミュニティに共有しフィードバックをもらう
「Pythonで分析を始めてみたいのだけど何から手をつければいいのか分からない」という人にとって最適なツールなのではないかと思います。
データ可視化、および機械学習に是非Colaboratoryを積極的に活用してみて下さい。
スマホ向けサイトをアプリっぽく見せるために半年間頑張ったことをまとめる
この記事は リクルートライフスタイル Advent Calendar2018の20日目の投稿です。
はじめに
この記事では スマホ向け web ページをアプリっぽく見せるための Tips を多く紹介します。
(CSS / JS / jQuery / React / WebGL の事例を紹介します)
(注) React 環境でのサンプルコードが多めですが、実装方法はどの環境でも変わらないと思います。ライブラリも同種のものが存在しているはずです。
最近だと、僕の大好きなアプリで味わった体験を、どうすれば Web で再現できるかなーって考えていました。そうしたネイティブアプリをWebで模倣したときに、知ったTipsやテクニックをまとめていきます。
この記事に書いてあること
- アプリっぽい体験はどのようなものがあるか
- CSS / JS / jQuery / React / GLSL を利用したネイティブアプリっぽさを出す小技
- その小技を実現するためのライブラリ(React 系統がメイン)
ネイティブアプリっぽいって何だろう
WebViewやPWAに関する仕事をするときは、アプリっぽさの追求をしています。
ただ、正直なところ、「ネイティブアプリっぽいとは、どういうものか」に対する明確な答えは持てていません。
とはいえ、突然レイアウトが変わるなどの、カクついた動きに対して 「web っぽい」、なめらかな動きに対しては「ネイティブっぽい」と感じるように思います。
そこで、どうやればカクついた動きを Web ページから取り除けるのか、どうすればなめらかさを足せるのかを考えながら、色々なアプリの挙動を真似たりしていました。そのときに使ってみたライブラリや、取り組みを以下にまとめます。
目次
- ページから Web 特有のカクツキを無くそう
- ページになめらかさを足そう
- ブラウザの機能やパーツを隠そう(要注意)
ページから Web 特有のカクツキを無くそう
ページ遷移時の transition について
web 上でページを遷移すると、ページ自体が切り替わるため、画面が真っ白になり一瞬だけちらつきます。一方で、ネイティブアプリではそのような遷移の際に、横から画面がプッシュされるなどのトランジションアニメーションが標準で用意されており、ちらつきを軽減させることができます。こういった動きは Web ではどう実現すればいいでしょうか。
jQuery プラグインでトランジション
jQuery に、animsitionという個人的にめちゃくちゃ気に入っているライブラリがあります。
SPA 実装じゃない遷移でもウェブっぽさがない感じを出してくれていい感じに動いてくれます。
React & CSS で Transition
トランジション 自体は CSS Animation で実現できます。
React で トランジション を行うには, componentDidMount
, componentWillUnMount
時に トランジション 用の CSS を適用させます。
function App() {
const [isAnimated, setAnimation] = useState(false);
useEffect(() => {
setAnimation(true);
});
return (
<div className="wrapper">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
<style jsx>{`
.wrapper {
color: ${isAnimated ? "white" : "black"};
background-color: ${isAnimated ? "black" : "white"};
transition: all 0.5s;
opacity: ${isAnimated ? 1 : 0};
width: 100w;
height: 100vh;
}
`}</style>
</div>
);
}
react transition group
実務で React
を利用するとなると, なんらかの routing ライブラリを利用すると思います。
大規模な開発になると、アニメーションの設定を毎回 Component に書くのはめんどくさいので、 routing ライブラリでフックしてしまいましょう。
react-router
を利用して入れば、react-transition-group
を利用すると便利です。
これはラップしたコンポーネントがマウントされた時、もしくはアンマウントされたときに、${transitionName}-enter
, ${transitionName}-leave
などのクラス名をつけてくれるコンポーネントを提供しています。これを Router 直下のコンポーネントにラップすれば、遷移するたびにクラスをつけることができます。あとはそのクラスに対応したトランジション用の css
を用意しておけば、自動的ににトランジションアニメーションが走ります
.fade-enter {
opacity: 0.5;
}
.fade-enter-active {
opacity: 1;
transition: opacity 800ms linear;
}
...
<TransitionGroup>
<CSSTransition key={currentkey} classNames="fade" timeout={800}>
<Switch>
<Route path="/" component={Articles} exact />
<Route path="/camera" component={Camera} exact />
<Route path="/camera_roll" component={CameraRoll} exact />
</Switch>
</CSSTransition>
</TransitionGroup>;
モバイル Web 特有のトランジションにおける注意点
モバイルにおける横スワイプでのブラウザバックは、コンポーネントの unMount 時のアニメーションが走った後に、再度 componentDidMount
時のアニメーションが走り、壊れた感じになってしまいます。
それに横スワイプ自体がプッシュバックしているかのような見え方になります。そのため、僕はモバイルから見られるアプリケーションを開発する際は、ブラウザバックされる際のアニメーションはつけていません。モーダルを閉じるといったような、ブラウザバックされない前提の処理にのみアニメーションをつけています。
もし、横スワイプ自体を封じた上で、ブラウザバックでのアニメーションをつけるのであれば、スクロール位置やスクロール量を計算して、横スワイプによるバックを監視すると可能です。横スワイプの監視には、HammerJSが便利です。もちろん、React用のバインディングも存在しています。react-hammerjs
ページの部分更新を行う
カクつきが生まれてしまう原因はページのロードにもあります。コンテンツの切り替えの瞬間に一瞬だけホワイトアウトしたり、コンテンツのロード後にレイアウトが変わるためです。それらはどうすれば避けられるでしょうか。
ページごとの loading を避ける + loader を表出する
ページが切り替わるたびにそのページ自体をロードすると、一瞬画面が真っ白になり、かくついた印象を与えてしまいます。この問題は Ajax
を利用して、コンテンツだけロードすれば解決できます。 また loading
中は loader
を出すことで、ユーザーのコンテンツ要求に対していきなりコンテンツが表出されることを防げます。これは送信中に loading
フラグを立て、 コンテンツが帰ってくると loading
フラグ を消す、そのフラグでloader
を出し分けるとすれば実現できます。僕は、loader
には spinkitをよく使います。
loader としてスケルトンビューを利用する
Ajax で部分的に更新しても、コンテンツを差し込んだだけだとレイアウトがガタっと変わったりして、カクカク感が生まれてしまいます。どうすればなくせるでしょうか。スケルトンビューを使ってみましょう。
スケルトンビューはコンテンツのレイアウトに合わせて表示される、コンテンツの外形です。コンテンツがローディング中でも表示させることができるので、ローディングとしても使えます。ローディングは先にコンテンツが表示される領域の外形だけ作っておけば、コンテンツが差し込まれた時の、レイアウト変更がないので、滑らかさを出せます。 1
react-loading-skeletonを利用すると比較的簡単に実装できます。
プルしてリフレッシュ
Twitter のようなアプリでは、コンテンツを再読み込みするために、ページを下に引っ張ることで更新できます。
ページ自体の更新ではなくコンテンツ単位で更新する方法として、感覚にもあっており、自然なコンテンツ読み込みを実現でき、カクツキ感は感じません。
これを Web 上実装するのであれば、 scroll
時にコンテンツの top
位置を監視し、ある閾値を超えたら fetch action
を発火させ loading flg
を立てるなどして、実装するという方法があります。
とはいえ、要素の位置を監視するのは DOM への依存コードの管理を自分で行う必要があり、ぼくは苦手なので、便利なライブラリ react-pull-to-refresh に任せてしまっています。
<ReactPullToRefresh
onRefresh={handleRefresh}
className="your-own-class-if-you-want"
style={{
textAlign: "center"
}}
>
<ListItem.Article />
<ListItem.Article />
<ListItem.Article />
<ListItem.Article />
<ListItem.Article />
</ReactPullToRefresh>
このライブラリは, pull した領域に
<div className="loading">
<span className="loading-ptr-1"></span>
<span className="loading-ptr-2"></span>
<span className="loading-ptr-3"></span>
</div>
を挟んでくれます。そのため、ローダーを出したい場合はこれらの要素に loader 用の CSS を当ててあげましょう
たとえば公式サンプルでは「・・・」がアニメーションする loader が作られています。
無限スクロール
loading
の方法として、無限スクロールという手段もあります。
無限スクロールとは、一定量スクロールしたらコンテンツが継ぎ足されていくローディング方法を指しています。
とくにフィードやタイムラインを扱うアプリを利用していると、見覚えがあるのではないでしょうか。このローディングの方法は、ページネーションや画面全体更新がないので、カクツキ感を無くすことができます。
ネイティブアプリ特有の手法ではなく Web 上 でも比較的この体験をすることができます。たとえばjQuery
+ waypointプラグイン を使った実装は昔からよくある手法として知られています。waypoint
自体は画面の指定位置に要素が入ったことを知らせてくれる役割を持つライブラリであり、これの React 版のreact-waypointも存在します。ぼくは無限スクロールの実装には、このreact-waypoint
使っていました。
import Waypoint from 'react-waypoint';
_loadMoreTodo = () => {
// this.state.todosを増やしていく処理
}
...
return <div className="todos">
{todos.map(todo => <Todo todo={todo} />)}
<Waypoint onEnter={() => this._loadMoreTodo()} />
</div>
ページになめらかさを足す
UI をなめらかな感じにすると、アプリ感がでてきます(情報量0の日本語ですいません・・・)。さらに、UI に対してだけでなく、ユーザーの視線や思考に対してもなめらかさを意識してあげると良いでしょう。つまり、なめらかさやネイティブアプリっぽさを出すためには、ユーザーの操作一つ一つが一連の動きとして繋げられるように考慮されているインターフェイスを作る必要があると思います。ブラウザの制限の上でどこまで追求できるでしょうか。頑張ってみました。
横スクロールの動きをできるようにしてあげる
一般的に web はページで提供されることが多く、ユーザーには、縦のスクロールを要請します。
縦スクロールだけだとユーザーの動きを一方向に制限してしまっており、横スクロールを採用することで、もっとすっとコンテンツを探せることもできそうです。そこで web でも横スクロールを積極的に取り入れていく方法を考えてみましょう。
overflow-x:scroll を利用する
overflow-x
は画面からはみ出た要素 x 軸方向をどう扱うかを扱うことができ、ここでscroll
を利用することで、
横スクロールさせることができます。
.wrapper {
width: 90%;
display: flex;
overflow-y: scroll;
}
.wrapper::-webkit-scrollbar {
display: none;
}
.card {
background-color: red;
width: 100px;
height: 50px;
flex-shrink: 0;
margin-right: 8px;
}
これは比較的簡単な実装方法ですが、注意点として、親が flexbox の場合は子供にflex-shrink: 0;
を持たせないと、スクロール領域外にはみ出してくれません。あと、スクロールバーが出てしまうと Web っぽくなるので可能であれば消してしまいましょう(要注意:PC からアクセスされてしまうと、アクセシビリティは下がってしまいます)。
また、スクロールの強さの取得や、スライドを 1 つずつ固定するといったことは難しいです。そこでライブラリの利用を考えて見ましょう。
swiper を利用する
そこで、Swiper
の出番です。
Swiperは横スクロールを支援するライブラリで、スクロールイベントの取得やフォーカスされている要素を取得できます。
Swiper.on('hoge')
でさまざまなイベントをハンドルでき、その中でも setTranslate
はスワイパブルなアイテムが移動した際に発火し、その位置を取得できるため、移動量を計算できます。
swiper.on('setTranslate', function onSliderMove() {
console.log(this.translate);
});
FYI: https://stackoverflow.com/questions/48375955/swiper-how-to-get-translatex-real-time
また React 上で利用できるreact-id-swipeなんてものもあります。
swipable なタブ
ニュース系アプリには横スワイプがあります。これを Web で実装するにはどうしたらいいでしょうか。
検討段階では Swiper を利用してコンテンツごとスワイプさせようと考えていました。。
しかし、実装コストの兼ね合いからreact-swipeable-viewsを利用しました。めちゃくちゃ楽でした。
<SwipeableViews>
<ViewA />
<ViewB />
<ViewC />
</SwipeableViews>
実際のニュースアプリでは、スワイパブルなビューの上に Tab が付いていると思います。
そのタブをクリックしたらビューがスワイプ、ビューをスワイプしたらタブも切り替わるという挙動になります。
それも作りましょう。
には onChangeIndex
という関数があり、ここでどのタブに切り替えられたかを取得できるので、どこを切り替えたのかを state に保存しましょう。そして、そのタブが state に応じて切り替わるようになって入ればそれで上の要件は満たせます。(これはあらかじめタブを自作するか、タブライブラリを入れておく必要が有)
modal / action sheet
iOS 開発においては JS でいう alert がデフォルトでそこそこかっこいいです。 (https://qiita.com/Simmon/items/319b738e2a667d6a6b3d)
これは、actionsheet や modal として利用することができ、アニメーションもついておりなかなかずるいくらいにかっこいいです。
負けていられません、web 実装するために自前実装です。
半透明な view を fixed する
モーダルは画面全体を覆う、半透明な背景を用意し、その上にコンテンツの描画領域を乗せれば作れます。ここでポイントは、コンテンツのアニメーションです。モーダルを表出するだけでは、画面の表示非表示をきりかえるだけでちらつき感が出てしまいます。なめらか感のために、コンテンツは何かアニメーションをつけてあげましょう。
import React, { useState, useEffect } from "react";
const Modal = (props) => {
const [isAnimated, setAnimation] = useState(false);
useEffect(() => {
setAnimation(true);
});
return (
<div className="wrapper">
背景背景背景背景背景背景背景背景背景背景背景背景背景背景背景背景背景背景背景背景
<div className="innter" /> {/* componentとしてexportするなら, {children}になる */}
<style jsx>{`
.wrapper {
background-color: rgb(0, 0, 0, 0.7);
width: 100vw;
height: 100vh;
position: fixed;
}
.innter {
background-color: white;
transition: all 1s;
position: absolute;
width: 80%;
height: 80%;
bottom: ${isAnimated ? "10%" : 0};
left: 10%;
}
`}</style>
</div>
);
}
action sheet を作るには
先のモーダルコンポーネントを応用して、選択肢を 2 つ用意するだけですね! (これは古いコードそのまま持ってきたので、s-c 実装です)
return (
<BackGround>
<Container>
<TitleWrapper>
<Text.SmallText>編集中です。削除しますか?</Text.SmallText>
</TitleWrapper>
<Choice onClick={onProceed}>
<Text.Default color={COLOR.danger}>編集を削除する</Text.Default>
</Choice>
<Choice onClick={onCancel}>
<Text.Default>戻る</Text.Default>
</Choice>
</Container>
</BackGround>);
const BackGround = styled.div`
position: fixed;
width: 100%;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
`;
const Show = keyframes`
0% {
bottom:0%;
}
100% {
bottom:5%;
}
`;
const Container = styled.div`
position: absolute;
width: 90%;
height: 25%;
bottom: 5%;
left: 5%;
background-color: rgba(255, 255, 255, 0.9);
z-index: 1;
border-radius: 16px;
display: flex;
flex-direction: column;
animation: ${Show} 0.2s linear;
`;
const TitleWrapper = styled.div`
width: 100%;
height: 20%;
display: flex;
align-items: center;
justify-content: center;
border-bottom: solid 1px ${COLOR.placeholder};
`;
const Choice = styled.div`
width: 100%;
height: 40%;
display: flex;
align-items: center;
justify-content: center;
:not(:last-child) {
border-bottom: solid 1px ${COLOR.placeholder};
}
`;
スプラッシュ画面
スプラッシュ画面とは、アプリを起動したときに表示される画面です(例えばアイコンやタイトルなど)。一般的にバックグラウンドで起動していないときに起動すると表示されます。
これを web で原始的に実装しようとすれば、スプラッシュ用の画面とルーティングを用意し、ユーザーの来訪頻度に合わせて出す方法をとると思います。
localStorage で来訪頻度を管理してスプラッシュを表示する
この手法で開発したときはredux
を利用しました。redux
には store
の情報をそのまま localStorage
に保存してくれるミドルウェアライブラリ redux-persist
があります。これを導入するとアプリ起動時に REHYDRATE
するアクションが走るので, そのアクションを reducer
や saga
で監視し、前回表出させた期間と現在の時間の差分に応じて、 SHOW_SPLASH
のようなアクションを発火し、 スプラッシュ画面を表出しています。 表出後はその表出した日時を store
に保存し, redux-persist
を使って永続化させています。
icon を設定する(PWA 限定)
もし、 PWA 化している場合は icon
をmanifest.json
の設定をすることでスプラッシュ画面を出すことができます。
さらに同時に背景色を指定すると、スプラッシュが表示されるまでの間に、画面にその色を先に出せます。
背景色にスプラッシュイメージのメインカラーを指定しておくと、スプラッシュ画面への遷移もなめらかになります。
{
"short_name": "fs",
"name": "fullscreen splash",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "icon.png",
"type": "image/png",
"sizes": "96x96"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#000000"
}
モバイル safari に対するスプラッシュ
とはいえ、実は上の方法では mobile safari だとえません。その場合は HTML
の header
に
<link rel="apple-touch-startup-image" href="path/to/image">
を追加してください。しかしこれでも、各 iOS 端末の画像サイズに合わせて登録しないと、読み込んでくれません。その結果
<link rel="apple-touch-startup-image" href="path/to/image640x1136.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="path/to/image750x1334.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="path/to/image1242x2208.png" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="path/to/image1125x2436.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
...
と header 部が肥大化してしまいます。そこで、Google が用意しているPWACompat
という仕組みをを利用しましょう。これでモバイル Safari における煩雑なアイコン登録が勝手にしてもらえます。これは、script
タグを 1 つ挟むと、manifest.json
を完全に解釈してくれないブラウザに対しても、自動で関係する link
タグを挿入してくれるものです。
FYI: https://github.com/GoogleChromeLabs/pwacompat
ユーザーの操作に反応してUI上にエフェクトをかけてあげる
CSS in JS で動的に CSS の値を変える
CSS in JS
ライブラリを利用してCSS
を書いている場合、CSS
にJS
から変数を渡すことができます。
そうすることでユーザーの挙動や経過時間などに比例してスタイルを変えることができます。
これはユーザーに対してフィードバックを与えたり、なめらかさを演出を作れたり、アプリっぽさを出すためには、とても重宝します。
しかし CSS 自体はなかなか理解が難しく、また人間が扱える範囲も限られています。(意:人の力ではCSS
の力は完全には引き出せない)
そこで、CSS の限界を超えたエフェクトやフィードバックを作りたければ、canvas
の利用が視野に入ってきます。
動的に canvas を動かす
JS
にはcanvas
要素を扱うためのライブラリがあります。
2D を扱いたいなら Pixijs
PixiJS は 2D のグラフィックを作ることが得意なライブラリです。
最近だと、 アイドルマスターのブラウザゲームで採用されたことにより、盛り上がりがあったかと思います。
公式のサンプルを適当に動かすだけでもそれなりの、なめらか感を出すことができてオススメです。僕はサンプルをコピペする以上のことは知らないです。(それでも十分に動いてくれます)
3D を扱いたいなら Three.js
Three.js は 3D のグラフィックを作ることができるライブラリです。3D なので、奥行きがあります。
公式のサンプルがとても充実しています。こちらも、公式のサンプルを適当に動かすだけでもそれなりの、なめらか感を出すことができます。これも公式サンプルをコピペする以上のことは知らないです。(それでも十分に動いてくれます)
プリミティブに行くなら WebGL
canvas
を操作する方法としてシェーダーを書くという手段もあります。ブラウザは WebGL
を利用できるのでGLSL
で書いたシェーダープログラムを実行できます。そして React
の props
を canvas
に流すと、アプリケーションの状態に応じて動作する WebGL
コンテンツを開発することができ、リッチなフィードバックを実現できます。しかし WebGL を React から動かすためには、props
を canvas
に渡す方法を考えなければならず、そのマッピングを自前で頑張るか、マップしてくれるなんらかのライブラリを利用します。
ライブラリで行うには gl-reactがオススメです。
これは GLSL
を実行するコンポーネントを提供してくれます。さらにそのコンポーネントに流したuniforms
というprops
にはshader
プログラムの中で利用できる値を詰められます。
const shaders = Shaders.create({
helloGL: {
frag: GLSL`
precision highp float;
varying vec2 uv;
uniform float blue, red;
void main() {
gl_FragColor = vec4(clamp(uv.x, 0.2, 0.7), clamp(uv.x, 0.3, red), clamp(blue, 0.3,0.8), 1.0);
}`
}
});
class HelloGL extends React.Component {
render() {
const { blue } = this.props;
return (
<Node shader={shaders.helloGL} uniforms={{ blue:value, red: value / 2 }} />
);
}
}
...
return (
<Surface width={window.innerWidth} height={350}>
<HelloGL value={value} />
</Surface>
);
ブラウザの機能やパーツを隠そう(要注意)
⚠️ アクセシビリティは犠牲になります。あなたのアプリケーションが、アクセシビリティを考慮しないといけない場合は利用しないでください。⚠️
user に選択させない
フルルクリーンモードや WebView 内でコンテンツを提供した時、ネイティブっぽさを出すことができます。
しかしユーザーが画像やテキストを長押しするとヘルパーが立ち上がるので、「あ、ブラウザだ」って気づいてしまいます。
選択できないようにしてしまいましょう。
.hoge {
user-select: none;
}
スクロールバーを消す
PC で使われない前提ならスクロールバーも消してしまいましょう。PC ユーザーがいるならダメですよ。
とくに横スクロールだとデスクトップユーザーはスクロールできなくなってしまいます。(怒られた経験有)
body::-webkit-scrollbar {
display: none;
}
フルスクリーン
アドレスバーやツールバーが画面に出ていると、web っぽさが出てしまいます。また、ユーザーの体験のために表示領域を大きくしたいというニーズもあるかもしれません。ブラウザにはアドレスバーを消す機能が一応存在しています。しかし、確実に実行可能であるというわけではないこと、URL バーを消すことで返ってユーザビリティを下げることもあるので、注意してこの機能を使ってみましょう。
android
Android 端末かつ Chrome を利用している場合は PWA 化していない場合でも、FullScreenAPI
が利用して、フルスクリーンでコンテンツを提供できます。
safari
昔は minimal-ui の設定を書けば可能でしたが、今は利用できません。(方法を調べているのこの情報に出会いますが、今はもう使えません)
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
代わりに、 ユーザーがホーム画面へ追加したときのみ限定の挙動になりますが メタタグに次のことを書けば可能です。
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
つまり、Safari の場合は、PWA 化されていなければ、フルスクリーンでコンテンツを提供することができません。
フルスクリーンと OAuth2(要調査)
PWA 化している状態で、フルスクリーンモードで OAuth 認証してみましょう。Redirect で PWA に戻れません。そのため、元のブラウザのページに戻しています。そのせいで、いつまでたっても認証できないという辛いことが起きています。これに対する対応策は、正直知らないです。試したことはないのですが、iframe 越しに認証画面をひらけばいけるみたいな話を聞いたことがあります。(要調査: 実現可能性&セキュリティリスク)
(加筆)どういうときにアプリっぽさを出せばいいか
ネイティブアプリっぽく見せることの是非に対しての言及があったため、記事の提供者としての立ち位置を表明させてください。
まず、そのWebページがアプリとして開かれる場合(例えば、ネイティブアプリのWebViewから開かれる場合、もしくはPWAから開かれる場合)は、ネイティブアプリっぽさが有っても良いと思います。むしろ持たせた方が、アプリに慣れているユーザーにとってはフレンドリーになります。
では、普通にブラウザから開かれるページはどうあるべきかでいうと、このような記事を書いておいて恐縮なのですが、ネイティブアプリっぽく見せる必要はあまりないと思います。Webページの開発においては、まず不要とも言えるでしょう。ただ、Webアプリケーションの開発においては、ネイティブっぽさがユーザーのためになり、採用した方が良いユースケースもあります。
WebページとWebアプリケーションの違いですが、僕の認識ではWebページは、インターネットから情報を探しに来るユーザーのために公開してあるページです。一方でWebアプリケーションは、ユーザーが何らかの問題解決を行うためのWeb上のプラットフォームだと思っています。
Webページにおいては、ユーザーにとって情報を探しやすいUIを作るべきであり、一覧性やユーザーの慣れ親しんだ操作方法を提供すべきだと思います。そのため、ネイティブアプリっぽいUIは、ユーザーにとって不便であり取り入れない方が良いケースがほとんどだと思っています。
しかし、Webアプリケーションにおいては、ユーザーが自身のニーズや悩みを解消できるように、ユーザーをサポートする仕組みを作って行った方が良いでしょう。そのため、ユーザーに寄り添えるような設計を目指すべきで、ユーザーからの入力や、ユーザーへフィードバックを与えるイベントが多く発生する設計になるでしょう。
このとき、ネイティブアプリっぽい動き(特にアニメーションと横方向への移動)は、ユーザー体験を向上させるために必要になってくると思います。具体例をあげると、ユーザーからのインプットにおいては、あるinputでバリデーションエラーにひっかかったときに、なんらかのアニメーションを持たせてユーザーにエラーを知らせることで、エラーに対する気づきやフィードバックを、わかりやすく伝えることができます。他にも、業務支援系のモバイルアプリにおいては、店舗の売り上げなどを日別で確認するとき、前後日で比較した時は右左のスワイプで切り替えると、直感的に切り替えることができ、ユーザーの思考を阻害することなくコンテンツを提供することができます。このようにアプリっぽさを追求した方がユーザーのためになるケースも存在します。
また装飾をするだけのアニメーションや表現については、 ユーザーを疲れさせない範囲ではありますが 、ユーザーをわくわくさせる、アプリの世界観を演出するといった目的においては使ってもいいのではないかと思っています。特に世界観を作りあげることはユーザーがそのサービスを愛し、ファンになってくれる要因の一つでもあります。
まとめると、Webページの見栄えをアプリっぽくするだけの行為にはあまり意味はなく、ユーザーにとって迷惑にもなるとは思うのですが、Webアプリケーション開発において、ユーザーのユースケースを想定し、ユーザーの体験や業務にとってfitするUIの実現方法として、アプリっぽさが必要となるケースもあるので、アプリっぽくみせるための方法を頭の片隅に入れておくと良いかもしれません。
-
スケルトンじゃないローダーでも当てはまる話です。ローダーはコンテンツ表示領域と同じ領域を持たせて起きましょう ↩
API開発を支える可視化ツールをMVPとして作っている話
この記事はリクルートライフスタイル Advent Calendar 2018の21日目の記事です。
わりと他のみんながガチガチな本気記事投稿しているので、仕事のスキマ時間や趣味の延長でやっている小ネタについて書いていきたいと思います。
はじめに
どうも改めまして、ホットペッパービューティでAPI開発をしている @tacumai です。
ぼくは2年ほど社内でインフラエンジニアし、3年目になる今年はJavaをゴリゴリ書くアプリケーションエンジニアをさせていただいております。僕自身、インフラのas Code化をしたり、運用の自動化したりと、社内では日々ヌクモリ運用によって起こり得る本番障害を減らすべくいい感じのDevOpsな取り組みをしていったのですが、アプリケーション開発に移ると、新たにアプリレイヤの課題があることに気づきました。
ある日、こんな課題を感じた
これまで
でみられるようなマイクロサービス化・システムリプレイスをホットペッパービューティでは行なってきました。そしてそれは今でも現在進行中の形で進んでいます。しかし、APIの数が増えるも、どのAPIがどのデータベースのどのテーブルに対して、参照・更新しているのかわからず、リプレイス時には既存の複雑なコードを読むことに多大な苦労をせざるを得ない状況でした。
こういうものを作りたい
めんどくさがりマンのぼくは、コードを読みながら猛烈に「なんかいい感じにこのリポジトリのURLを入力してくれたらどのエンドポイントがどのController/Service/Repositoryを経由し、どのテーブルにアクセスしているのかすぐにわかるようになってくれないかなぁ」と思う毎日でした。とまぁ、そんな都合の良いツールは社内には存在せず、絶望しました。
そして、おもったのです。
「「「「「「 つくればよいのでは? 」」」」」」
まずはMVPを定義するぞ
「リポジトリのURLを入力してくれたらどのエンドポイントがどのController/Service/Repositoryを経由し、どのテーブルにアクセスしているのかがすぐにわかるツール」をつくるために、やらなければいけないこと(やれたらいいなとおもうこと)を整理すると、膨大にあることに気づいたので、大まかに最低限何をやらなければならないのか、整理しました。そして
- Javaのコードを読み込み、SQLファイルがどのエンドポイント(コントローラ)に紐づいているか求めるパーサ・分析ツールが必要
- 1の作業を終えたあと、構造化してファイルに保存する必要がある
- 構造化ファイルを読み込み、ビジュアライズする必要がある
で出来そうだなと思い、ステップを分けました。
なお、これは「仕事」ではありません。あくまで「自分が楽したい(他の人も楽できる)ツールをとにかく作りたいんだ」という気持ちで始めた趣味ツールなので、手間をかけたくありません。実際に動くものを作り、価値があるとおもったら手間暇かけて作り込めればいい。なので 1. Javaのパーサ・分析
については難易度が高く、そしてコスパが悪いので今回はやらないという判断をしました。さらに、内部実装まで分析できると嬉しいですが、より難易度が高いはずなので、まずはエンドポイントだけを考えることにしました。また可視化についても、かっこいいスマートでよさげなFWをつかったアレコレも考えず、学習コストなしですぐに使える jQueryを使って実装を始めました。
構造を定義
まずは構造の定義を。Javaコードを分析すると、おそらくこんなコードが生まれるであろう、という成果物を自分で定義。
アプリケーション・個々のAPI・DBについて、構造化できればよいな、と判断し、以下のようにまとめてみました。
// API単位の情報を管理するJSONファイル
{
"fuga-api": {
"repository-url": "https://github.com/hoge/fuga-api",
"detail": "ユーザ向けリソースAPI",
"languages": [
"Java",
"Groovy"
],
"frameworks": [
"Spring-Boot"
]
}
}
// アプリ単位の情報を管理するJSONファイル
{
"title": "予約取得API",
"belongsTo": "fuga-api",
"http": {
"endpoint": "/reservations/{id}",
"method": "GET"
},
"db-requests": [
"development1": {
"scheme": "ADPHPD",
"tables": {
"STORES": [
"ID",
"NAME",
"CREATED_AT",
"UPDATED_AT"
],
"USERS": [
"ID",
"NAME",
"UP_DATE",
"CREATED_DATE"
]
}
}
]
}
// DBの情報
{
"SCHEME1": {
"detail": "カスタマ向けテーブルスキーマ",
"tables": {
"users": {
"ID": {
"type": "int",
"primaryKey": true,
"index": true
},
"NAME": {
"type": "varchar(30)"
},
"CREATEAT_AT": {
"type": "DATE"
},
"UPDATED_AT": {
"type": "DATE"
}
}
}
}
}
可視化してみる
JavaScriptの canvas
機能を使って、事前に準備したJSONファイルを読み込み、以下のような可視化を行なってみました。
個人的にはもっといい感じにしたいぞ!という気持ちが実装中からめちゃくちゃ溢れてきましたが、一旦、「JSONファイルさえ書いて読み込めば、APIごとのテーブル参照が一目瞭然でわかる」という状態に持っていくことができました。JSONファイルを増やしていけば、情報的には、当初求めていたニーズを満たすことができます。
今後
「MVP」としての形には持っていくことができましたが、これでは運用もできないし、他のアプリケーションとの連携もまだ難しい状態です。次の頑張りどころは、Javaのパーサまたはビジュアライゼーションの部分ですが、見た目がよいほうが改善も頑張れるので、今のこの四角形で面白みのないcanvas描画を、別のツールなりで置き換えてよさげな感じにすることを目標に改善を進めたいと思います。
以上、みなさま良いお年を!
iOSアプリをKotlin/NativeとFlutterに移植する
この記事はリクルートライフスタイル Advent Calendar 2018の22日目の記事です。
はじめに
ホットペッパービューティーでネイティブアプリの開発をしている @yrhorita です。
この記事では、Kotlin/NativeとFlutterとで同じアプリケーションを作ってみたときのやり方や感想などを書いていきます。
想定シチュエーション
Swiftで書いたiOS版を展開していたプロダクトについて、PMが「そろそろPMFも見えてきたし、Android版も作り始めたいなー」と言い出した。
自分がエンジニアとして取りうる選択肢の第一はもちろんKotlinでAndroid版を作ることだが、それ以外にクロスプラットフォームという選択肢がどれだけ現実的か、せっかくなので少し試してから決めても良いのでは?
作るプロダクト
画像を眺めるアプリです。シングルカラムで、無限スクロールで見ていく感じ。
APIから取ってきたObjectリストにはそれぞれ画像のURLが書いてあり、セルごとに画像をリクエストして描画していくイメージです。
必要そうな処理は、APIリクエストしてjsonをパースする、URLから画像を取得し表示する、縦向きのシングルカラムのリストビューを表示する、スクロールに応じてページングしてAPIリクエストをする、あたりです。
やってみよう
今回考えるプラットフォームはAndroidとiOSに限定します。
共通の技術stack
画像はpixabayから取ってきます。APIキーが必要なので、これを所定のファイルから読み出します。
Flutter
完成版
https://github.com/yrhorita/advent-calendar-2018/tree/master/flutter_sample
Android | iOS |
---|---|
![]() |
![]() |
環境構築
- dart 2.1.0
- https://www.dartlang.org/tools/sdk に従ってDart sdkをインストール
- Flutter v1.0.0
- https://flutter.io/setup-macos/ に従ってセットアップ
flutter upgrade
で出てくる指示に従っていけば良いのでとても楽ですね。
作り方
既存のiOSの資産を使わず、最初からゴリゴリ書いていくことにします。
IDEはVS Codeでもいいのですが、筆者はIntelliJファミリーが好きなのでAndroid Studioで書きました。
Flutterのwidgets libraryを見つつ、各アイテムにはシンプルなContainerを使えば良さそうということで選択。
シングルカラムのリストを実現できそうなクラスはいくつかありそうでしたが、ListView のbuilderを使っていくのがパフォーマンス観点で有利とあるので従ってみました。
APIクライアントやネットワーク越しに画像のキャッシュ含めて使いやすくしてくれるライブラリ(PicassoやKingfisherのような)は、 Flutter Packages で適当に探して入れました。
アーキテクチャについては、Flutter(というか、Dart?)といえばBLoCアーキテクチャのようですが、今回は 締切間近すぎて ベタっとmainファイルに書いてしまいました…
感想
Dart
Dartは書いたことがなかったのですが、Javaっぽいので書くこと自体はなんとかなりました。
Kotlinに飼いならされた身としてはセミコロンを忘れがちでしたが、Android StudioにDartプラグインを入れておけばかなり高速にwarningを出してくれるため、比較的ストレス少なく書けました。
Dartはシングルスレッドで動くのですがasync/awaitなど非同期処理のために必要な言語機能も揃っており、最近の静的型付け言語としてなかなかいい感じです。
強いて言えば、Optionalくらいは用意してくれてもよかった気はしますかね。
Flutter
Flutterでのネイティブアプリ開発はすごく楽で楽しいと思います。なんといってもHot Reload/Restartはすごく強力です。普段のAndroid/iOS開発ではビルドの待ち時間の数十秒~数分をどう有効活用するかを考えていましたが、Flutterであればその必要は殆どなくなりそうです。
LinearLayoutもUITableViewも知らなくても、OkHttpもAPIKitも使わなくても、上のようなアプリが書けてしまうということにも驚きます。Android/iOSのネイティブAPIを知らないままにクロスプラットフォームの開発をできてしまうのは、両ネイティブアプリ開発をやってきている人間としては面白いと同時にちょっと焦りますね
Material Designを中心にWidgetが豊富に用意されているので、特殊なデザインがないアプリであれば本当に簡単に作ってしまえそうです。
Android/iOSの分岐については、今回のサンプルアプリの中ではDartだけで実現しています。具体的には、dart:ioライブラリ内の Platform.isAndroid
などで分岐させました。
本格的に分ける場合は、MethodChannelというAPIを使ってFlutterのクライアントからネイティブのhostとやり取りする形式になりそうです(参考)。PlatformViewを使えばWebViewやMapsなども使えそうですね(エアプ)。
参考にした記事
Kotlin/Native
環境構築
https://github.com/JetBrains/kotlin-native のREADMEに従ってセットアップします。1時間くらいかかると書いてありますが、筆者のMBPでは2時間かかりました。
- JDK 1.8.0_192, Xcode10.1
- Kotlin 1.3.11
- Kotlin/Native v1.1.0
Troubleshooting
-
./gradlew dependencies:update
がBuild file '/Users/yuri/go/src/github.com/JetBrains/kotlin-native/build.gradle' line: 44
でFailするとき- XcodeのCommand Line Toolsの設定ができていないかも
-
xcrun xcodebuild -version
でバージョンが見られなければXcode上からPreferences->Locations->Command Line Toolsを選択する
作り方
既存のiOSアプリをKotlin/NativeのMultiplatform Projectに移植していくことを想定します。
iOSアプリ側から共通化できそうなコード(APIレスポンスを受けるクラスなど)や共通化できそうなインタフェース(APIリクエスト部分)をKotlin/Nativeのcommon moduleに切り出していきます。
HttpクライアントはKtor clientがMPP対応していていい感じだったので使ってみます。
…とやってみていたのですが、結局途中からはKotlinでAndroid版を作っていく作業になってしまったので、今回は割愛します。
途中までの作業は yrhorita/advent-calendar-2018 に置いておきますので興味のある方はご覧ください。
画像のURL取得まで終わっているので、あとはRecyclerViewなどに画像を表示しスクロールに応じてページングしていくだけです。
感想
iOS版だけの状況からKotlinによるAndroid版を作っていくような状況であれば、共通部分をKotlin化しながら作っていくことができるため、二重メンテのコストを下げつつマルチプラットフォーム展開ができるようになるのが良いなと思いました。
既存のiOS資産を活かしながら進めることができるのは魅力的ですね。
また、使い込むと「どこがプラットフォーム間で共通化できる/できないのか」をとにかく考えていくことになるため、ネイティブアプリ(クライアントサイドのプログラミング)における抽象化のトレーニングにもなりそうだと思いました。
思想として最初からcommon moduleと各プラットフォーム用のmoduleに分けている割り切りはとても現実的だなと感じます。今回はシンプルなアプリを作成したため共通化しうる部分も多かったですが、実際のアプリ開発では認証やWebView、他アプリ連携やバックグラウンド処理、プッシュ通知といった、プラットフォームごとに固有または癖がある処理が多数あります。
すべてにwrapperのlibraryがあれば良いですが、現実的には依存も増えてしまい難しい予感がします。
プラットフォーム固有の処理は割り切ってプラットフォームごとに記述し、あくまでプラットフォーム間で共通の処理だけcommon moduleで共通化していくという考え方で使っていくのが良さそうです。
筆者はAndroidもiOSもちょっとわかるのでなんとかなりましたが、両方が書けない場合の立ち上がりは結構苦しい気がしました。
それと、素のKotlinでAndroidアプリを書いていくのと比べたデメリット(Javaのassetsが使えないことなど)がiOSアプリとのコード共通化といったメリットと比べてそこまで大きいかと言われると、まだ疑問符がつくかなと思ってしまいます。
ただ、個人的にはKotlinで両OSアプリを書けるということがすべての疑問符を打ち消してくれる気がします!Kotlin最高!
終わりに
執筆中にKotlin/Native, Flutterともにめでたくv1.0.0がリリースされたので、一度書いた記事をまるっと書き直しました
連休の残り2日は弊社の開発マネージャー陣による記事が続く予定です。お楽しみに。
それでは、Happy Holidays!
Slackの情報から人/組織の分析をしてみる
この記事はリクルートライフスタイル Advent Calendar 2018の23日目の記事です。
はじめに
飲食領域の開発グループのマネージャをやってる@omrcoです。
ここ最近ダイエット中です。好きなサラダチキンは「国産鶏サラダチキン アクマのキムラー」です。
自分のための雑務自動化や何らかのデータの収集/分析以外ではほぼ業務でコード書くことがないのですが、今回は「なんかマネジメント面で面白いことに繋がらないかなー」と(エンジニアらしくそれなりにドキュメント読みこんだり、コードをぼちぼちは書いたりして)試していることを紹介したいと思います。
背景
HOT PEPPER グルメ / Restaurant BOARDといったサービスのシステム開発が自分のグループのミッションなのですが、
- 開発者200名以上
- 飲食領域の企画(& 営業)の人達とプロジェクトベースで協働
- AirID / AirREGIといった関連サービスの開発を行っているAir領域の人達との連携
- その他横断組織(データサイエンティスト, データ基盤, オンプレインフラ基盤, クラウドインフラ基盤 etc.)との協働
という具合に大きなサービスゆえにステークホルダー・コミュニケーションが多くなってしまうため、「どうすればより効率的に開発が進められるだろう」という課題に常に向き合わなければなりません。
その中でも『関わるのがどういう人/組織かを捉える』ことは個人的に大切だと日々感じることなので、1on1(リクルート用語でいうと「よもやま」)を通して経験してきたことや考えていることを聞いたり、16Personalities やってみてもらったりといったことから、エンジニアならばGitHub/JIRA/Confluenceを見る・ディレクタならば企画資料見てみる・チームとして見るなら議事録/Slackのやりとりを見る・飲み会をするといった地味(?)なことをしています。
(会社としても表彰や社内報といった形で仕事のプロセス/成果を横展開するということに積極的なので、そこでもいろんな人を知ることが出来ます。)
ただ上記も
- 時間をかけすぎるわけにはいかない
- 必ずしも全員が積極的にやっているわけではない
- 自分個人ではなく他の人にも捉えてもらえるよう/伝えやすいように出来ないか
- (話が逸れてしまうが)組織変更多い・関わる人は多いのに社内の人事管理システム/アカウント管理がなかなかのレベルでイケてなくて所属を調べることすら難しい事がある
などなどの悩みがあります。
概要
(真っ当になんらかのサービス導入するのがよいとは思うものの)調査/提案しにいくのも面倒なので、A;やDonut.aiといったサービスにインスパイアされて社内コミュニケーションツールとして所属組織/職種や雇用形態等に関係なく広く使われてるSlackでHRTech的な事をまずは自分の隙間時間で出来ないかと試作してみることにしました。
Slackの情報をベースにするということで、個人よりも関係性/スキルよりもキャラクターということに着目したいと思います。(Slackにはアナリティクス機能もあるのですが、現状一般ユーザーの自分には開放されてません。)
大まかな手順は
- Slackから情報を取得する
- 取得した情報をNeo4jに取り込む
- Neo4jの機能を使っていろいろ加工/分析する
です。
Slack APIについて
APIとしてわかりやすく公式ドキュメントも充実してますし、使ってみた系の記事も多いので特に書くことはないのですが
- channels.list
- channels.info
- users.list
のAPIを使いました。
それぞれ
- channels.listを実行するときはexclude_members, limitを設定したほうが早い(らしい)。private channelは全チャンネルに入っているユーザーを作らない限り一覧取得は不可能。
- channels.infoを大量実行するときはレートリミットがあることを考慮する cf. Rate Limits
- users.listの結果にはslackbot(USLACKBOT)やdeleted/is_botがtrueのものなど微妙にレスポンス構造が異なるものが含まれる
くらいに気をつければよいかと。出力は以下のようなcsvとしています。
channel.csv
id | name | num_members |
---|---|---|
Cxx | xxx | xxx |
user.csv(r_name以降の列はSlack以外の情報)
id | name | real_name | r_name(本名) | r_user_type(所属形態) | company(所属会社) | org(所属組織) | |
---|---|---|---|---|---|---|---|
Uxx | xxx | xxx | xxx | xxx | xxx | xxx | xxx |
channel_user.csv
channel_id | user_id |
---|---|
Cxx | Uxx |
Neo4jについて
Neo4jはグラフ構造を持ったデータを扱うことに最適化されたGraphDBの一種で、オープンソースでは最も人気があるらしいです。
「Amazon Neptuneも半年前くらいにGAになったしGraphDBいよいよか」ともGraphDB利用の活発化を期待していましたが、そんなこともないっぽくて残念です。(自分が拾えてないだけかもですが)
GraphDBは概念を理解するのに慣れも必要ですが、Neo4jは公式のドキュメントや学習コンテンツが充実してます。
また簡単な導入については記事/スライド(ex. グラフデータベースNeo4jハンズオン(インストールからプログラミングまで))が結構あるので興味があればそちらを読んでいただければ。
(個人的な趣味が大きいですが)
- Webインターフェースでネットワークグラフの可視化が簡単に出来る
- RDBよりも楽に/直感的に検索できるケースがある
- ネットワーク分析系のライブラリが組み込める
ということで選んでいます。
利用環境
- MacOS High Sierra 10.13.6
- JDK 1.8.0_192
- Neo4j 3.5.0
- neo4j-graph-algorithms 3.5.0.1
- neo4j-graph-algorithms 3.5.0.1
データの取り込み
Cypher Shell(旧: Neo4j Shell)でCSV取り込みが簡単にできます。
#!/bin/bash
cypher-shell -u neo4j -p password << EOF
CREATE CONSTRAINT ON (channel:Channel) ASSERT channel.code IS UNIQUE;
CREATE CONSTRAINT ON (user:User) ASSERT user.code IS UNIQUE;
USING PERIODIC COMMIT 1000
LOAD CSV WITH HEADERS FROM "file:///channel.csv" AS line
CREATE (Channel {code: line.id, name: line.name, count: line.num_members});
USING PERIODIC COMMIT 1000
LOAD CSV WITH HEADERS FROM "file:///user.csv" AS line
CREATE (User {code: line.id, name: line.name, real_name: line.real_name, r_name: line.r_name, r_user_type: line.r_user_type, company: line.company, org: line.org});
USING PERIODIC COMMIT 1000
LOAD CSV WITH HEADERS FROM "file:///channel_user.csv" AS line
MATCH (Channel {code: line.c_id}),(User {code: line.u_id})
CREATE (u)-[:JOINED]->(c);
EOF
分析事例
上記で準備出来たデータベースをもとに、Neo4jの機能を使ってデータ分析っぽいことをやってみます。
ここでも用語/手法等については詳細な説明は行いませんので、ググってください。
データの加工
ユーザーとチャンネルの関係そのままでは分析を行いにくかったため、ユーザー間/チャンネル間のJaccard係数(集合の類似度みたいなものの一つ)をそれぞれチャネル/ユーザーの集合から出してみます。
ちなみにdegreeCutoff, similarityCutoffを適当なところで指定しておかないと計算量が大きくなってしまうので注意が必要。(チューニングすればいけるのかもしれないが、OOMで何度も死んだ。)
この結果をもとにユーザー同士とチャネル同士のRelationを作成します。(正しく実態が反映されているかは別として、類似したチャンネルに入っているユーザーは知り合いであり、類似したユーザーが入っているチャンネルはリンクのあるチャンネルであるとした。)
MATCH (u:User)-[:JOINED]->(c:Channel)
WITH {item:id(u), categories: collect(id(c))} as userData
WITH collect(userData) as data
CALL algo.similarity.jaccard.stream(data, {degreeCutoff: 10, similarityCutoff: 0.1})
YIELD item1, item2, count1, count2, intersection, similarity
RETURN algo.getNodeById(item1) AS u1, algo.getNodeById(item2) AS u2, intersection AS i, similarity AS s
MERGE (u1)-[:KNOW{intersection: i, similarity: s}]-(u2)
MATCH (u:User)-[:JOINED]->(c:Channel)
WITH {item:id(c), categories: collect(id(u))} as userData
WITH collect(userData) as data
CALL algo.similarity.jaccard.stream(data, {degreeCutoff: 10, similarityCutoff: 0.1})
YIELD item1, item2, count1, count2, intersection, similarity
RETURN algo.getNodeById(item1) AS c1, algo.getNodeById(item2) AS c2, intersection AS i, similarity AS s
MERGE (c1)-[:LINK{intersection: i, similarity: s}]-(c2)
ユーザーデータの可視化
ユーザー同士の関係性を可視化してみる(User: 1112, Know: 17409)。
この際LIMITをつけて対象を減らす、あるいはWEBインタフェースの最大初期表示数を制限しないとパフォーマンスは悪くなってしまうことに注意。(下記図も関係性を持たないノードは省いている。intersectionやsimilarityで更にフィルタリングをかけることも可能。)
定性評価だがいくつかのユーザーのクラスタが形成されていることが見て取れると思います。これでどんな人と接点を持っているかが感覚的につかめます。
MATCH (u:User)-[:KNOW]->(:User) RETRUN u
クラスタリング分析
Userの知人関係を元にLouvainアルゴリズム(ネットワークのクラスタリングの結合度合いを指標を定義してそれを最適化する手法。高速かつ精度の良い。)でコミュニティ抽出を行ってみます。
CALL algo.louvain.stream('User','KNOW',{weightProperty:'similarity',write:true,writeProperty:'community'})
YIELD nodeId, community
WITH algo.getNodeById(nodeId)AS user, community
RETURN user.name, user.org, community
ORDER BY community
(個人情報の観点から)結果をココでは掲載できませんが、下記のような多様なケースについて同一領域/機能に関わる人が同じコミュニティに振り分けられていることが確認できました。これで何のサービス/機能に関わってるかわからないという悩みともおさらばです。
- 事業領域(ユニット)の各グループのメンバー
- 事業領域ではなく機能軸組織(ユニット)所属だったり、兼務が大量にあってどの領域に関わっているかわかりにくい人
- 所属組織が主務会社の関係で不明だったり、異動の関係で未反映だったりという事情でデータが欠損しているあるいは古いままの人
- そもそも何をやっていてどんな人がいるかわからないユニット/グループ(笑)
擬似的な結果
名前 | 所属組織 | コミュニティ |
---|---|---|
A | 飲食Uプロ開G | 1 |
B | 飲食UプランニングG | 1 |
C | マーケUCRM2G飲食T | 1 |
D | N/A | 1 |
: | : | : |
E | 旅行UクラウェブG | 2 |
F | 旅行Uプロ開G | 2 |
: | : | : |
G | テクプラUデータ基盤G | 3 |
H | テクプラUデータ分析G | 3 |
上記可視化と組み合わせると感覚的にもわかりやすくなるのでしょうが、Neo4jだけだとそこまでリッチな可視化は出来たないため出力結果を別のツールに食わせるといったことが必要になります。
中心性分析
ネットワークの中心性の概念には様々なものがありますが、組織間のハブの役割を果たしている人物を見つけるためにユーザー同士の関係から媒介中心性(当該ノードを通る経路数が多い)の高いユーザーを見つけたいと思います。
参考までに今回対象にした規模程度では全く困りませんでしたが、巨大なネットワークを分析する際には厳密な数値を出そうとすると計算量が大きくなりすぎるため近似値を計算するためのRA-Brandes algorithmを利用したほうが良いとのこと。
CALL algo.betweenness.stream('User','KNOW',{direction:'out'})
YIELD nodeId, centrality
WITH algo.getNodeById(nodeId) AS user,centrality
RETURN user.name, user.org, centrality
ORDER BY centrality DESC
(上と同じくで)これも掲載出来ないのですが、このクエリの結果は非常に興味深く「〜〜さん(Slackのいろんなチャンネルで見る人)とかが出てくるのだろうなー」という仮説とは少し異なり、「各グループ/チームの窓口」みたいな人が上位に来る傾向でした。(勘違いしていただけで本来的にそういう指標なのかも。ネットワークの中心とはどういうことかなどが参考になるかもしれません。)
今回は扱いませんでしたが、次数中心性(ほかノードとのリンク数)や近接中心性(他のノードとの距離の近さ)と組み合わせて見ると普段あまり関わりがない人でも組織内でどんな役割を果たしているかわからないという悩みからも解放されそうです。
蛇足
以下は思いつきでやってみたけど「なんだかなぁー」となったので、ココで供養しておきます。
リコメンド
グラフDBだと簡単なリコメンドのような機能も数行のクエリで実装できるので試してみます。
(主観では良さそうなのですが、どうやったら妥当さを評価/表現できるかパッと思いつかず社内の人とかでないとわからないものとなってしまいました )
自分へのおすすめチャンネル
自分と同じチャンネル(c)に入っている人(ou)が他に入っているチャンネル(rc)をouの数とrcのメンバー数でソートして出してみる。
MATCH p=(u: User{name: "t_ohmori"})-[:JOINED]->(c: Channel)<-[:JOINED]-(ou: User)-[:JOINED]->(rc: Channel)
WHERE NOT (u)-[:JOINED]->(rc)
RETURN rc.name,rc.count, COUNT(DISTINCT ou) as count
ORDER BY count DESC, rc.count DESC
LIMIT 10
rc.name | rc.count | count |
---|---|---|
air-all | 275 | 207 |
hpb-dev-all | 249 | 177 |
step2_vdi | 223 | 164 |
hpb-prd-operation | 203 | 155 |
hpb-dev-attendance | 214 | 153 |
hpg-nb | 167 | 147 |
air-自己紹介 | 195 | 141 |
hpb-release | 176 | 138 |
air_store | 139 | 129 |
lost-items | 171 | 125 |
自分が運営してるチャンネル(社内Twitterみたいなもの)へ招待したらよい人
自分が運営しているチャンネルに入っている人(u)が入っているチャンネル(oc)に入っている他の人(ru)をuとocとのリレーションの総数が多い順にソートして出してみる ※ロジック怪しい
MATCH p=(c: Channel{name: "ohmori-time"})<-[:JOINED]-(u: User)-[j:JOINED]->(oc: Channel)<-[:JOINED]-(ru: User)
WHERE NOT (c)<-[:JOINED]-(ru)
RETURN ru.name, COLLECT(DISTINCT oc.name), COUNT(j) AS count
ORDER BY count DESC
LIMIT 10
(表の見やすさのためcollect部分は省いています)
ru.name | count |
---|---|
ykoyano | 690 |
yj_yamamoto | 514 |
akt | 492 |
hinosawa | 455 |
kohei-kawasaki | 433 |
myamyu | 426 |
booklin477 | 407 |
yuki_hara | 405 |
t_iwanaga | 400 |
myamashita | 394 |
チャンネル分析
媒介中心性
いろんな組織の社交の場的なものが出せないかと、チャンネル同士の関係をもとに媒介中心性を出してみる。
CALL algo.betweenness.stream('Channel','LINK',{direction:'out'})
YIELD nodeId, centrality
RETURN algo.getNodeById(nodeId).name AS channel, centrality
ORDER BY centrality DESC LIMIT 10
channel | centrality |
---|---|
cet-concierge | 10839.415385160875 |
times-kato | 7845.863047187394 |
blt-bq-loader-alert | 5678.978754912108 |
blt-machine-learning | 5532.979271056408 |
hpr-db | 5471.719375019848 |
hpr-impairment | 5401.087485111652 |
time-odamiki | 5254.038198516776 |
aws | 4904.973350024011 |
gourmet-id_incident | 4651.1873013511695 |
time-kanai | 4566.633960597578 |
領域横断での基盤組織への相談場所であったり、横断での障害時用チャンネルなどが抽出できた。
Page Rank
また上述したとおり、紹介した媒介中心性の他にも中心性の指標はいくつかあります。
チャンネル同士の関係を元にページランクを出してみる(ウェブページの重要度を決定するためのアルゴリズム。Google創業者のRallyPageにより提案)。
CALL algo.pageRank.stream('Channel', 'LINK', {
iterations:20, dampingFactor:0.85, weightProperty: "similarity"
})
YIELD nodeId, score
RETURN algo.getNodeById(nodeId).name AS page, score
ORDER BY score DESC LIMIT 10
page | score |
---|---|
airregi-pre-pb | 7.140604 |
hpg-dev-画像拡充 | 6.6502475 |
air-fe-offsite | 6.506452999999999 |
税制改正_hpg | 6.2843565 |
blt-bolton-tickets | 5.7006615 |
jln_lpo_list | 4.819755499999999 |
jalan-dp-あとからコンビニ決済問題 | 4.7487125 |
sento-jsm | 4.388576 |
hpb-prj-43358-oudan | 4.151647 |
grm-system-notice | 3.676684 |
結果は出せたけども、解釈をすること(グラフも見ながら考えてみたけど)も難しかった。ナニコレ。
終わりに
SlackのAPIで取得できる情報を元にNeo4jで簡単な分析を行えるようにしてみました。
特に結論はないのですが、思いつきで進めている割には本当に(個人的に)役立ちそうな結果も出ているので、
- 可視化ツール組み合わせてグラフで見やすくする
- メッセージ/emojiのやりとりの情報も組み合わせてみる
- 今回はユーザーの属性情報としたけども所属組織とかはノードとして管理したほうがよかったかも
- プライベートチャネルの情報も組み合わせられる(自分の関わる範囲が限界だろうが)ようにする
とさらに面白くできるのではないかなと思ってます。
客観的に見ると「かなりネトストっぽいことをなぜ世界に公開しているのだろう(しかもクリスマス前に)」という後悔もありますが、HRTechっぽいこと出来て満足です。
みなさま、よい年末・年始をお過ごしください!
良いプロダクトバックログについて考えてみるイブ
はじめに
クリスマスイブ、ということは今年もあと少しとなります。
本業では大規模開発のマネージメントをしています。サブシステムやチーム単位では厳密にスクラムではないものの、イテレーティブな開発を試していく動きも(少しずつではありますが)あったりします。そこで良いプロダクトバックログとはなんだろうかと少し考えてみました。
TL;DR
プロダクトバックログは永遠にβ版
重複を気にしないで書く
たとえばあなたがプロダクトオーナーになったとしましょう。まっさらな状態からプロダクトバックログを作っていきます。きっと周囲の人の頭が良ければ良いほど、MECE(もれなく、だぶりなく)であることを求めてくるかもしれません。
しかし、はじめから「もれなく、だぶりなく」というのは、なかなか難しいものです。重要なのは「もれなく」です。はじめのうちは重複はおそれず、必要だと認識したものを次々と書いていきましょう。
形容詞、形容動詞は使わない
抽象的なアイディアが思いつくこと自体はよくあることです。
しかし、実際に開発していくチームのメンバーと認識がずれやすい表現は避けたほうが賢明です。
キレイな~ など形容詞、形容動詞を使うのはなるべく控えるべきでしょう。
要求事項が不明瞭になり、エンジニアとのハレーションも増えてしまうかもしれません。
必ず受け入れ条件を書く
これも前項と似ている話です。スクラムに詳しい方ならAcceptance Criteria というと伝わるでしょうか。受け入れる人によって基準が変わっては困ってしまいます。エンジニアはAcceptance Criteriaを目指して実装していきます。基準が明確ならば、ストレスもきっと少ないでしょう。
INVESTで整形する
ある程度量がたまってきて、展望が見えてきたら書いたものは放置せず、整えていくことが大事です。その際には「INVEST」というものを意識するとうまくいくかもしれません。
Indecent --- 依存しない(少ない)
Negosietable --- 交渉可能
Valuable --- 価値がある
Estimate --- 見積もり可能
Size -- 手頃なサイズ
Test --- 検証可能
サイズと依存は対立しますが、迷った時はサイズを小さくする方を選ぶのがセオリーのようです。見通しが立てやすいためです。
プロダクトバックログは比較で見積もる
さあ、実際に開発していくとして、当然あなたはどれくらいかかるものかが気になります。
大事なことは何のために見積もるのかです。事前に一つ一つのアイテムについてエンジニアが勘と経験でxxx時間かかると見積もったものがどこまで正しいのか、正直あなたにはわかりません。
それよりもベロシティを計測しましょう。開発チームの生産量を見える化したほうが効率的で正確かもしれません。比較で見積もるというのは、たとえば一番小さなアイテムを1としてそれ以外がいくらくらいになるか、で見積もっていきます。スプリント x ベロシティで生産量を出していけば、リソース計画も立てることは可能です。
Undoneもきちんと積む
いざ実際に開発を始めるとリリースの際に完了(Done)できなかったものも出てくるでしょう。
そういう時は無理せず、焦らずきちんと積み残し(Undone)としてのせることが肝心です。
たとえば、成果物定義の中で作らなければならないドキュメントがあったとして、様々な都合上該当スプリントのリリースから外した場合、きちんとバックログに積んでいかないと、どんどん形骸化してしまいます。イテレーティブな開発 = 早くリリースできるではありません。チームのベロシティを可視化しながら、ゴールを目指すことが目的なはずです。Undoneは保守的に消化しましょう。
プロダクトバックログは永遠にβ版
イテレーティブな開発をしているということはマーケットの中でいろいろな挑戦を繰り返しているということだと思います。マーケットへの提供価値もプロダクトの機能も試行錯誤の中で変わっていくとすればプロダクトバックログもまた永遠にβ版として、定期的Refinementすることが大事になっていくでしょう。
プロダクトバックログにはあなたのプロダクトオーナーとしてのスタンスが表現されていると言えるかもしれません。
最後に
つらつらと書いてしまいましたが、メリークリスマス!
Merry Christmas ウェッブサーバー
この記事はリクルートライフスタイル Advent Calendar 2018の25日目の記事です。
はじめに
みなさんは新しい言語を習得していく時の最初の一歩はHello World
ではないでしょうか? この記事で単なるHello World
を出力ではなく、Hello World
ウェッブサーバーをいくつかのプログラミング言語で作って行きたいと思います。各言語のウェッブサーバーフレームワークを使用せずに、できるだけ標準ライブラリーを使用します。
あ、本日は12月25日Christmas Dayということで、Hello World
の代わりにMerry Christmas!
というメッセージを表示するようにしました。
また、ローカルマシンではそれぞれの言語をインストールのはめんどくさいと思うので、Docker Imageを使ってcontainer上で実行します。
Dockerfileとソースコードがあるディレクターで以下のコマンドを実行します。
docker build --tag server-xmas:latest . && docker run --rm -p 8080:8080 server-xmas:latest
プラウザで localhost:8080 に開くと、Merry Christmas!
というメッセージが表示されます。
では、各言語の書きやすさなどを比べてみてくださいね。
みなさん、Merry Christmas!!!
Go
以下のように、ソースコードとDockerfileを配置し、上記でのdockerコマンドを実行します。
├── Dockerfile
└── xmas
└── main.go
ソースコード
main.go
:
package main
import (
"fmt"
"log"
"net/http"
)
var greeting = "Merry Christmas! (go)"
func myHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, greeting)
}
func main() {
http.HandleFunc("/", myHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Dockerfile
FROM golang:1.8-alpine
COPY xmas /go/src/xmas
RUN go install xmas
FROM alpine:latest
COPY --from=0 /go/bin/xmas .
EXPOSE 8080
CMD ./xmas
Python
python3の実装です。以下のように、ソースコードとDockerfileを配置し、上記でのdockerコマンドを実行します。
.
├── Dockerfile
├── main.py
ソースコード
main.py
:
import http.server
import socketserver
GREETING = "Merry Christmas! (python3)"
class MyHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(bytes(GREETING, "utf-8"))
with socketserver.TCPServer(("", 8080), MyHandler) as httpd:
print("Server Running...")
httpd.serve_forever()
Dockerfile
FROM python:3.7-alpine
ENV DIR=/work
COPY main.py ${DIR}/main.py
WORKDIR ${DIR}
EXPOSE 8080
CMD python main.py
Java
javaのウェッブサーバーと言えば、Spring
を使うと思いますが、メッセージ表示するだけなので標準のライブラリーだけで作成します。
以下のように、ソースコードとDockerfileを配置し、上記でのdockerコマンドを実行します。
.
├── Dockerfile
├── main.java
ソースコード
main.java
:
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
class Main {
static final String GREETING = "Merry Christmas! (java)";
public static void main (String args[]) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/", new MyHandler());
server.setExecutor(null);
server.start();
}
static class MyHandler implements HttpHandler {
public void handle(HttpExchange t) throws IOException {
System.out.println("Server running...");
t.sendResponseHeaders(200, GREETING.length());
OutputStream outputStream = t.getResponseBody();
outputStream.write(GREETING.getBytes());
outputStream.close();
}
}
}
Dockerfile
FROM openjdk:8-jdk-alpine3.8
ENV DIR=/work
COPY main.java ${DIR}/main.java
WORKDIR ${DIR}
RUN javac main.java
EXPOSE 8080
CMD java Main
NodeJs
なまのJavascriptでは書けないので、nodejsで書きます!
以下のように、ソースコードとDockerfileを配置し、上記でのdockerコマンドを実行します。
.
├── Dockerfile
├── main.js
ソースコード
main.js
:
const http = require('http');
const port = 8080;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Merry Christmas! (nodejs)\n');
});
server.listen(port, () => {
console.log(`Server running at http://localhost:${port}/`);
});
Dockerfile
FROM node:8.14-alpine
ENV DIR=/work
COPY main.js ${DIR}/main.js
WORKDIR ${DIR}
EXPOSE 8080
CMD node main.js
Ruby
以下のように、ソースコードとDockerfileを配置し、上記でのdockerコマンドを実行します。
.
├── Dockerfile
├── main.rb
ソースコード
main.rb
:
const http = require('http');
require 'socket'
message = "Merry Christmas! (ruby)"
server = TCPServer.new 8080
loop do
client = server.accept
client.puts "HTTP/1.0 200 OK"
client.puts "Content-Type: text/plain"
client.puts
client.puts message
client.close
end
Dockerfile
FROM ruby:2.5-alpine
ENV DIR=/work
COPY main.rb ${DIR}/main.rb
WORKDIR ${DIR}
EXPOSE 8080
CMD ruby main.rb
おわりに
上記を比べてみてどうでしょうか? それぞれの良さがちょっとだけでも見えたら幸いです。
次の記事では各言語の主要となるフレームワークを使って、 Hello World!
ウェッブサーバーも作って行こうと思います。
ソースコードもGithubに上げています。