ContractS開発者ブログ

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

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

@SpringBootTestを使ったSpockテストが遅いので、Gradleでテストをカテゴリ分けして起動を高速化する

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

以前の記事でも言及があったように、現在はユニットテストの起動および実行に非常に時間がかかっています。

最もコード量が多いプロジェクトで、ユニットテストの起動に約2分、実行には数十分かかります。

理由としては、Spring Bootを使用したJavaプロジェクトにて、ControllerやServiceに対して @SpringBootTest を付与したテストを行っているためです。

中でもControllerに対しては WebEnvironment.RANDOM_PORT を指定した結合テストとして記述されているため、 gradle test --tests <className> などでControllerのテストを除外しないと、Webサーバーや組み込みDBの起動が行われ、テスト起動および実行に時間がかかってしまいます。

完了まで数十分では、変更の都度すべてのテストをローカルで実行というわけにはいかず、変更したクラスに対応するテストのみ修正する、という運用になりがちです。結果、CIから実行されたテストの失敗が、度々起こりました。

単体レベルのテストは、簡単に実行して30秒以内には結果が出てほしいので、結合レベルのテストを除外してテストしたいと考え、SpockとGradleで実現する方法を調査しました。

テストのカテゴリ分け

テストサイズの考え方を参考に、Smallテストを導入したいと思います。

Spockのテストのカテゴリ分けについては、JUnit@Category がそのまま使用できます。

任意のクラスをカテゴリ分けに使用できますが、マーカーとしてわかりやすいよう、テストサイズに対応したクラスを作成します。

package com.holmescloud

final class SmallTests {

    private SmallTests() {
    }

}

POJOEnum、依存クラスをモック化したServiceなど、Springに依存しないテストクラスに対して、 @Category(SmallTests.class) を付与しておきます。

smallTestタスクの作成

続いて、Gradleのタスクとして実行できるよう、build.gradleに以下のタスク定義を追加します。

task smallTest(type: Test, dependsOn: testClasses) {
    useJUnit {
        includeCategories 'com.holmescloud.SmallTests'
    }
}

これで gradle smallTest を実行すると、@Category(SmallTests.class) を付与したテストクラスのみが実行されます。

ログ出力の追加

このままでは実行経過がわかりにくいため、 afterSuite にて lifecycle レベルのログ出力を追加します。

除外したテストも afterSuite に渡されますが、それらは第2引数 TestResultTestResult#getTestCount が0となるため除外しています。

また、クラス単位、Test Executor単位、タスク単位の結果が渡されますが、Test Executor単位の結果は不要と思い、除外しています。

Test Executorの場合、第1引数の TestDescriptor のうち、 TestDescriptor#getClassName がnull、かつ TestDescriptor#getParent が存在するため、それで判定します。

それ以外は成功件数とテスト件数、経過時間と表示名を出力するよう、タスク定義に追記します。

task smallTest(type: Test, dependsOn: testClasses) {
    useJUnit {
        includeCategories 'com.holmescloud.SmallTests'
    }

    afterSuite { desc, ret ->
        if (!ret.testCount || (!desc.className && desc.parent)) {
            return
        }

        def retSec = (ret.endTime - ret.startTime) / 1000
        def retCount = "${ret.successfulTestCount}/${ret.testCount}"
        def retMsg = "[${ret.resultType}] ${retCount}, ${retSec}s"
        logger.lifecycle "${retMsg}: ${desc.displayName}"
    }
}

一度コンパイルした状態で、 gradle cleanCompileTestGroovy smallTest し、テストクラスのコンパイルおよびカテゴリ分けしたテストの実行時間を確認しました。以下のように、クラスごとの結果とタスクの総計が出力されます。

> Task :smallTest
[SUCCESS] 14/14, 0.981s: <className>
[SUCCESS] 13/13, 0.055s: <className>
[SUCCESS] 8/8, 0.041s: <className>
[SUCCESS] 35/35, 5.539s: Gradle Test Run :smallTest

BUILD SUCCESSFUL in 29s

smallTestタスクにおける経過時間の総計と各クラスごとの合計の差は、カテゴリ分けされていないテストの読み飛ばし時間です。

対象となるテストクラス数が少ないこともありますが、Webサーバーの起動が行われないため、タスクの実行時間を5.5秒まで短縮できました。テストクラスのコンパイルを含めても約30秒と、許容できる時間に収まったかと思います。

今後の課題

ひとまずテストをカテゴリ分けし、選択的に実行可能となりましたが、全てのテストを実行したときの遅さは解消されていません。

個人的には以下のように順を追って、少しずつ解消していければと考えています。

  1. Serviceについては、 @SpringBootTest は使わず、依存クラスをモックとしたシンプルな単体テストを記述し、Smallテスト扱いとする
  2. Controllerについては、 @SpringBootTest は使わず、 @WebMvcTest を利用し、依存するServiceなどは @MockBean などでモック化したテストを記述、Mediumテスト扱いとする
  3. Repositoryについては、組み込みDBを用いたテストを記述し、Mediumテスト扱いとする

また、Spring Bootアプリケーションのテストに関する私の知識はv1.5ごろのものです。v2になって以降、より良いテスト方法があるかもしれないので、適宜調査していきたいと思います。

最後に

Holmesではバックエンドだけでなく、フロントエンドでもテスト記述を推進しております。

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

lab.holmescloud.com

lab.holmescloud.com

参考資料

testing.googleblog.com

www.slideshare.net

qiita.com

qiita.com

Vuex + TypeScriptで非同期通信のサンプルを作りました

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

Holmesのアプリケーションは、フロントエンドを主にThymeleaf + JQueryで構築しています。
今後はSPA(Single Page Application)に切り替えます。それにともないNuxtJS + TypeScriptを採用することになりました。
現在、NuxtJSおよびVue.jsを鋭意勉強中です。

まずはNuxtJSの公式ドキュメントを読むところから始めました。
読む→作って試す、と繰り返していたのですが、いまいちうまく試せなかったのがVuexでした。 公式通りであれば問題なかったのですが、実現したいのは以下の条件でした。

  • Actions, Mutations, Stateを定義
  • TypeScript(Interface)
  • 非同期通信(GET/POST)

この条件で公開されているコードを調べました。
何かの条件が欠けていることが多かったのですが、見つけたのがGoogle-Books-APIです。

こちらのコードを元にしてサンプルを作ってみました。
Vuexの大まかな流れを理解できるかと思います。

Vuexとは

詳しくは下記のVuexの公式ドキュメントを見て頂ければと思います。
Vuexはコンポーネントが共通で持てるStoreであり、コントローラーになります。
ただし「共通の状態を共有する複数のコンポーネントを持ったときに、すぐに破綻します」 とあるように使い方は難しいです。
今回はこの点は置いておいて全体像を掴むことにしました。

Vuexの構成

f:id:w-miuchi:20200422090202p:plain
"単方向データフロー"のコンセプト
Stateを扱うのはMutations、そのMutationsを操作するのはActionsとなります。
作成したサンプルにおいては以下の役割としました。

Actions

非同期処理(GETやPOST)を記述する。
またComponentから呼ぶ場合、受け渡すパラメータはインターフェースを用意する。

Mutations

非同期処理(Actions)の処理結果をStateに保存する。
Componentからは呼ばない。

State

Componentから呼ばれる。
Stateにもインターフェースを用意する。

サンプル

作ったサンプルは、以下の画面2つです。
今回はアカウント一覧画面を紹介します。

  1. アカウント一覧画面

    • 非同期でアカウント一覧情報を取得すること
    • 取得時はローディングすること
    • 検索機能があること
  2. アカウント編集画面

    • URIにidを含むこと
    • idを元に非同期でアカウント情報を取得すること
    • 名前を編集すること
    • 非同期でアカウント情報を更新すること

Store

ファイル構成

store
├── account
│   ├── edit.ts
│   ├── search.ts
│   └── types.ts
├── dummy.ts
├── store.ts
└── types.ts

インターフェースを設定

store/account/types.ts

// Account
export interface Account {
  id: string
  name: string
}

// Account検索結果
export interface SearchAccountState {
  keywords: string
  accounts: Account[]
  total: number
  page: number
  isLoading: boolean
  isThere: boolean
  isError: boolean
}

// Account検索用パラメータ 
export interface SearchAccountsPayloadObj {
  keyword: string
  page: number
}

インタフェースの定義はわかりやすくできました。
HolmesのようにサーバーサイドにJavaの使っている場合、インターフェースは理解しやすいと思います。またサーバーサイドと定義を合わせられるのもメリットです。 またローディングやページングのような共通化できるものはインターフェースにした方がいいでしょう。

RootStateにModuleとして設定

store/types.ts

import { SearchAccountState, EditAccountState } from './account/types';

export interface RootState {
  // アカウント一覧
  SearchAccountModule: SearchAccountState,
  // アカウント編集
  EditAccountModule: EditAccountState
}

検索結果と考えるとRootStateに設定することは考えどころです。
同じComponentを複数設置すると、検索結果まで同期してしまいます。

Vuex.Storeを設定

store/store.ts

import Vue from 'vue';
import Vuex from 'vuex';
import { RootState } from './types';

Vue.use(Vuex);

export default new Vuex.Store<RootState>({});

Mutaions, Actionsの処理

store/account/search.ts

import { Module, VuexModule, Mutation, Action, getModule, } from 'vuex-module-decorators'
import { Account, SetSearchAccountsObj, SearchAccountsPayloadObj } from './types';
import store from '../store';
import dummy from '../dummy';

@Module({dynamic: true, store, name: 'SearchAccountModule', namespaced: true})
class SearchAccountModule extends VuexModule {
  // Store
  public keyword: string = '';
  public accounts: Account[] = [];
  public total: number = 0;
  public page: number = 0;
  public isLoading: boolean = false;
  public isThere: boolean = true;
  public isError: boolean = false;

  // 検索結果の保存
  @Mutation
  public setSearchAccounts(payload: SetSearchAccountsObj): void {
    this.accounts = payload.accounts;
    this.page += 1;
    this.total = payload.total;
  }

  // 検索キーワードの保存
  @Mutation
  public setSearchKeywords(payload: string): void {
    this.keyword = payload
  }

  // 検索結果の初期化
  @Mutation
  public resetSearchAccounts(): void {
    this.keyword = '';
    this.accounts = [];
    this.total = 0;
    this.page = 0;
  }

  // 検索結果取得中のローディング
  @Mutation
  public setLoading(payload: boolean): void {
    this.isLoading = payload
  }

  // 検索結果の有無
  @Mutation
  public setThere(payload: boolean): void {
    this.isThere = payload
  }

  // 検索結果取得のエラー有無
  @Mutation
  public setError(payload: boolean): void {
    this.isError = payload
  }

  // 検索結果取得
  @Action({})
  public async getAccounts(payload: SearchAccountsPayloadObj): Promise<void> {
    const { data, error }: any = await
      new Promise(res => 
        // 非同期処理
        setTimeout( () => {
          const items: Account[] = dummy.accounts.filter((item: Account) => {
            return item.name.match(new RegExp(payload.keyword, 'ig'));
          });
          res({
              data: {
                total: items.length,
                items: items
              },
              error: null
            }
          )
        }, 1000));
    this.setLoading(false);

    // 検索結果のStore保存
    if (data) {
      const info: SetSearchAccountsObj = {
        total: 0,
        accounts: [],
      };
      if (data.total !== 0) {
        info.total = data.total;
        info.accounts = data.items;
        this.setSearchAccounts(info);
        this.setThere(true)
      } else {
        this.setSearchAccounts(info);
        this.setThere(false)
      }
      this.setError(false)
    } else {
      this.resetSearchAccounts();
      this.setError(true)
    }
  }
}

export default getModule(SearchAccountModule)

こちらの@Mutationや@Actionというアノテーションの書き方はvuex-module-decoratorsを利用しています。
@MutationはStateの設定処理になります。
@Actionはダミーの情報をsetTimeoutで取得していますが、axiosを利用した通信に変わることを想定しています。
ダミーの情報は以下になります。

store/dummy.ts

export default {
  accounts: [
    {id: '1', name: 'Giorno Giovanna'},
    {id: '2', name: 'Bruno Bucciarati'},
    {id: '3', name: 'Guido Mista'},
    {id: '4', name: 'Leone Abbacchio'},
    {id: '5', name: 'Narancia Ghirga'},
    {id: '6', name: 'Pannacotta Fugo'},
  ]
};

Component

<template>
  <div class="AccountBlock">
    <div class="AccountBlock--header">
      <div class="AccountBlock--title">Account List</div>
      <a class="AccountBlock--refresh" @click="fetchAccounts">Refresh</a>
      <form @submit="search"><input class="AccountBlock--inputKeyword" type="text" placeholder="Keyword" v-model="keyword"></form>
    </div>
    <div class="AccountBlock--item" v-for="(account, index) in accountItems">
      <div class="AccountBlock--itemName">{{ account.name }}</div>
      <div class="AccountBlock--itemEdit"><nuxt-link v-bind:to="{name:'account-edit-id',params:{id:account.id}}">Edit</nuxt-link></div>
      <div class="AccountBlock--itemRemove"><a href="/remove">Remove</a></div>
    </div>
    <div class="AccountBlock--Loading" v-show="isLoading">Loading...</div>
    <div class="AccountBlock--noData" v-show="!isThere">No data.</div>
  </div>
</template>

<script lang="ts">
  import { Vue, Component } from 'vue-property-decorator';
  import { Account, SearchAccountsPayloadObj } from '../../store/account/types';
  import SearchAccountModule from '../../store/account/search';

  @Component
  export default class SearchAccounts extends Vue {
    protected keyword: string = '';
    // アカウント一覧の取得
    protected get accountItems(): Account[] {
      return SearchAccountModule.accounts;
    }
    // ローディングの取得
    protected get isLoading(): boolean {
      return SearchAccountModule.isLoading;
    }
    // アカウント一覧有無の取得
    protected get isThere(): boolean {
      return SearchAccountModule.isThere;
    }
    // アカウント取得エラーの有無
    protected get isError(): boolean {
      return SearchAccountModule.isError;
    }
    // アカウント取得
    protected async fetchAccounts(): Promise<void> {
      await SearchAccountModule.resetSearchAccounts();
      SearchAccountModule.setLoading(true);
      SearchAccountModule.setThere(true);
      const data: SearchAccountsPayloadObj = {
        keyword: this.keyword,
        page: 0,
      };
      await SearchAccountModule.getAccounts(data);
      SearchAccountModule.setSearchKeywords(data.keyword)
    }
    created() {
      this.fetchAccounts();
    }
    search(e: any) {
      e.preventDefault();
      this.fetchAccounts();
    }
  }
</script>

レンダリングはStateを利用し、
非同期処理のアカウント情報取得はActionsを使用できるようになりました。

所感

Vuexの処理の流れや全体像を掴むことができました。 ただし、Storeにどの情報を持たせるべきかを考えて設計する必要があることがわかりました。

またサンプル作成のために他にもコードを調べましたが、
Vuexは作りたいものによって構造や書き方が様々でした。
非同期通信の有無やJavaScript OR TypeScriptでも大きく異なります。

ここからは、アプリケーションの構造に合わせた設計で考えて行きたいと思います。

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

lab.holmescloud.com

lab.holmescloud.com

新米エンジニアがTDD(テスト駆動開発)を、約3ヶ月間行なって感じたこと

2019卒で入社した、Holmesの倉島です。(サーバーサイドエンジニア)

2020年の1月から、自社でTDD(テスト駆動開発)を推進していく方向になりました。 そこで、私が開拓を任されましたので、ブログ記述時現在(2020年4月)まで約3ヶ月間TDDを行なった感想を述べたいと思います。

TDD開始時点

エンジニアとしての経験が浅いので、まずTDDとは何者かを知るところから始めました。
参考にさせていただいた資料:

channel9.msdn.com

dev.classmethod.jp


理解したこととしては...
  ・テストコードを記述してから実装をすること
  ・RED→GREEN→リファクタリング の3つのサイクルに沿って開発を行うこと
RED:落ちるテストを記述
GREEN:とにかくテストが落ちない実装をする
リファクタリング:テストが落ちない状態を維持して、意味のある実装にする



以上を意識して開発を行なってみました。
結果的に、プロダクトのカバレッジが15%上昇しました!
以下は行ってみての感想です。


よかったこと

1.テストコードを記述しない、という事が減った

テストコードを後に回してしまうと、記述し忘れや、時間の問題でテストコードが記述されないままになってしまう事がありました。TDDであればテストコードを記述してから実装を行うので、記述しないということは無くなっていきました。

2.仕様の確認をしながら実装することができる。

テストコードを記述するにあたって考えなくてはならない事が、「処理を通して如何なる結果を期待するか」であるので、何をしたいかが明確になる。私は「何から作ればいいんだ?」と思う事が多々あるので非常に有効でした。

3.画面(フロント)が完成する前に、サーバー側の動きをチェックできる

画面上から動かす事ができなくとも、処理の動きは確認する事ができるので、フロント側とサーバー側に作業のズレがあったとしても一定の処理の保証ができることが大きなメリットだと感じました。

4.既存処理の改修(リファクタリング)、仕様変更が楽。

テストコードが記述されていると、その処理は何をする処理なのかが理解しやすいし、既存のテストが落ちないようにリファクタリングをすれば良いので、悩む時間が(体感)減ったと感じました。

よくなかったこと

1.セットアップコードが多い場合やデータ関連が複雑な場合は時間がかかる

処理を動かすためのデータを用意する時間がとても多くなって、時間対効果が見込めなくなることがありました。

2.処理の結果が不鮮明な場合のテストコードが雑になりがち

処理の結果が不鮮明であると、テストで期待する結果が書きづらかった。例として、受け取ったデータを別の処理に送るだけの処理が挙げられます。結局、そういった処理は期待する結果を「エラーが出ない事」のみにしてしまうケースがありました。

3.テストの起動がすこぶる遅いので、時間がかかる(環境依存)

Holmesの開発環境に限った話ではあると思いますが、 Spring Boot を使用して起動を行なっていることに加え、DIコンテナで管理しているクラスの量が多いのでテストの起動がとても遅いです。一回の起動に2分ほどかかることもありました。

まとめ

TDDを行う上で最も気を使ったことは、どこまで端折りができるかだと思います。というのも、TDDのサイクルは細かくしようと思えばどこまでも細かい単位で行えてしまいます。この部分のこのテストは不要、と適切に判断し不要なテストを削っていくことがTDDにおいて大切なことだと感じました。 TDDを行なって、享受できるメリットは多いと思います。個人的には仕様の確認をしながら実装ができることが大きなメリットだと思いました。 また、上述の「2.処理の結果が不鮮明な場合のテストコードが雑になりがち」のようにどんな場面でも効果を発揮するものではないことがわかりました。

終わりに

私の感想が、TDD理解、導入への一助になれば幸いです。

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

lab.holmescloud.com

lab.holmescloud.com