ContractS開発者ブログ

契約マネジメントシステム「ContractS CLM」の開発者ブログです。株式会社HolmesはContractS株式会社に社名変更しました。

Pixelaを使ってデプロイを可視化してみた #pixela

こんにちは、id:c-terashimaです

Pixela とはGitHubの草のように数値を可視化するツールです

pixe.la

弊社はGitLab CIを利用してmasterブランチにソースがマージされると自動的にステージング環境へデプロイが行われます
ちょっとしたことではありますが、ステージング環境へのデプロイを1日どのぐらい行っているのか Pixela を使って数えてみることにしました

手順

ユーザ登録

Pixelaにアカウントを作成します

$ echo `uuidgen`
$ dummy-uuid
$ curl -X POST https://pixe.la/v1/users -d '{"token":"dummy-uuid", "username":"holmes", "agreeTermsOfService":"yes", "notMinor":"yes"}'

Request Body

Key description
agreeTermsOfService 利用同意
notMinor 未成年確認

グラフ作成

デプロイ回数を描画するグラフを作成します

$ curl -X POST https://pixe.la/v1/users/holmes/graphs -H 'X-USER-TOKEN:dummy-uuid' -d '{"id":"staging","name":"deployCounter","unit":"deploy","type":"int","color":"shibafu"}'

Request Body

Key description
unit 単位
type 数量の種類(int or float)
color 芝生の種類 (shibafu (green), momiji (red), sora (blue), ichou (yellow), ajisai (purple) and kuro (black))

カウントアップ

以下のAPI呼び出しをCIに組み込むことで自動的にカウントアップが行われます

$ curl -X PUT https://pixe.la/v1/users/holmes/graphs/production/increment -H 'X-USER-TOKEN:dummy-uuid' -H 'Content-Length:0'

実際にできたやつ

f:id:c-terashima:20200720173604p:plain

あ、だれか土日にデプロイしてる!!
なんてことも可視化され、休日にもデプロイされていることがわかりましたw
今回はステージングのみですが、本番環境も可視化してセールスチームなどに共有してあげればいつリリースしたか振り返ることにも利用できそうですね

最後に

とっても容易に可視化することができました
芝生が見慣れているというのもあるかもですが視覚的に見やすくわかりやすいですね
デプロイだけではなく定量的に取得している数値などがあれば Pixela で可視化してみてはどうでしょうか?


Holmesはエンジニア・デザイナーを募集しています
興味がある方はぜひこちらからご連絡ください!

lab.holmescloud.com

lab.holmescloud.com

スクラムチームに新しい仲間が入るので、ドラッカー風エクササイズによるチーム期待値調整ワークをやってみた

こんにちは。Holmesでスクラムマスターをしている吾郷です。

今回はチームビルディングの一環として行ったドラッカー風エクササイズについて振り返っていきたいと思います。

前提・状況

現在弊社では、毎月会社に数名の新しい仲間が入ってくれています。嬉しいことに、半年一緒にやってきたスクラムチーム(開発メンバー4人)にも、7月から新しい仲間が1名加わってくれることになりました。既存のメンバーはすごく楽しみにしています。

また、タックマンモデルを参考として、チームの成長段階から考えてみましょう。

f:id:seiseiholmes:20200717112638j:plain
仕事ができる人は「正しい衝突」が超得意!から抜粋

半年ほど活動している自チームの段階は混乱期〜統一期といったところでしょうか。チームメンバーが変更になることで、再度形成期を過ごしながらチームを作り上げる必要があります。

目的

そこで、半年やってきたチームの練度をできるだけ保ちながら、チームにおける不安や緊張を緩和し、スムーズに新しい仲間の受け入れを行いたいです。 また、形成期を迎えるチームにおいてお互いのスキルや価値観、期待値を共有・すり合わせを行うことで、今後のチーム活動の基盤とし、成長への後押しとしたいです。

ドラッカー風エクササイズとは

ドラッカー風エクササイズは、アジャイルサムライで紹介されたチームにおける期待をすり合わせるための手法です 具体的には、以下の4つの質問に答えます。
①自分は何が得意なのか?
②自分はどうやって貢献するか?
③自分が大切に思う価値は?
④チームメンバが自分に期待していることは?

また、今回はカイゼン・ジャーニーを参考として5つ目の質問を追加することにしました。質問は以下です。
⑤その期待はあっているか?
カイゼン・ジャーニーで言及されていますが、最初の4つの質問は自分に視点を置いていましたが、5つ目の質問ではメンバーからの回答となります。メンバーからの回答はよりよいフィードバックとして、自信の期待値の調整ができます。

今回は期待値の共有にとどまらず、期待値のすり合わせまで行いたいと考え、質問を追加しました。注意としては、期待値の差異の受け取り方は人によってはマイナスにとらえてしまう場合もあるかと思います。今回は差異がわかることは良いことで、すり合わせのきっかけになることが大事である旨をワークの冒頭で丁寧に説明しました。

実施方法

今回は、リモートワーク状況ということで、オンラインホワイトボードのMiroを使用して行いました。 タイムボックスは一時間とします。

流れは下記です。

  1. チェックイン(実施日がたまたま7月7日でしたので七夕を題材にして行いました。)
  2. 4つの質問にそれぞれ回答2分ずつで回答を入力(質問×2分)
  3. 各メンバーに補足してもらいながら記入内容を共有(メンバー×2分)
  4. 各メンバーの記入内容について質問・深堀り(10分)
  5. 各メンバーの最後の質問に対して、自分の期待とあっているかどうか三段階で評価(3分)
  6. 評価結果についてすり合わせ(残り時間)

また、ワーキングアグリーメントとして以下を設定し最初に共有しました。

・メンバーを絶対に否定しない(否定はしないけど、意見はありです。)

実施結果

以下のような表が出来上がりました。

f:id:seiseiholmes:20200717122631p:plain
結果表
横列が質問、縦列をメンバーとしています。
また、期待の合致度を表す星のカラー定義は以下としています。

  • 青色・・・いい感じで合致している
  • 黃色・・・まあまあ合致している
  • 赤色・・・ちょっと違う

途中で出てきたコメントに関してはオレンジや濃い黄色、青色の付箋で追加しております。
また、画像を見ると気づくかもしれませんが、期待の合致度を表す星についてはメンバーによって青が多めの方、黄色が多めの方とそれぞれ違いが見えて面白かったです。

チームメンバーの感想(一部抜粋)

KEEP
・普通に楽しい会だった
・スプリントでの良い息抜きになった
・メンバー間のコミュニケーションが更に活発になった気がする
・新しく入ったメンバーが他メンバーを把握するのに良いと思いました
・自分自身の見直しが出来た

TRY
・POも声をかけるべき
・コンパクトバージョンでもいいのでQ毎にやってもよいと思いました
・発表者毎に質疑するともっと良さそう

このように嬉しい声がありました。また、カイゼン案などの意見もくれたので、参考にしながら次回以降のTRYとして実践していこうと思います。

発表者毎に質疑するともっと良さそう

こちらに関しては、各メンバーの記入内容についての時間をまとめて取ってしまったため、前の時間で最初の方に共有してくれた方の内容を思い出しにくく深堀りづらいという状況が生まれたためです。一人ひとりに焦点を当て、チームで共通認識を持てる方が良いと思いますので、要カイゼンとしました。

POも声をかけるべき

また、こちらに関してですが、スクラムを知っている方はご存知の通りスクラムチームは、PO・SM・開発チームで構成されています。しかし、最近スクラムイベントはまだしも、こういったチームビルディングイベントになるとPOの招待を忘れがちになる現状があります。非常に痛いです。POもよく嘆いております。そこで、チームメンバーとPOの架け橋となるべきSMが、POのことを忘れないようにPOの写真を机の横に置いておくTRYをやってみようかなと思った次第です。

更に、今回もう一つのポイントであった新しい仲間からの感想も抜粋させていただきます。

・メンバーの個性(落ち着きと秩序と盛り上げ)や、フロントとバックエンドのバランスがいいなと思いました。
・みんなが謙虚で、承認し合う雰囲気は素晴らしいです。
・自分が考える期待されていることと、メンバーから期待されていることが近しく、やりたいことが受け入れられており、チームで動く土台にりました。

ということで、なんかいい感じですねΣ(゚∀゚ノ)ノキャー 実施してよかったです!

感想

前提として、スクラムにおいてチームメンバーが変更されることはあまり良しとされておりません。しかし、新しい仲間が加わることによる新しい風(経験、知識、観点)はチームにとって新鮮さを保ち続けるいい要素になります。また、新しいチームを作り上げていくという課題を乗り越えることで一層チームとして成長できるのではないかと考えております。とはいえ、頻繁な変更(ここでいう頻繁の定義はチームや会社の状況によります)を行うことによるチームへの負荷というもの存在しますので、状況を判断してチームの変更可否を決めていくのがいいかと思います。

また、今回のワークで加筆すべき良かった点としては、メンバー同士の期待値のすり合わせができたというのはもちろんのこと、スクラムマスターとしてチームと接していた自分自身への期待値・評価のすり合わせも行うことができたということです。これまでは見えにくかったチームから自分への評価を聞けたことは、今後の活動を行う上でも大きな後押しとなると思います。

チームビルディングに迷っている方、スクラムマスターとしての成果に不安がある方は是非おすすめします!

最後に

Holmesはエンジニア・デザイナーを募集しています
興味がある方はぜひこちらからご連絡ください!

lab.holmescloud.com

lab.holmescloud.com

AWSでWebSocketのネットワーク構成を考えてみる

Holmesでエンジニアをしているid:w-miuchiです。

先日弊社のサービスでリアルタイム通知の構築がトピックに上がりました。
リアルタイム通知の手段としてWebSocketに着目し、そのネットワーク構成を考えてみました。

今回はその考えた中から構成案をいくつかピックアップして紹介します。

条件

考える上で、以下を条件としました。

  1. データベース(Amazon RDS)の更新をきっかけにし、エンドユーザーに通知を行う
  2. 負荷分散を可能とする
  3. 仮定としてWebSocketコネクション情報はAmazon ElastiCache for Redisを利用

想定されるトラフィック量の算出も必要とはなりますが、今回は現在のHolmesのサービスと同じ量と仮定します。

注意

本内容は構成案であり、検証や実装は行っておりません。
構成内容は弊社SREに確認済みです。

利用サービス

今回の条件で利用する(できる)サービスを洗い出したいと思います。

Routing Application DataStore Cache
ELB (ALB, CLB)
API Gateway
ECS
EC2
Lambda
RDS
DynamoDB
Redis (ElastiCache)

こちらを元に組み合わせて構成を考えます。

構成案

1. ALB + ECS + RDS + ElastiCache for Redis

f:id:w-miuchi:20200703173730p:plain
構成1

ALB(Application Load Balancer)を利用する方法です。

ALBはパスベースルーティングが可能なため、例えば/websocketというパスで切り分けることができます。
またサブドメイン(例:websocket.xxxxxx.com)で切り分けるのであればCLB(Classic Load Balancer)でも可能です。

上記ではECS(Amazon Elastic Container Service)ですが、EC2でも問題はありません。WebSocket専用のサーバーを用意するのも有効かと思います。

処理としてはサーバーでElastiCacheのRedisにconnectionを保存しハンドシェイクします。
サーバーはデータベースにポーリングし更新通知を受け取ります。
ElastiCacheのRedisに保管されたSocketのconnectionからエンドユーザーに通知します。

非常にシンプルな構成で、弊社サービスではすでにALBを利用しているため比較的に導入しやすいです。 正直なところ弊社サービスを考えるとこの構成がベストプラクティスと考えます(笑)

メリット

  • 現サービスと同じ構成のため導入しやすい
  • コスト計算が行いやすい

デメリット

  • ポーリングによってRDSの負荷がボトルネックになる可能性があり(Socketで利用するサーバ台数を制限するなど検証が必要)

2. ALB + Lambda + DynamoDB + ElastiCache for Redis

f:id:w-miuchi:20200703173734p:plain
構成2

構成1との違いは2点です。

1点目はALBのターゲット先をECSからAWS Lambdaにしています。
WebSocketの通知だけであれば処理は軽量でAWS Lambdaでも可能かと考えます。

2点目はデータベースの更新をAmazon DynamoDBにし、Lambdaでイベントを受け取っている点です。

Lambdaを利用するためサーバーの負荷分散を任せることが可能です。

メリット

  • Lambdaというマネージドサービスを利用するため負荷分散が容易

デメリット

  • DynamoDB, Lambdaのコスト計算が必要

3. API Gateway + Lambda + DynamoDB + ElastiCache for Redis

f:id:w-miuchi:20200703173738p:plain
構成3

構成2との違いはALBをAmazon API Gatewayに変えています。

ステートフルなフロントエンドとして、WebSocket API を作成できます。

API Gatewayペイロードサイズやリクエスト数等の制限をかけたいならこちらがおすすめです。
またWebsocketをServerlessのサービスとして独立させることが可能です。

メリット

  • Lambdaに加えAPI Gatewayというマネージドサービスを利用するため負荷分散が容易
  • API Gatewayの制限が活用できる

デメリット

  • API Gateway, Lambda, DynamoDBのコスト計算が必要

4. API Gateway + Lambda + RDS for PostgreSQL(RDS Proxy)

f:id:w-miuchi:20200703173741p:plain
構成4

こちらはAmazon RDSにPostgreSQLを利用した場合です。

PostgreSQLでは通知を受け取る機能(NOTIFY/LISTEN)があり、こちらを利用します。 接続にはAmazon RDS Proxyを利用します。
RDS ProxyはLambdaを同じVPC内に配置することでデータベースとの接続をプールすることが可能です。 Lambdaが起動するたびに発生していたデータベースとの接続を緩和します。

ただし、最大のネックがRDS Proxyがプレビューであること...と本記事を書いている時にRDS ProxyがGAになりました!

https://aws.amazon.com/jp/blogs/aws/amazon-rds-proxy-now-generally-available aws.amazon.com

GAになったばかりのためこちらはかなり検証が必要です。

メリット

  • マネージメントサービスにおける負荷分散が利用できる
  • RDBPostgreSQLの場合はそのまま使うことができる

デメリット

  • RDS ProxyがGAになったばかりで、コストもかかる

最後に

いかがだったでしょうか。

RDS Proxyを利用した構成はかなりチャレンジングですが、個人的興味で加えさせていただきました!
今後は実際に構築し検証も行ってみたいと考えています。 その結果はまた記したいと思います。

Websocketの導入検討をしている方の一助になりましたら幸いです。


Holmesはエンジニア・デザイナーを募集しています
興味がある方はぜひこちらからご連絡ください!

lab.holmescloud.com

lab.holmescloud.com

Spring Bootのgradle bootRunによる起動を高速化してみる

Holmesでエンジニアをしている山本です

Holmesでは、サーバーサイドアプリケーションをGradle管理のSpring Bootで実装しています。現在、ローカル環境での gradle bootRun によるSpring Bootアプリケーションの起動まで、数十秒かかっているため、多少なりとも短縮できないかと思い、調査を行いました。

参考としたのは、以下のページです。

bufferings.hatenablog.com

起動時間としては、利用可能メモリが4GBほどある状態で5回 gradle bootRun を実行し、起動ログに表示される Started Application in ... seconds の秒数を平均したものを使用します。

作業前

Started Application in 39.092 seconds
Started Application in 35.281 seconds
Started Application in 39.612 seconds
Started Application in 39.754 seconds
Started Application in 47.941 seconds # 外れ値
Started Application in 38.136 seconds

5回目はそれ以外の値と10秒ほど乖離があるため、外れ値として除外します。それ以外の平均は、 38.375 となりました。これを基準としていきます。

作業方針

実行環境がAmazon Corretto 8 64bit + Spring Boot v2.1のため、それらのバージョンで利用できるもの、かつ、本番など既存の環境には影響せず、ローカル環境での実行に閉じた方法を選択します。

1. Gradle起動オプションの調整

まずはGradleの起動オプションを調整します。

対象となるGradleプロジェクトの gradle.properties は、以下の通りです。

org.gradle.jvmargs=-Xmx2048M
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.configureondemand=true

デーモンが有効となっています。Gradleの起動オプションの設定は、デーモンが無効な場合は GRADLE_OPTS 、有効な場合は org.gradle.jvmargs で指定します。

ローカル環境にのみ設定を反映させたいので、グローバル設定ファイルの ~/.gradle/gradle.properties を作成し、以下のように記述します。

org.gradle.jvmargs=-Xms2g -Xmx2g -XX:TieredStopAtLevel=1 -noverify

この状態で実行した結果が、以下になります。

Started Application in 36.176 seconds
Started Application in 34.452 seconds
Started Application in 37.581 seconds
Started Application in 39.238 seconds
Started Application in 37.324 seconds

平均は 36.954 となりました。1.4秒程度、3.7%の改善です。

オプションはそれぞれ、以下の意味を持ちます。

Xms, Xmx

メモリ割り当てプール(Javaヒープサイズ)の最小値および最大値です。同じ値とすることで、実行中のメモリ再割り当てを回避します。

XX:TieredStopAtLevel

XX:+TieredCompilation にて階層型コンパイルが有効化されている場合に、JITコンパイラを指定します。

64bit Javaの場合、階層型コンパイルはデフォルトで有効化されます。

  • 0: インタプリタ
  • 1~3: C1。それぞれプロファイル利用の有無などが異なる
  • 4: C2

C1がHotSpot VMのClient VM、C2がHotSpot VMのServer VMに相当します。

以下のページに詳しいです。

Oracle JDK8 JavaVMオプション - ソフトウェアエンジニアリング - Torutk

noverify

バイトコードの検証なしに、classのロードを有効化します。

2. コンポーネントのインデックススキャンの有効化

Spring Framework 5より追加された、コンポーネントのインデックススキャンを有効化します。詳細は以下に詳しいです。

qiita.com

build.gradledependencies に、 annotationProcessor "org.springframework:spring-context-indexer:${SPRING_VERSION}" を追加してビルドすると、クラスファイル出力先のMETA-INF配下に、spring.components というテキストファイルが生成されます。

このファイルが存在すると、起動時にクラスパスをスキャンしてDIコンテナに登録するのではなく、ファイルを読んでDIコンテナに追加するという挙動になります。

ファイルの内容は、 コンテナ管理Beanの完全修飾クラス名=付与されているアノテーション となります。検証時点で、662クラスが出力されていました。

この機能について検索すると、大して変わらない、かえって遅くなった、という情報が散見されます。これだけのクラス数ではどうなるでしょうか...

Started Application in 35.902 seconds
Started Application in 39.535 seconds
Started Application in 41.434 seconds
Started Application in 40.117 seconds
Started Application in 39.228 seconds

平均は 39.243 となりました。 36.954 と比べると約2.3秒、6%ほど遅くなっています。

また、Mavenであれば <optional>true</optional> を設定しておくことで、 spring.components をWARやJARから除外することができますが、Gradleではoptionalに該当するオプションがないため、プロパティ spring.index.ignore=true を設定するなどの作業が別途必要となります。

改善効果が薄そうなため、インデックススキャンについては除外することとしました。

3. コンテナ管理Beanの遅延初期化

コンテナ管理Beanは、デフォルトではアプリケーション起動時にすべてDIコンテナに登録されます。遅延初期化を有効化することで、コンテナ登録のタイミングをBeanの初回利用時に変更できます。

Spring Boot v2.2.0 からは、プロパティとして spring.main.lazy-initialization=true を指定することで、一律で遅延初期化が適応されますが、v2.1では使えないため、以下のクラスを追加します。

@Configuration
@Profile("local")
@Slf4j
public class LazyInitBeanFactoryPostProcessor implements BeanFactoryPostProcessor {

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        log.debug("bean lazy load setting start.");

        for (String beanName : beanFactory.getBeanDefinitionNames()) {
            beanFactory.getBeanDefinition(beanName).setLazyInit(true);
        }

        log.debug("bean lazy load setting end.");
    }

}

@Profile("local") を付与しているのは、本番環境などに影響を与えないためです。

GradleでSpring Bootのプロファイル名を指定するには、環境変数 SPRING_PROFILES_ACTIVE を設定するか、Java起動オプションの -Dspring.profiles.active を設定します。

export SPRING_PROFILES_ACTIVE=local してから、bootRunを実行します。

Started Application in 29.95 seconds
Started Application in 28.846 seconds
Started Application in 30.67 seconds
Started Application in 30.493 seconds
Started Application in 29.685 seconds

平均は 29.929 となりました。 36.954 から約6秒、19%の短縮です! これまでに比べ、大きく短縮できました。

その他の改善策

Thin Launcher化 + AppCDSを行えば効果がありそうです。

また、bootRunによる起動であれば、JARやWARを生成しないため、AppCDSの効果があるかもしれないですが、OpenJDKベースのJavaではv10以降でないとAppCDSが使えません。

nowokay.hatenablog.com

試しに、 org.gradle.jvmargs-XX:+UseAppCDS を追加してみましたが、 Unrecognized VM option 'UseAppCDS' で起動に失敗しました。

もっとシンプルな高速化の方法としては、Java8からはJavaヒープからPermanent領域が廃止され、ネイティブメモリ上にMetaspace領域が取られるようになったため、空きメモリを確保しておくことも重要です。空きメモリが少ない状態だと、倍近く遅くなることもありました。

Javaのメモリ管理については、以下のページが詳しいです。

equj65.net

奇跡の一枚

Webブラウザ、ビデオ会議アプリ、IDE、エディタ、その他もろもろを終了し、空きメモリを12GB程度にした状態では、20秒で起動できました。

f:id:h-yamamoto_holmescloud:20200618183045p:plain

振り返り

Gradle起動オプションの調整と、コンテナ管理Beanの遅延初期化で、 38.375 から 29.929 と約8.4秒、22%の起動時間短縮を行えました。

コンテナ管理Beanが662件と多いため、インデックススキャンの有効化と遅延初期化はそれぞれ効果があるのでは推測していましたが、遅延初期化は予想通り大きな時間短縮につながった一方、インデックススキャンはかえって遅くなってしまいました。

また、改善できたとはいえ、まだまだ30秒程度かかってしまいます。Spring Bootの場合、起動時間は @SpringBootTest などを使用したテストの実行時間にも関係するので、もう少し短縮したいところです。

最後に

Holmesではエンジニア・デザイナーを募集しております。ご興味がある方はこちらからご連絡ください。

lab.holmescloud.com

lab.holmescloud.com

Selenium IDEでイチからUIテストを作成してみる

本記事は2020年5月20日現在の情報をもとに作成しています。

はじめに

こんにちは。
株式会社Holmesでエンジニアをしている id:yoshiJ です。

Holmesはプロダクトの機能追加を確実かつ迅速にリリースを行うためにUIテストの自動化を推進しています。

UIテストとは…
UIテストは、プロダクトのユーザーインターフェイスが正しく機能しているかを確認するテストです。
画面上で適切なアクション実行され、データが表示され、インターフェイス状態が期待どおりに変化するかどうかのチェックを主眼においています。

手動で画面を操作して動作確認を行うUIテストは非常に時間がかかるため、これを自動化することでリリースに掛けるリードタイムを大幅に短縮することができ、大きなメリットとなります。
その反面、自動化したテストは画面の更新があるとそれまで使っていたものが動かなくなったりすることが多々あるのでメンテナンス性の良さが求められます。

そのUIテスト自動化の一環として弊社ではSelenium IDEというツールを使い始めたので、本日はそのツールの導入から実際に使用するところまでの紹介ができればと思います。

Selenium IDEとは?

Selenium IDEとは、SeleniumHQより提供されているオープンソースWebブラウザ操作ツールです。
現在ではChromeFireFox向けに拡張機能として展開されています。
(下記リンクのクリックで各ブラウザで提供されているストアページに遷移します。)
Chrome版Selenium IDE
FireFox版Selenium IDE

Selenium IDEの最大の特徴は、プログラムを直接書かずともブラウザ上の操作ベースで動作の記録、実行が行える部分にあります。
日々のルーティンとなっている入力操作の自動化からHolmesが採用しているようなUIテストまで使い方によって様々な応用がきくスグレモノです。
非エンジニアでもブラウザ操作のみで完結できる手軽さがあるため、導入ハードルは非常に低いと言えるのではないでしょうか。

今回はChromeの操作ベースで解説を行います。

Selenium IDEChromeに追加する

  1. Selenium IDEのストアページにアクセスし、画面上のChromeに追加ボタンを押下

    f:id:yoshiJ:20200520234647p:plain
    ストア画面

  2. 追加を行うかの確認メッセージが出てくるのでそのまま追加を続行 f:id:yoshiJ:20200520235236p:plain

  3. 追加が完了した通知とSelenium IDEを立ち上げるためのアイコンがウィンドウ右上に出現します。

    f:id:yoshiJ:20200521000022p:plain
    ウィンドウが暗色になるテーマを使用していると非常に見づらいアイコンです…

導入はこれで終わりです。拡張機能として提供されているので導入は非常に簡単ですね。

テストの作成(記録)

今回はGoogleでHolmesのホームページを検索してアクセスする作業をテストとして登録する手順で解説します。

  1. まず、導入時に追加されたアイコンをクリックしてSelenium IDEを起動します。立ち上がると別ウィンドウで開かれ、Selenium IDEのスタートメニューが表示されます。 f:id:yoshiJ:20200521001717p:plain
  2. メニューの中からRecord a new test in a new projectを選択し、プロジェクトの命名を行います。好きな名前を付けたらOKボタンを押下します。(プロジェクト名はいつでも変更可能) f:id:yoshiJ:20200521002722p:plain
  3. 登録する作業の開始ページのURLを求められるので入力後Start Recordingを押下。
    f:id:yoshiJ:20200521003213p:plain
    今回は検索するところからスタートするのでGoogleのURLを記入
  4. 別ウィンドウで新たにChromeが立ち上がり、記入したURLのサイトが自動で開かれます。画面右下に動作の録画をしている旨のメッセージが表示されていることを確認し、テストとして登録したい動作を実行していきます。 f:id:yoshiJ:20200521075038p:plain
    f:id:yoshiJ:20200521081042p:plain
    「Holmes」と検索しトップに来ている検索結果をクリック
    f:id:yoshiJ:20200521081142p:plain
    Holmesのトップページが開かれました
  5. 行いたい作業が一通り完了したらSelenium IDEに戻り、画面右上の録画ボタンをクリックして録画を停止します。(もう一度ボタンをクリックすることで続きから録画を再開することも可能です。) f:id:yoshiJ:20200521084820p:plain
  6. 先のステップで録画を行っていたウィンドウの画面右下に動作の録画をしている旨のメッセージが表示されていないことを確認します。
  7. Selenium IDEの画面に戻り、テストに対してタイトルを付けます。
    f:id:yoshiJ:20200521082013p:plain
    画面右の「Untitled*」と表記のある箇所にマウスオーバーするとメニューボタンが表示されるのでクリック
    f:id:yoshiJ:20200521082113p:plain
    「Reneme」をクリック
    f:id:yoshiJ:20200521082512p:plain
    名前を変更するポップアップが開かれるので任意のテスト名を命名して「Rename」をクリック
    f:id:yoshiJ:20200521082641p:plain
    名前が書き換わりました
  8. タイトルが保存できたらプロジェクトの保存を行います。画面右上のフロッピーディスクのアイコンから行います。
    f:id:yoshiJ:20200521083352p:plain
    アイコンにマウスオーバーするとSave projactと表示されるのでそのままクリック
    f:id:yoshiJ:20200521083912p:plain
    任意のファイル名を付けてPCに保存
  9. これで一通りの作成作業が完了です。次に起動した際に作成したプロジェクトを開くときはSelenium IDEの起動時画面の「Open an exsting project」をクリックするか、編集画面の右上の既存プロジェクトを開くアイコンをクリックすることで立ち上げることが出来ます。 f:id:yoshiJ:20200521084630p:plain f:id:yoshiJ:20200521084648p:plain

補足

新たにテストを追加する場合

プロジェクト内に新たに別のテストを追加したい場合は画面左側のメニューから行います。
メニューの項目名がTestsになっていることを確認してその隣にあるプラスボタンを押下することで新規テストの追加ができます。
テスト名を付けて新規テストが開けたら画面右上の録画ボタンをクリックすることで新たにテスト登録を行う事ができます。 f:id:yoshiJ:20200521100435p:plain

テストの実行

作成が完了したら実際にテストを実行して結果を確認してみます。

  1. 実行したいテストを開き、画面左上あたりに配置されているボタン群の中から表示しているテストの実行を行う三角形のボタンをクリックします。
    • テスト処理の実行速度は実行環境や確認の運用に合わせて設定が可能です。テスト実行ボタンの右側にあるボタンから調整できます。(デフォルトだと最速に設定されています。) f:id:yoshiJ:20200521102857p:plain
  2. すると別ウィンドウが立ち上がり、記録されたテストが自動で実行されます。
  3. テストの処理が完了するとSelenium IDEの画面に実行が完了した処理と実行ログが表示されます。 ログの結果が全てグリーンの表示になっていればすべての処理が正常に完了しています。ログに赤い表示が入っている場合は何処かの処理で失敗しているので、エラーメッセージを確認して失敗している箇所のテストの見直しが必要になります。
    ログは画面右下の削除ボタンからいつでも消すことができるのでテスト毎に消去すると実行結果が見やすくなります。
    f:id:yoshiJ:20200521111301p:plain
    テストの完了後画面

テストの修正

もしテストに失敗したり、記録時に余計な動作を追加してしまった際や入力値の変更を間違えてしまった場合でも後から修正を加えることが可能です。
例として上記で作ったテストに対して下記の動作を追加するパターンを作って見ていきます。

1. Holmesのページからブラウザの戻るボタンをクリックして検索結果のページに戻る
2. 「Holmes開発者ブログ」と検索
3. 検索結果の一番上に出る当ブログのトップページにアクセスする
  1. 例に記載した操作をブラウザ操作で登録してみました。 f:id:yoshiJ:20200521122136p:plain
  2. このテストを実行すると…
    失敗してしまいます。
    よく見るとブラウザの戻るボタンが記録されていないようです。どうやらSelenuim IDEでは録画する際にブラウザバックのボタンをクリックしても動作として記録されないようですね。 f:id:yoshiJ:20200521122207p:plain
  3. ではどうするかというと、Selenium IDEでは動作としてJavaScriptの記述ができるのでスクリプトを操作として追加していきます。
    まず、コマンドを入れたい位置の下の動作をクリックして選択したあと、Control+クリック(Windowsでは右クリックに相当)でメニューを出し「Insert new command」をクリックすると、新たな操作を登録するための空の行が生成されます。 f:id:yoshiJ:20200521122809p:plain f:id:yoshiJ:20200521123228p:plain
  4. 空の行が生成されたら行をクリックして画面下側のコマンド編集欄でJavaScriptで戻る挙動を登録します。
    Commandの欄にはrun scriptを設定し、Targetの欄にはwindow.history.back(-1);を登録します。
    これで設定完了です。 f:id:yoshiJ:20200521123027p:plain
  5. 再度テストを実行すると…正常に完了しました! f:id:yoshiJ:20200521123608p:plain
  6. 仕上げに余計な動作を削除します。
    12~14行目の操作は不要なので消していきます。
    削除する挙動をクリックし、Control+クリック(Windowsでは右クリックに相当)でメニューを出し「Delete」をクリックすると、指定した行が削除されます。 f:id:yoshiJ:20200521123852p:plain
  7. 不要な動作を消しきったらテストの完成です! f:id:yoshiJ:20200521123931p:plain

このような手順を繰り返して正常に動作するテストを組み上げていきます。

まとめ

以上が導入から作成までの一通りの操作の解説でした。 Selenium IDEの知識がない方でも操作できるように執筆したつもりですが、不明な点やわかりにくい点がありましたらご指摘いただけますと幸いです。

今回は紹介しきれませんでしたが、Selenium IDEではテスト処理の中で関数を使用して分岐や繰り返し処理を登録してテストに幅をもたせることができます。
加えてエクスポート機能が備わっており、JavaPythonRubyなどのコード用に整形して出力できるので既存のテストなどに組み込むことが出来ます。

ブレークポイントでのデバッグや操作する要素を細かく指定することもできるため、実際のUIテストでも十分実用できるレベルのツールだと思います。
使用するハードルも低いため、UIテストツールを検討している方は是非使ってみて下さい。

個人保有ないしは自分の所属する組織のサイト以外のページ巡回、ページ操作を含む処理を短時間に繰り返すと対象のサイトに負荷を与えてしまい、トラブルに発展する場合がありますのでご利用の際には十分に気をつけて行うようお願いします。
本エントリーはそのような操作を助長する意図のものではございません。

おわりに

いかがでしたでしょうか? Selenium IDEの導入検討をしている方の一助になりましたら幸いです。

Holmesはエンジニア・デザイナーを募集しています
興味がある方はぜひこちらからご連絡ください!

lab.holmescloud.com

lab.holmescloud.com

Facebookライクなローディング、スケルトンスクリーンをVue.js、Reactで簡単に実装する

こんにちは!株式会社Holmesでエンジニアをしている平田です。

みなさんスケルトンスクリーンをご存知ですか?
FacebookYouTubeなどで使われているローディングのことをスケルトンスクリーンと言ったりします。
今では様々なWEBサイトでも使われているため、よく目にするのではないでしょうか

f:id:k-hirata:20200604162130g:plain
YouTubeローディング

ケルトンスクリーンを採用することで、以前よく使われていたスピナー等に比べて、
ローディング後に表示される情報を推測できるようになるため、待たされている時間を短く感じさせることができます。
そんなスケルトンスクリーンをVue.jsで簡単に実装できるvue-content-loaderの使い方を紹介します。
これはReact用のreact-content-loaderのvue版みたいなもののため、Reactを使われている方にも参考になると思います。

vue-content-loader使い方

インストール

# yarnを利用している方
yarn add vue-content-loader
# npmを利用している方
npm i vue-content-loader

使い方

使い方はいたって簡単で、下記のようにコンポーネントをif文で切り替えて表示したいコンテンツと差し替えるだけです。

<template>
  <content-loader v-if="isLoading"></content-loader>
</template>

<script>
import { ContentLoader } from 'vue-content-loader'

export default {
  components: {
    ContentLoader
  },
  data: {
    isLoading: true
  }
}
</script>

また、FacebookLoaderやInstagramLoaderなど5種類のローディングがパッケージに含まれているため、簡単に使ってみることもできます。

カスタムローディングの作り方

vue-content-loaderはSVGを使って作成されています。
そのため、先ほどの<content-loader>の子要素に表示したいローディングのSVGを指定するだけで、カスタムローディングが作成できます。

<content-loader width="130" height="40">
    <circle cx="20" cy="20" r="10" />
    <rect x="40" y="15" rx="4" ry="4" width="80" height="10"/>
</content-loader>

これをtemplateとして登録すればいつでも使えるようになります。

f:id:k-hirata:20200604162224g:plain
見本1:アイコン+ユーザー名

注意点としては、fill="none"を使って枠線のみの図形を表示しようとしても、塗りつぶされた図形が表示されます。
これは内部的にはユーザーが指定したSVGをclipPath要素で指定することで表示しているためです。

SVGなので、ellipse(楕円)やpolygon(多角形)、text(文字)も使えます。lineやpolylineなどの線分は、表示されないか、内側が塗りつぶされるためご注意ください。

f:id:k-hirata:20200604162311g:plain
<ellipse cx="60" cy="20" rx="50" ry="10" />
f:id:k-hirata:20200604162352g:plain
<polyline points="40,0 40,80 80,80" />
⬆︎L字を指定していますが三角に塗りつぶされています
f:id:k-hirata:20200604162417g:plain
<polygon points="150,75 179,161 269,161 197,215 223,301 150,250 77,301 103,215 31,161 121,161" />
f:id:k-hirata:20200604162439g:plain
<text x="20" y="20" font-size="30px">Hello World!</text>

また、こちらのオンラインツールを使うと、コーディングせずに図形を配置するだけでローダーを自作することもできます。活用してみてください。

プロパティ

プロパティを指定することでもローディングをカスタムできます。

Prop Type Default Description
width number 400 viewBoxの幅および、ビューポート(表示領域)の幅
height number 130 viewBoxの高さおよび、ビューポート(表示領域)の高さ
speed number 2 アニメーション効果の速度
preserveAspectRatio string 'xMidYMid meet' svgのpreserveAspectRatio属性
primaryColor string '#f9f9f9' 表示ローディングの基本色です
secondaryColor string '#ecebeb' 線形グラデーション(linearGradient)の中間色です。アニメーションでこちらの色が駆け抜けて見えます。
uniqueKey string randomId() SSRをしている場合、こちらを指定しないと、randomIdが2度生成されてしまい、ローディングが一瞬で消えてしまいます。
animate boolean true アニメーションさせるかを指定できます
baseUrl string empty string <head /><base url />を指定している場合、同様に指定する必要があります。
primaryOpacity number 1 primaryColorのopacityを指定できます。
secondaryOpacity number 1 secondaryColorのopacityを指定できます。

<content-loader width="130" height="40" primaryColor="red" secondaryColor="blue" :speed="5">
    <circle cx="20" cy="20" r="10" />
    <rect x="40" y="15" rx="4" ry="4" width="80" height="10"/>
</content-loader>

f:id:k-hirata:20200604162510g:plain
見本2

おわりに

いかがでしたでしょうか。
図形の組み合わせで簡単に実装できると思いますので、ぜひコンテンツにあったオリジナルなローディングを作って活用してください。

株式会社Holmesではエンジニア・デザイナーを募集しています 。
興味がある方はこちらからご連絡ください!

lab.holmescloud.com

lab.holmescloud.com

spring-boot-starter-scim2を使ってSCIM APIを作る

こんにちは、id:c-terashimaです

tl;dr

SCIMとはプロビジョニングやデプロビジョニング用のアカウント・グループ情報をRESTful APIで操作するプロトコルです
基幹システムで管理しているアカウント情報を別システムに流し込むのに利用され、ホームズクラウドでも6月にリリース予定です

spring-boot-starter-scim2の利用方法をまとめていこうと思います

参考

spring-boot-starter-scim2とは

SpringBoot上で SCIM2 SDK を利用するためのOSSになります
導入することで以下のメリットがあると考え導入しました

  • Request/ResponseのEntity
  • ServiceProvider、ResourceTypesなどのエンドポイントの自動生成
  • フィルター文字列の解析

github.com

環境

開発環境は以下の通りです

  • Java 8
  • Gradle 5.4.1
  • Kotlin 1.3.71
  • SpringBoot 2.2.6 RELEASE

build.gradle

以下のようにdependencyを追加します

dependencies {
    implementation 'com.bettercloud:spring-boot-starter-scim2:1.0.0'
    implementation 'com.bettercloud:scim2-sdk-common:1.0.0'
    implementation "com.unboundid.product.scim2:scim2-sdk-common:2.3.3"
    implementation "com.unboundid.product.scim2:scim2-sdk-server:2.3.3"
}

com.bettercloudでは足りない機能をcom.unboundidで補う必要があるため、追加します

エンドポイント

spring-boot-starter-scim2 は以下のエンドポイントを自動で出力してくれます
ServiceProviderConfigapplication.yml に出力する情報を記載するだけでOKで、残りの2つは@ScimResourceをControllerクラスに付与するだけです

@ScimResource(description = "Access User Resources", name = "User", schema = UserResource::class)
  • /ServiceProviderConfig
  • /ResourceTypes
  • /Schemas
scim2:
  service-provider-config:
    documentationUri: http://www.simplecloud.info
    patch:
      supported: true
    bulk:
      supported: true
      maxOperations: 1000
      maxPayloadSize: 10000
    filter:
      supported: true
      maxResults: 100
    change-password:
      supported: false
    sort:
      supported: true
    etag:
      supported: false
    authenticationSchemes:
      - name: SCIM
        description: SCIM
        specUri: http://localhost:8080
        documentationUri: http://localhost:8080
        type: oauthbearertoken
        primary: true

各項目についてはGithubに説明がありますので、そちらをご覧いただければと思います

github.com

/ServiceProviderConfigのResponse JSON

{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
  ],
  "patch": {
    "supported": true
  },
  "bulk": {
    "supported": false,
    "maxOperations": 1000,
    "maxPayloadSize": 10000
  },
  "filter": {
    "supported": true,
    "maxResults": 100
  },
  "changePassword": {
    "supported": false
  },
  "sort": {
    "supported": false
  },
  "etag": {
    "supported": false
  },
  "meta": {
    "resourceType": "ServiceProviderConfig",
    "location": "http://localhost:8080/ServiceProviderConfig"
  }
}

/ResourceTypesのResponse JSON

{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "id": null,
  "externalId": null,
  "meta": null,
  "totalResults": 2,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
      ],
      "id": "User",
      "name": "User",
      "description": "Access User Resources",
      "endpoint": "/Users",
      "meta": {
        "resourceType": "ResourceType",
        "location": "http://localhost:8080/ResourceTypes/User"
      }
    },
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
      ],
      "id": "Group",
      "name": "Group",
      "description": "Access Group Resources",
      "endpoint": "/Groups",
      "meta": {
        "resourceType": "ResourceType",
        "location": "http://localhost:8080/ResourceTypes/Group"
      }
    }
  ],
  "startIndex": 1,
  "itemsPerPage": 2
}

/SchemasのResponse JSON

出力量が多いので折りたたんでおります

JSONを見る

{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "id": null,
  "externalId": null,
  "meta": null,
  "totalResults": 1,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:Schema"
      ],
      "id": "urn:ietf:params:scim:schemas:core:2.0:User",
      "name": "User",
      "description": "User Account",
      "attributes": [
        {
          "name": "active",
          "type": "boolean",
          "multiValued": false,
          "description": "A Boolean value indicating the User's administrative status.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "addresses",
          "type": "complex",
          "subAttributes": [
            {
              "name": "country",
              "type": "string",
              "multiValued": false,
              "description": "The country name component.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "formatted",
              "type": "string",
              "multiValued": false,
              "description": "The full mailing address, formatted for display or use with a mailing label. This attribute MAY contain newlines.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "locality",
              "type": "string",
              "multiValued": false,
              "description": "The city or locality component.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "postalCode",
              "type": "string",
              "multiValued": false,
              "description": "The zipcode or postal code component.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred address. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "region",
              "type": "string",
              "multiValued": false,
              "description": "The state or region component.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "streetAddress",
              "type": "string",
              "multiValued": false,
              "description": "The full street address component, which may include house number, street name, PO BOX, and multi-line extended street address information. This attribute MAY contain newlines.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'work' or 'home'.",
              "required": false,
              "canonicalValues": [
                "other",
                "work",
                "home"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "Physical mailing addresses for this User.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "displayName",
          "type": "string",
          "multiValued": false,
          "description": "The name of the User, suitable for display to end-users. The name SHOULD be the full name of the User being described if known.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "emails",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred mailing address or primary e-mail address. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'work' or 'home'.",
              "required": false,
              "canonicalValues": [
                "other",
                "work",
                "home"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "E-mail addresses for the user. The value\nSHOULD be canonicalized by the Service Provider, e.g.\nbjensen@example.com instead of bjensen@EXAMPLE.COM. Canonical Type\nvalues of work, home, and other.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "E-mail addresses for the user. The value SHOULD be canonicalized by the Service Provider, e.g., bjensen@example.com instead of bjensen@EXAMPLE.COM. Canonical Type values of work, home, and other.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "entitlements",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "The value of an entitlement.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "A list of entitlements for the User that represent a thing the User has.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "groups",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "$ref",
              "type": "reference",
              "multiValued": false,
              "description": "The URI of the corresponding Group resource to which the user belongs",
              "required": false,
              "caseExact": true,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none",
              "referenceTypes": [
                "Group",
                "User"
              ]
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'direct' or 'indirect'.",
              "required": false,
              "canonicalValues": [
                "indirect",
                "direct"
              ],
              "caseExact": false,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "The identifier of the User's group.",
              "required": false,
              "caseExact": false,
              "mutability": "readOnly",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "A list of groups that the user belongs to, either thorough direct membership, nested groups, or dynamically calculated.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "ims",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred messenger or primary messenger. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'aim', 'gtalk', 'mobile' etc.",
              "required": false,
              "canonicalValues": [
                "qq",
                "skype",
                "gtalk",
                "aim",
                "icq",
                "yahoo",
                "msn",
                "xmpp"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "Instant messaging address for the User.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "Instant messaging addresses for the User.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "locale",
          "type": "string",
          "multiValued": false,
          "description": "Used to indicate the User's default location for purposes of localizing items such as currency, date time format, numerical representations, etc.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "name",
          "type": "complex",
          "subAttributes": [
            {
              "name": "familyName",
              "type": "string",
              "multiValued": false,
              "description": "The family name of the User, or Last Name in most Western languages (for example, Jensen given the full name Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "formatted",
              "type": "string",
              "multiValued": false,
              "description": "The full name, including all middle names, titles, and suffixes as appropriate, formatted for display (for example, Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "givenName",
              "type": "string",
              "multiValued": false,
              "description": "The given name of the User, or First Name in most Western languages (for example, Barbara given the full name Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "honorificPrefix",
              "type": "string",
              "multiValued": false,
              "description": "The honorific prefix(es) of the User, or Title in most Western languages (for example, Ms. given the full name Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "honorificSuffix",
              "type": "string",
              "multiValued": false,
              "description": "The honorific suffix(es) of the User, or Suffix in most Western languages (for example, III. given the full name Ms. Barbara J Jensen, III.)",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "middleName",
              "type": "string",
              "multiValued": false,
              "description": "The middle name(s) of the User (for example, Robert given the full name Ms. Barbara J Jensen, III.).",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": false,
          "description": "The components of the user's real name.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "nickName",
          "type": "string",
          "multiValued": false,
          "description": "The casual way to address the user in real life, e.g.'Bob' or 'Bobby' instead of 'Robert'. This attribute SHOULD NOT be used to represent a User's username (e.g., bjensen or mpepperidge)",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "password",
          "type": "string",
          "multiValued": false,
          "description": "The User's clear text password. This attribute is intended to be used as a means to specify an initial password when creating a new User or to reset an existing User's password.",
          "required": false,
          "caseExact": false,
          "mutability": "writeOnly",
          "returned": "never",
          "uniqueness": "none"
        },
        {
          "name": "phoneNumbers",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred phone number or primary phone number. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'work' or 'home' or 'mobile' etc.",
              "required": false,
              "canonicalValues": [
                "other",
                "pager",
                "work",
                "mobile",
                "fax",
                "home"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "Phone number of the User",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "Phone numbers for the User.  The value SHOULD be canonicalized by the Service Provider according to format in RFC3966 e.g., 'tel:+1-201-555-0123'.  Canonical Type values of work, home, mobile, fax, pager and other.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "photos",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred messenger or primary messenger. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function; e.g., 'photo' or 'thumbnail'.",
              "required": false,
              "canonicalValues": [
                "thumbnail",
                "photo"
              ],
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "reference",
              "multiValued": false,
              "description": "URI of a photo of the User.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none",
              "referenceTypes": [
                "external"
              ]
            }
          ],
          "multiValued": true,
          "description": "URIs of photos of the User.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "preferredLanguage",
          "type": "string",
          "multiValued": false,
          "description": "Indicates the User's preferred written or spoken language.  Generally used for selecting a localized User interface. e.g., 'en_US' specifies the language English and country US.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "profileUrl",
          "type": "reference",
          "multiValued": false,
          "description": "A fully qualified URL to a page representing the User's online profile",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none",
          "referenceTypes": [
            "external"
          ]
        },
        {
          "name": "roles",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "string",
              "multiValued": false,
              "description": "The value of a role.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "A list of roles for the User that collectively represent who the User is; e.g., 'Student', 'Faculty'.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "timezone",
          "type": "string",
          "multiValued": false,
          "description": "The User's time zone in the 'Olson' timezone database format; e.g.,'America/Los_Angeles'",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "title",
          "type": "string",
          "multiValued": false,
          "description": "The user's title, such as \"Vice President\".",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "userName",
          "type": "string",
          "multiValued": false,
          "description": "Unique identifier for the User typically used by the user to directly authenticate to the service provider.",
          "required": true,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "server"
        },
        {
          "name": "userType",
          "type": "string",
          "multiValued": false,
          "description": "Used to identify the organization to user relationship. Typical values used might be 'Contractor', 'Employee', 'Intern', 'Temp', 'External', and 'Unknown' but any value may be used.",
          "required": false,
          "caseExact": false,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        },
        {
          "name": "x509Certificates",
          "type": "complex",
          "subAttributes": [
            {
              "name": "display",
              "type": "string",
              "multiValued": false,
              "description": "A human readable name, primarily used for display purposes.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "primary",
              "type": "boolean",
              "multiValued": false,
              "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.",
              "required": false,
              "caseExact": true,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "type",
              "type": "string",
              "multiValued": false,
              "description": "A label indicating the attribute's function.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            },
            {
              "name": "value",
              "type": "binary",
              "multiValued": false,
              "description": "The value of a X509 certificate.",
              "required": false,
              "caseExact": false,
              "mutability": "readWrite",
              "returned": "default",
              "uniqueness": "none"
            }
          ],
          "multiValued": true,
          "description": "A list of certificates issued to the User.",
          "required": false,
          "caseExact": true,
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none"
        }
      ],
      "meta": {
        "resourceType": "Schema",
        "location": "http://localhost:8080/Schemas/urn:ietf:params:scim:schemas:core:2.0:User"
      }
    }
  ],
  "startIndex": 1,
  "itemsPerPage": 1
}

UsersとGroups

ユーザとグループのCRUDは実装する必要があります

Get

単体複数のリソースを取得する2つのエンドポイントを作る必要があります
複数のリソースを取得するエンドポイントはFilter指定が可能で、以下のようなクエリパラータでアクセスされます

/Users?filter=userName eq "user name"

eqequalの略で他に指定される条件は以下のようになっています

Operator Description
eq equal
co contains
sw starts with
pr present value
gt greater than
ge greater than or equal
lt less than
le less than or equal
and logical And
or logical Or

これらの条件を独自に実装し絞り込むのはなかなか大変ではありますが、条件解析もフレームワークが用意してくれています
パラメータで受け取った絞り込み文字列をFilterクラスを通して絞り込みを行います

@GetMapping
fun search(request: HttpServletRequest, @ModelAttribute searchRequest: SearchRequest
           , @RequestParam(value = ApiConstants.QUERY_PARAMETER_FILTER, required = false) filterString: String?
): ResponseEntity<ListResponse<GenericScimResource>> {
    // 対象全リソース取得
    val resources = getResources()

    // 絞り込み
    val filter: Filter? =
        if(filterString != null) Filter.fromString(filterString) else null
    val result = if(filter != null) {
        resources.filter { it ->
            FilterEvaluator.evaluate(filter, it.objectNode)
        }
    } else resources

    val listResponse = ListResponse(result.size, result, 1, result.size)

    return ResponseEntity.ok(listResponse)
}

POST

こちらはユーザやグループを登録するのですが、UserResourceに予めValidation設定が記載されているので、SchemaCheckerを利用して入力チェックを行うことができます

@PostMapping
fun create(request: HttpServletRequest, @RequestBody data: UserResource): ResponseEntity<GenericScimResource> {
    parameterValidation(data)

    val response = createResource(data)
    return ResponseEntity.created(createLocation(response.id)).body(response)
}

private fun parameterValidation(data: UserResource) {
    val coreSchema = getSchema()
    val schemaExtensions = getSchemaExtensions()

    val builder =
            ResourceTypeDefinition.Builder("test", "/test")
                    .setCoreSchema(coreSchema)
                    .addOptionalSchemaExtension(schemaExtensions)

    val resourceTypeDefinition: ResourceTypeDefinition = builder.build()
    val checker = SchemaChecker(resourceTypeDefinition)
    val resource = checker.removeReadOnlyAttributes(JsonUtils.valueToNode(data))
    val results = checker.checkCreate(resource)
    if (results.syntaxIssues.isNotEmpty()) {
        throw BadRequestException.invalidSyntax(results.syntaxIssues.joinToString())
    }
}

PATCH

リソースを一部更新するのに利用されます
以下はグループの名前変更メンバー追加を行っており、複数の更新をサポートする必要があります

{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [
        {
            "op": "replace",
            "path": "displayName",
            "value": "1879db59-3bdf-4490-ad68-ab880a269474updatedDisplayName"
        },
        {
            "op": "add",
            "path": "members",
            "value": [{
                "$ref": null,
                "value": "f648f8d5ea4e4cd38e9c"
            }
    ]
}

opには以下の3つが利用可能でEnumで定義されています

  • add
  • replace
  • remove
@PatchMapping("/{id}")
fun update(request: HttpServletRequest, @PathVariable("id") id: String
          , @RequestBody data: PatchRequest): ResponseEntity<Void> {

    updateResource(data, id)
    return ResponseEntity.noContent().build()
}

総評

exmapleも少なくGitHubユニットテストを解析しながらの作業でしたが、手間のかかるRequest/Responseクラスの作成や標準エンドポイントの自動出力など大変助かることが多かったかと思います
BulkUsersGroupsも標準規約があるのでフレームワークで用意してくれていてもいいのかなと感じました
時間があればPull Requestで改善を試みたいと思います

エンジニア・デザイナーを募集しておりますので、ご興味がある方はこちらからご連絡ください。

lab.holmescloud.com

lab.holmescloud.com