20220117のJavaに関する記事は8件です。

Wekaを使おう-数値データの推測

Javaで機械学習したくない? 機械学習といえばPythonって風潮ですが、Javaが得意な人は、今更Python学ぶのかなという気持ちになるでしょう。できれば、得意なJavaで機械学習できたら嬉しいですね。自分が開発したソフトに組み込めるし。 そんなことで、機械学習エンジンのWekaのAPIで色々と試してみようということで書き始めたいと思います。(間違いがあったらご指摘お願いします) まずは、数値データの推測からはじめます。 こちらの書籍(機械学習コレクション Weka入門) https://www.amazon.co.jp/%E6%A9%9F%E6%A2%B0%E5%AD%A6%E7%BF%92%E3%82%B3%E3%83%AC%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3-Weka%E5%85%A5%E9%96%80-I%E3%83%BB-BOOKS-%E5%92%8C%E7%94%B0/dp/4777520889 に出ているサンプルをAPIを使ってやってみることにしましょう。 CSVデータは以下の通り。末尾から2行目の売上が「?」となっています。これを推測してみます。test.csvとでもしましょうか。 弁当,レジ前,スイーツ類,コーヒー類,その他,売上 8,8,9,8,8,55 8,7,8,9,8,57 7,6,7,6,6,43 8,7,8,7,6,47 4,6,5,6,4,36 4,5,6,5,5,37 6,6,7,6,4,39 5,6,7,6,5,40 5,6,7,6,5,40 5,6,7,6,5,40 6,6,7,7,5,42 6,7,8,8,7,46 8,7,7,8,8,48 9,7,8,7,8,52 3,4,4,4,5,30 3,6,4,4,4,33 3,5,6,4,3,34 3,4,6,4,4,? 10,9,10,9,7,60 mini-weka Wekaをそのまま利用してもいいのですが、GUIを使わないならば、mini-wekaでもOKだと思います。( https://github.com/fracpete/mini-weka ) APIを利用してみる package example20220110; import java.io.File; import java.io.IOException; import weka.classifiers.Classifier; import weka.classifiers.evaluation.output.prediction.AbstractOutput; import weka.classifiers.evaluation.output.prediction.CSV; import weka.classifiers.evaluation.output.prediction.HTML; import weka.classifiers.evaluation.output.prediction.PlainText; import weka.classifiers.functions.MultilayerPerceptron; import weka.core.Instances; import weka.core.converters.CSVLoader; public class Main { public static void main(String[] argv) throws Exception { Main myWeka = new Main(); myWeka.run(); } void run() throws IOException, Exception { CSVLoader loader = new CSVLoader(); loader.setSource(new File("test.csv")); Instances tr = loader.getDataSet(); // 多層パーセプトロン(ニューラルネットワークを利用) MultilayerPerceptron mp = new MultilayerPerceptron(); // デフォルトに近いオプション mp.setOptions(new String[]{"-L", "0.3", "-M", "0.2", "-N", "500", "-V", "0", "-S", "0", "-E", "20", "-H", "a"}); PrintPredict pp = new PrintPredict(); System.out.println("---PLAIN---"); System.out.println(pp.getPlainText(mp, tr)); System.out.println("---CSV---"); System.out.println(pp.getCSV(mp, tr)); } class PrintPredict { private String Output(Classifier cls, Instances instances, AbstractOutput output) { try { instances.setClassIndex(instances.numAttributes() - 1); cls.buildClassifier(instances); StringBuffer writer = new StringBuffer(); output.setBuffer(writer); output.setHeader(instances); output.printClassifications(cls, instances); return writer.toString(); } catch (Exception e) { return e.getMessage(); } } String getPlainText(Classifier cls, Instances instances) { PlainText output = new PlainText(); return Output(cls, instances, output); } String getCSV(Classifier cls, Instances instances) { CSV output = new CSV(); return Output(cls, instances, output); } String getHTML(Classifier cls, Instances instances) { HTML output = new HTML(); return Output(cls, instances, output); } } } 出力 ---PLAIN--- 1 55 53.92 -1.08 2 57 55.332 -1.668 3 43 41.299 -1.701 4 47 45.9 -1.1 5 36 34.783 -1.217 6 37 34.776 -2.224 7 39 39.444 0.444 8 40 38.792 -1.208 9 40 38.792 -1.208 10 40 38.792 -1.208 11 42 40.724 -1.276 12 46 46.396 0.396 13 48 47.158 -0.842 14 52 51.856 -0.144 15 30 29.172 -0.828 16 33 31.837 -1.163 17 34 34.147 0.147 18 ? 32.867 ? 19 60 58.724 -1.276 ---CSV--- 1,55,53.92,-1.08 2,57,55.332,-1.668 3,43,41.299,-1.701 4,47,45.9,-1.1 5,36,34.783,-1.217 6,37,34.776,-2.224 7,39,39.444,0.444 8,40,38.792,-1.208 9,40,38.792,-1.208 10,40,38.792,-1.208 11,42,40.724,-1.276 12,46,46.396,0.396 13,48,47.158,-0.842 14,52,51.856,-0.144 15,30,29.172,-0.828 16,33,31.837,-1.163 17,34,34.147,0.147 18,?,32.867,? 19,60,58.724,-1.276 32.867と推測されたようです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaでWekaによる機械学習 01-数値データの推測

Javaで機械学習したくない? 機械学習といえばPythonって風潮ですが、Javaが得意な人は、今更Python学ぶのかなという気持ちになるでしょう。できれば、得意なJavaで機械学習できたら嬉しいですね。自分が開発したソフトに組み込めるし。 そんなことで、機械学習エンジンのWekaのAPIで色々と試してみようということで書き始めたいと思います。(間違いがあったらご指摘お願いします) まずは、数値データの推測からはじめます。 こちらの書籍(機械学習コレクション Weka入門) https://www.amazon.co.jp/%E6%A9%9F%E6%A2%B0%E5%AD%A6%E7%BF%92%E3%82%B3%E3%83%AC%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3-Weka%E5%85%A5%E9%96%80-I%E3%83%BB-BOOKS-%E5%92%8C%E7%94%B0/dp/4777520889 に出ているサンプルをAPIを使ってやってみることにしましょう。 CSVデータは以下の通り。末尾から2行目の売上が「?」となっています。これを推測してみます。test.csvとでもしましょうか。 弁当,レジ前,スイーツ類,コーヒー類,その他,売上 8,8,9,8,8,55 8,7,8,9,8,57 7,6,7,6,6,43 8,7,8,7,6,47 4,6,5,6,4,36 4,5,6,5,5,37 6,6,7,6,4,39 5,6,7,6,5,40 5,6,7,6,5,40 5,6,7,6,5,40 6,6,7,7,5,42 6,7,8,8,7,46 8,7,7,8,8,48 9,7,8,7,8,52 3,4,4,4,5,30 3,6,4,4,4,33 3,5,6,4,3,34 3,4,6,4,4,? 10,9,10,9,7,60 mini-weka Wekaをそのまま利用してもいいのですが、GUIを使わないならば、mini-wekaでもOKだと思います。( https://github.com/fracpete/mini-weka ) APIを利用してみる package example20220110; import java.io.File; import java.io.IOException; import weka.classifiers.Classifier; import weka.classifiers.evaluation.output.prediction.AbstractOutput; import weka.classifiers.evaluation.output.prediction.CSV; import weka.classifiers.evaluation.output.prediction.HTML; import weka.classifiers.evaluation.output.prediction.PlainText; import weka.classifiers.functions.MultilayerPerceptron; import weka.core.Instances; import weka.core.converters.CSVLoader; public class Main { public static void main(String[] argv) throws Exception { Main myWeka = new Main(); myWeka.run(); } void run() throws IOException, Exception { CSVLoader loader = new CSVLoader(); loader.setSource(new File("test.csv")); Instances tr = loader.getDataSet(); // 多層パーセプトロン(ニューラルネットワークを利用) MultilayerPerceptron mp = new MultilayerPerceptron(); // ランダムフォレストにしたければ以下のようにする // RandomForest mp = new RandomForest(); // デフォルトに近いオプション mp.setOptions(new String[]{"-L", "0.3", "-M", "0.2", "-N", "500", "-V", "0", "-S", "0", "-E", "20", "-H", "a"}); PrintPredict pp = new PrintPredict(); System.out.println("---PLAIN---"); System.out.println(pp.getPlainText(mp, tr)); System.out.println("---CSV---"); System.out.println(pp.getCSV(mp, tr)); } class PrintPredict { private String Output(Classifier cls, Instances instances, AbstractOutput output) { try { instances.setClassIndex(instances.numAttributes() - 1); cls.buildClassifier(instances); StringBuffer writer = new StringBuffer(); output.setBuffer(writer); output.setHeader(instances); output.printClassifications(cls, instances); return writer.toString(); } catch (Exception e) { return e.getMessage(); } } String getPlainText(Classifier cls, Instances instances) { PlainText output = new PlainText(); return Output(cls, instances, output); } String getCSV(Classifier cls, Instances instances) { CSV output = new CSV(); return Output(cls, instances, output); } String getHTML(Classifier cls, Instances instances) { HTML output = new HTML(); return Output(cls, instances, output); } } } 出力 ---PLAIN--- 1 55 53.92 -1.08 2 57 55.332 -1.668 3 43 41.299 -1.701 4 47 45.9 -1.1 5 36 34.783 -1.217 6 37 34.776 -2.224 7 39 39.444 0.444 8 40 38.792 -1.208 9 40 38.792 -1.208 10 40 38.792 -1.208 11 42 40.724 -1.276 12 46 46.396 0.396 13 48 47.158 -0.842 14 52 51.856 -0.144 15 30 29.172 -0.828 16 33 31.837 -1.163 17 34 34.147 0.147 18 ? 32.867 ? 19 60 58.724 -1.276 ---CSV--- 1,55,53.92,-1.08 2,57,55.332,-1.668 3,43,41.299,-1.701 4,47,45.9,-1.1 5,36,34.783,-1.217 6,37,34.776,-2.224 7,39,39.444,0.444 8,40,38.792,-1.208 9,40,38.792,-1.208 10,40,38.792,-1.208 11,42,40.724,-1.276 12,46,46.396,0.396 13,48,47.158,-0.842 14,52,51.856,-0.144 15,30,29.172,-0.828 16,33,31.837,-1.163 17,34,34.147,0.147 18,?,32.867,? 19,60,58.724,-1.276 32.867と推測されたようです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

MouseController

package org.tei; import java.awt.AWTException; import java.awt.Dimension; import java.awt.FlowLayout; import java.awt.Font; import java.awt.Robot; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.util.Random; import javax.swing.JButton; import javax.swing.JFrame; public class MouseController implements Runnable { private Robot robot; private boolean isStop = false; public MouseController() { try { ControllerFrame frame = new ControllerFrame("Prevent Locking"); frame.setVisible(true); robot = new Robot(); } catch (AWTException e) { e.printStackTrace(); } } @Override public void run() { int x; int y; Random random = new Random(); while (!isStop) { //随机生成坐标。 x = random.nextInt(1000); y = random.nextInt(1000); //开始移动 robot.mouseMove(x, y); //每5秒移动一次 robot.delay(6000); } } /** * GUI Frame 生成一个button,控制程序 * @author max * */ private class ControllerFrame extends JFrame { private static final long serialVersionUID = 1L; private JButton close = new JButton("close"); public ControllerFrame(String title) { this(); setTitle(title); } public ControllerFrame() { setLayout(new FlowLayout(FlowLayout.LEADING)); setSize(316, 338); setResizable(false); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); Dimension preferredSize = new Dimension(300,300); Font font = new Font("", 1, 80); //设置button 大小,文字等属性 close.setPreferredSize(preferredSize); close.setFont(font); close.setBorderPainted(true); close.setFocusable(false); add(close); //点击button后,程序终止。 close.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { isStop = true; dispose(); } }); } } public static void main(String[] args) { MouseController m = new MouseController(); m.run(); } }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

KotlinでviewBindingを使う

ブロジェクト作成 赤のMainActivityを開きSplitで開くと、コードとデザインを同時に確認できます。 水色がtexttViewのコードとエディタです。 ボタン追加 ボタンを追加すると、コードとデザイン両方追加されます。 背景色も変更してみる 赤枠のbackgroundをいじれば変更できました。 バインディングの設定 build.gradleを開いて、赤で囲ったコードを追加し、右上のSync Nowを押します。 この辺りのやり方は公式ドキュメントを見てください。 バインディングを使う バインディングクラス 上で設定したので、バインディングクラスがファイルごとに生成されるそうです。 今回、初期ではactivity_main.xmlがあるので、ActivityMainBindingが生成されています。 例えば、activity_result.xmlを作成すると、ActivityResultBindingが生成されるはずです。 定義に飛んでみる ActivityMainBindingにカーソルを合わせてcommand + クリックするとxmlファイルに飛べるので関連性が分かると思います。 使い方 このbindingを使って、レイアウトの変数とビューにアクセスできます。 設定方法はバインディングクラスのinflate()メソッドを使用して、オブジェクトをバインドできるそう。 layoutInflaterをいうのは、レイアウトXMLファイルを対応するViewオブジェクトにインスタンス化するものだそうです。(公式ドキュメント) 詳しいことは公式ドキュメント参照。 カウントアップ処理を追加してみた tapCountを作成してこれをtextViewに反映します。 bindingを通してtextViewやbuttonにアクセスしてやりたい処理を書くことができるそうです。 最後に 今回はバインディングの方法について書いてみました。 まだAndroid・Kotlin始めて3日目くらいなので他の方法などあれば教えてくださーい
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Spring Boot と Scalar DB を用いた API の作り方③

こちらの記事はSpring Boot と Scalar DB を用いた API の作り方②の続きになります。 前回までの記事をご覧になってない方は先にそちらに目を通すようにお願いします。 Spring BootとScalar DBを用いたAPIの作り方① Spring BootとScalar DBを用いたAPIの作り方② Spring BootとScalar DBを用いたAPIの作り方③(本記事) 目次 Spring Securityによるアクセス制限 実装方針 実装方法 Spring Security Testの実装 E2Eテストの導入 Cucumberの導入 テストシナリオの作成 E2Eテストの実装 まとめ Spring Securityによるアクセス制限 Spring Securityを使い、ユーザーを所属しているグループによって、管理者権限と一般権限を割り当てアクセス制御を行います。 以下の表にしたがって、権限ごとにアクセスできるリソースを分けていきます。 対象 機能 管理者 一般ユーザー Anonymous ユーザー(自分自身) 登録する ⭕️ ⭕️ ⭕️ 同上 更新する ⭕️ ⭕️ 同上 取得する ⭕️ ⭕️ 同上 削除する ⭕️ ⭕️ ユーザー(自分以外) 更新する ⭕️ 同上 取得する ⭕️ 同上 削除する ⭕️ 同上 一覧取得する ⭕️ グループ(所属グループ) メンバーを追加する ⭕️ ⭕️ 同上 メンバーを削除する ⭕️ ⭕️ 同上 メンバーを一覧取得する ⭕️ ⭕️ 同上 削除する ⭕️ ⭕️ グループ(全て) 登録する ⭕️ ⭕️ 同上 メンバーを追加する ⭕️ 同上 メンバーを削除する ⭕️ 同上 メンバーを一覧取得する ⭕️ 同上 グループを一覧取得する ⭕️ ⭕️ 実装方針 APIに対する認証、認可をリクエスト中のAuthorizationヘッダの値で行います。この認可はリクエストごとに行います。 ヘッダーによる認証方式は、Spring Security では事前認証シナリオのケースに該当します。 Spring Securityでは、事前認証用に提供されているクラス群が存在するため、そのクラス群を活用する必要があります。 実装方法の概要は以下の様になります。 - リクエスト中のAuthorizationヘッダの値によって認証、認可処理を行うためのフィルター、サービスを作成する - Spring Securityでリクエストごとに認可処理を行うためにセッションを使用しないよう設定する。 - コントローラークラスのメソッドに必要な権限を持っているかをチェックするために@PreAuthorize(“hasAuthority('権限名’)")のように@PreAuthorizeアノテーションをつける 実装方法 Spring Securityを利用するためのbuild.gradleのdependenciesは以下の通りです build.gradle dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.security:spring-security-test:5.6.0' } リクエスト中のAuthorizationヘッダから値を取り出すためのフィルタを以下のように作成します。 以下の実装例ではWebSecurityConfigクラスを作成し、そのサブクラスとして実装しています。 config/WebSecurityConfig.java static class MyPreAuthenticatedProcessingFilter extends AbstractPreAuthenticatedProcessingFilter { @Override protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) { return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)).orElse(""); } @Override protected Object getPreAuthenticatedCredentials(HttpServletRequest request) { return ""; } } 次にフィルタで取り出したAuthorizationの値を使い認証済みユーザとユーザに権限を与える処理を作成します。 認証済みユーザの作成はorg.springframework.security.core.userdetail.Userクラスを拡張したAccountUserを作成し使用します。また、ユーザに与える権限はAuthorityUtils.createAuthorityListメソッドに権限名を与えることで生成しています。 以下の例ではAuthorizationヘッダの値から、リポジトリクラスのメソッドでユーザー情報を呼び出し、ユーザーがadminグループに所属している場合に管理者権限を与え、それ以外の場合に一般権限を与えています。 service/AuthenticationService.java @Service public class AuthenticationService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> { @Autowired UserRepository userRepository; @Autowired DistributedTransactionManager db; @Override public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException { try { String userId = token.getPrincipal().toString(); List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>(); DistributedTransaction tx = db.start(); User user = userRepository.getUser(tx, userId); authorities.add(new SimpleGrantedAuthority("ROLE_USER")); List<String> groupNameList = new ArrayList<String>(); List<String> groupIdList = new ArrayList<String>(); List<UserGroup> userGroups = Optional.ofNullable(user.getUserGroups()).orElse(new ArrayList<UserGroup>()); userGroups.forEach( (userGroup -> { groupNameList.add(userGroup.getGroupName()); groupIdList.add(userGroup.getGroupId()); })); if (groupNameList.contains("admin")) { authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); } return new AccountUser("user", "password", authorities, user.getUserId(), groupIdList); } catch (TransactionException | ObjectNotFoundException e) { throw new UsernameNotFoundException("Invalid authorization header."); } } public static class AccountUser extends org.springframework.security.core.userdetails.User { String userId; List<String> groupIdList; public AccountUser( String username, String password, Collection<? extends GrantedAuthority> authorities, String userId, List<String> groupIdList) { super(username, password, authorities); this.userId = userId; this.groupIdList = groupIdList; } public List<String> getGroupIdList() { return groupIdList; } public String getUserId() { return userId; } } } 作成したフィルタとユーザサービスがインジェクションされるようにBean定義を行います。また、Spring Securityでセッションを使用しないよう設定します。セッションを使用しないことでWeb APIに対するリクエストごとに認可処理が行われる様にします。 また、@PreAuthorizeアノテーションを有効化するためには @EnableGlobalMethodSecurity(prePostEnabled = true)をつける必要があります。 config/WebSecurityConfig.java @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationService authenticationService; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable(); http.authorizeRequests() .anyRequest() .authenticated() .and() .addFilter(preAuthenticatedProcessingFilter()) .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS); } @Bean public AbstractPreAuthenticatedProcessingFilter preAuthenticatedProcessingFilter() throws Exception { MyPreAuthenticatedProcessingFilter preAuthenticatedProcessingFilter = new MyPreAuthenticatedProcessingFilter(); preAuthenticatedProcessingFilter.setAuthenticationManager(authenticationManager()); return preAuthenticatedProcessingFilter; } @Bean PreAuthenticatedAuthenticationProvider tokenProvider() { PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider(); provider.setPreAuthenticatedUserDetailsService(authenticationService); provider.setUserDetailsChecker(new AccountStatusUserDetailsChecker()); return provider; } static class MyPreAuthenticatedProcessingFilter extends AbstractPreAuthenticatedProcessingFilter { @Override protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) { return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)).orElse(""); } @Override protected Object getPreAuthenticatedCredentials(HttpServletRequest request) { return ""; } } } コントローラ内のメソッドに@PreAuthorize(“hasAuthority('権限名’)")をつけ、メソッド実行前に必要な権限を持っているかをチェックします。 controller/UserController.java @RestController @RequestMapping("/users") public class UserController { private static final String PATH_USER_ID = "user_id"; @Autowired UserService userService; @PostMapping() @ResponseStatus(HttpStatus.CREATED) public String createUser(@RequestBody CreateUserDto createUserDto) throws TransactionException { return userService.createUser(createUserDto); } @PutMapping("/{user_id}") @PreAuthorize("hasRole('ROLE_ADMIN') or principal.userId == #userId") @ResponseStatus(HttpStatus.OK) public void updateUser( @PathVariable(PATH_USER_ID) String userId, @RequestBody UpdateUserDto updateUserDto) throws TransactionException { userService.updateUser(userId, updateUserDto); } @DeleteMapping("/{user_id}") @PreAuthorize("hasRole('ROLE_ADMIN') or principal.userId == #userId") @ResponseStatus(HttpStatus.OK) public void deleteUser(@PathVariable(PATH_USER_ID) String userId) throws TransactionException { userService.deleteUser(userId); } @GetMapping("/{user_id}") @PreAuthorize("hasRole('ROLE_ADMIN') or principal.userId == #userId") @ResponseStatus(HttpStatus.OK) public GetUserDto getUser(@PathVariable(PATH_USER_ID) String userId) throws TransactionException { return userService.getUser(userId); } @GetMapping() @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('ROLE_ADMIN')") public List<GetUserDto> listUsers() throws TransactionException { return userService.listUsers(); } } Spring Security Testの実装 Spring Security には、 JUnit によるテストをサポートする仕組みが用意されています。 @WithMockUser、@WithMockUser、@WithUserDetailsのアノテーションを使用して認証されたモックユーザーを作成できます。 また、@WithSecurityContext を使用して必要な SecurityContext を作成する独自のアノテーションを作成できます。 以下の例では、@WithMockCustomUser という名前のアノテーションを作成できます。 test/**/security/SecurityUtil.java public class SpringSecurityUtil { @Retention(RetentionPolicy.RUNTIME) @WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class) public @interface WithCustomMockUser { String username() default "user"; String password() default "password"; String userId() default "userId"; String role() default "role"; String groupId() default "groupId"; } } @WithMockCustomUser に @WithSecurityContext アノテーションが付けられていることがわかります。これは、Spring Security テストサポートに、テスト用に SecurityContext を作成する予定であることを示すものです。@WithSecurityContext アノテーションでは、@WithMockCustomUser アノテーションを指定して、新しい SecurityContext を作成する SecurityContextFactory を指定する必要があります。 WithMockCustomUserSecurityContextFactory の実装は以下のとおりです。 test/**/security/WithMockCustomUserSecurityContextFactory.java public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithCustomMockUser> { @Override public SecurityContext createSecurityContext(WithCustomMockUser user) { List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>(Arrays.asList(new SimpleGrantedAuthority(user.role()))); List<String> groupIdList = new ArrayList<String>(Arrays.asList(user.groupId())); SecurityContext context = SecurityContextHolder.createEmptyContext(); AccountUser principal = new AccountUser(user.username(), user.password(), authorities, user.userId(), groupIdList); Authentication authentication = new UsernamePasswordAuthenticationToken( principal, principal.getPassword(), principal.getAuthorities()); context.setAuthentication(authentication); return context; } } これで、テストクラスまたはテストメソッドに新しいアノテーションを付けることができ、Spring Security の WithSecurityContextTestExecutionListener により、SecurityContext が適切に読み込まれます。 コントローラークラスで作成したユニットテストに@WithCustomMockUserアノテーションを付与して、テストを追加していきます。 test/**/UserControllerTest.java @ContextConfiguration @WebMvcTest(UserController.class) public class UserControllerTest { private static final String BASE_URL_PATH = "/users"; private static final String MOCKED_USER_ID = "6695bdd7-ccb3-0468-35af-e804f79329b2"; private MockMvc mockMvc; @MockBean private UserService userService; @MockBean private UserRepository userRepository; @MockBean DistributedTransactionManager manager; @Autowired UserController userController; @Autowired private ObjectMapper objectMapper; @Autowired private WebApplicationContext context; @MockBean private AuthenticationService authenticationService; @BeforeEach public void setup() { mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); } @Test @WithAnonymousUser void createUser_byAnonymousUser_shouldSuccess() throws Exception { CreateUserDto createUserDto = UserStub.getCreateUserDto(); when(userService.createUser(createUserDto)).thenReturn(MOCKED_USER_ID); mockMvc .perform( post(BASE_URL_PATH) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(createUserDto))) .andExpect(status().isCreated()); } @Test @WithCustomMockUser(role = "ROLE_ADMIN") void updateUser_byAdminUser_shouldSuccess() throws Exception { UpdateUserDto updateUserDto = UserStub.getUpdateUserDto(); mockMvc .perform( put(BASE_URL_PATH + "/" + MOCKED_USER_ID) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateUserDto))) .andExpect(status().isOk()); } @Test @WithCustomMockUser(userId = MOCKED_USER_ID) void updateUser_byOwnSelf_shouldSuccess() throws Exception { UpdateUserDto updateUserDto = UserStub.getUpdateUserDto(); mockMvc .perform( put(BASE_URL_PATH + "/" + MOCKED_USER_ID) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateUserDto))) .andExpect(status().isOk()); } @Test @WithCustomMockUser(userId = MOCKED_USER_ID) void getUser_byOwnSelf_shouldSuccess() throws Exception { mockMvc.perform(get(BASE_URL_PATH + "/" + MOCKED_USER_ID)).andExpect(status().isOk()); } @Test @WithCustomMockUser(role = "ROLE_ADMIN") void getUser_byAdminUser_shouldSuccess() throws Exception { mockMvc.perform(get(BASE_URL_PATH + "/" + MOCKED_USER_ID)).andExpect(status().isOk()); } @Test @WithCustomMockUser(userId = "inValidUserId") void getUser_byInvalidUser_thenAccessDenied() throws Exception { assertThrows(AccessDeniedException.class, () -> userController.getUser(MOCKED_USER_ID)); } @Test @WithCustomMockUser(role = "ROLE_ADMIN") void listUsers_shouldSuccess() throws Exception { mockMvc.perform(get(BASE_URL_PATH)).andExpect(status().isOk()); } @Test @WithCustomMockUser void lisUsers_byGeneralUser_thenAccessDenied() throws Exception { assertThrows(AccessDeniedException.class, () -> userController.listUsers()); } } E2Eテストの導入 Cucumberを利用したE2Eテストの仕方を紹介します。 Cucumberの導入 build.gradleに追加するdependenciesは以下の通りです。 build.gradle testImplementation group: 'io.rest-assured', name: 'rest-assured', version: '4.3.1' testImplementation 'io.cucumber:cucumber-java:6.10.4' testImplementation 'io.cucumber:cucumber-spring:6.10.4' testImplementation 'io.cucumber:cucumber-junit:6.10.4' Cucumberをコマンドラインから実行するためのTaskを作成します。 build.gradleに以下のConfigurationを追加します。 build.gradle configurations { cucumberRuntime { extendsFrom testImplementation } } 次にCucumberを実行するためのTaskを作成します。 build.gradle task cucumber() { dependsOn assemble, testClasses doLast { javaexec { main = "io.cucumber.core.cli.Main" classpath = configurations.cucumberRuntime + sourceSets.main.output + sourceSets.test.output args = ['--plugin', 'pretty', '--plugin', 'html:target/cucumber.html', '--glue', 'com.example.api.cucumber', 'src/test/resources'] } } } このTaskは、src/test/resourcesディレクトリの下の.featureファイルにあるすべてのテストシナリオを実行するように構成されています。 Mainクラスの–glueオプションで、シナリオの実行に必要なステップ定義ファイルの場所を指定します。 –pluginオプションで、テストレポートの形式と場所を指定します。 テストシナリオの作成 それでは、src/test/resources以下にアプリケーションのテストシナリオ作成していきます。 CucumberでのテストシナリオはGherkinと呼ばれるテスト記述言語フォーマットで書く必要があります。* .featureファイルを作成し、1つ以上のシナリオが含まれている必要があります。 各シナリオにはCucumberが実行・検証するstepsを記述します。 test/resources/users.feature Feature: Test /users CRUD endpoints Background: Admin user and General user already existed When the user "general" already existed When the user "admin" already existed And the user "admin" creates Admin Group Then it returns a status code of 201 for user Scenario: Admin user updates General user information When the user "admin" updates the user "general" information Then it returns a status code of 200 for user Scenario: General user updates his information When the user "general" updates the user "general" information Then it returns a status code of 200 for user Scenario: Admin user gets General user information When the user "admin" updates the user "general" information When the user "admin" gets the user "general" information Then it returns a status code of 200 for user And it returns user "general" Scenario: General user gets his information When the user "general" updates the user "general" information When the user "admin" gets the user "general" information Then it returns a status code of 200 for user And it returns user "general" Scenario: Admin user gets all users When the user "admin" gets all users Then it returns a status code of 200 for user E2Eテストの実装 各Cucumberテストの設定を行うクラスを作成します @RunWith(Cucumber.class)でアノテーションを付けて、このランナーを使用するようにJUnitに指示し、すべてのCucumber機能を使用できるようにします。 test/**/cucumber/CucumberIntegrationTest.java @RunWith(Cucumber.class) @CucumberOptions( plugin = {"pretty", "html:target/cucumber-report.html"}, glue = {"com.example.api.cucumber"}, features = {"src/test/resources"}) public class CucumberIntegrationTest {} 次にCucumberテストに依存性注入を行うCucumberSpringConfigurationクラスを作成します。 @CucumberContextConfigurationアノテーションは、このクラスをSpringのテストコンテキスト構成として使用するようにCucumberに指示します。 test/**/CucumberSpringConfiguration.java @CucumberContextConfiguration @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class CucumberSpringConfiguration {} 各E2Eテストシナリオで使用するメソッドをクラスメソッドとして作成しておきます。 test/**/cucumber/E2eMethods.java public class E2eMethods { public static String contentType = "application/json"; public static String BASE_URL = "http://localhost:8080"; private static E2eMethods e2eMethods = null; private static Response response; private static String authorizationHeader = ""; public static E2eMethods getInstance() { if (e2eMethods == null) { e2eMethods = new E2eMethods(); } return e2eMethods; } public static RequestSpecification initializeRequest() { RestAssured.baseURI = BASE_URL; RequestSpecification request = RestAssured.given(); request.header("Content-Type", contentType); request.header("Authorization", authorizationHeader); return request; } public Response post(String endPointURL, String body, String userId) { authorizationHeader = userId; RequestSpecification request = initializeRequest(); response = request.body(body).post(endPointURL); return response; } public Response postUser(String endPointURL, String body) { RequestSpecification request = initializeRequest(); response = request.body(body).post(endPointURL); return response; } public Response put(String endPointURL, String body, String userId) { authorizationHeader = userId; RequestSpecification request = initializeRequest(); response = request.body(body).put(endPointURL); return response; } public Response deleteGroupUser(String endPointURL, String userId) { authorizationHeader = userId; RequestSpecification request = initializeRequest(); response = request.put(endPointURL); return response; } public Response get(String getFormat) { RequestSpecification request = initializeRequest(); response = request.get(getFormat); return response; } public Response delete(String endPointURL, String userId) { authorizationHeader = userId; RequestSpecification request = initializeRequest(); response = request.delete(endPointURL); return response; } public Response getWithUserId(String endPointURL, String userId) { authorizationHeader = userId; RequestSpecification request = initializeRequest(); response = request.get(endPointURL); return response; } public String getJsonString(Object object) { ObjectMapper mapper = new ObjectMapper(); try { return mapper.writeValueAsString(object); } catch (JsonProcessingException e) { throw new RuntimeException("Error while parsing response body json object", e); } } } また、各シナリオで使用するテストデータを取得するためのクラスも作成しておきます。 test/**/cucumber/E2eConstants.java public class E2eConstants { public static final String USERS_ENDPOINT_URL = "/users"; public static final String GROUPS_ENDPOINT_URL = "/groups"; public static final String GROUP_USERS = "group-users"; public static final String STRING_FORMAT_SINGLE_ID = "%s/%s"; public static final String STRING_FORMAT_TWO_SLASH = "%s/%s/%s"; public static final String STRING_FORMAT_THREE_SLASH = "%s/%s/%s/%s"; private static final String MOCKED_EMAIL = "mockedEmail"; private static final String MOCKED_FAMILY_NAME = "mockedFamilyName"; private static final String MOCKED_GIVEN_NAME = "mockedGivenName"; private static final String MOCKED_PREFERRED_LANGUAGE = "mockedPreferredLanguage"; private static final String MOCKED_PHONE_NUMBER = "mockedPhoneNumber"; private static final String MOCKED_USER_ID = "mockedUserId"; private static final String MOCKED_TYPE = "mockedType"; public static CreateUserDto getCreateUserDto() { CreateUserDtoBuilder builder = CreateUserDto.builder(); return builder.email(MOCKED_EMAIL).build(); } public static UpdateUserDto getUpdateUserDto() { UpdateUserDtoBuilder builder = UpdateUserDto.builder(); UserDetailDto userDetail = getUserDetailDto(); return builder .email(MOCKED_EMAIL) .familyName(MOCKED_FAMILY_NAME) .givenName(MOCKED_GIVEN_NAME) .userDetail(userDetail) .build(); } public static UserDetailDto getUserDetailDto() { UserDetailDtoBuilder builder = UserDetailDto.builder(); return builder .preferredLanguage(MOCKED_PREFERRED_LANGUAGE) .phoneNumber(MOCKED_PHONE_NUMBER) .build(); } public static GetUserDto getGetUserDto(String userId) { GetUserDtoBuilder builder = GetUserDto.builder(); return builder .userId(userId) .email(MOCKED_EMAIL) .familyName(MOCKED_FAMILY_NAME) .givenName(MOCKED_GIVEN_NAME) .userDetail(getUserDetailDto()) .build(); } public static CreateGroupDto getCreateGroupDto(String groupName) { CreateGroupDtoBuilder builder = CreateGroupDto.builder(); return builder.groupName(groupName).build(); } public static GroupUserDto getGroupUserDto(String userId) { GroupUserDtoBuilder builder = GroupUserDto.builder(); return builder.userId(userId).type(MOCKED_TYPE).build(); } public static List<GroupUser> getGroupUsers() { GroupUser groupUser = GroupUser.builder().userId(MOCKED_USER_ID).type(MOCKED_TYPE).build(); return new ArrayList<GroupUser>(Arrays.asList(groupUser)); } public static GetGroupDto getGetGroupDto(String groupId, String groupName) { GetGroupDtoBuilder builder = GetGroupDto.builder(); return builder.groupId(groupId).groupName(groupName).build(); } } それでは.featureファイルで定義したシナリオに記載したステップを検証する機能を実装していきます。 Cucumberは、@When、@And、@Thenなどのアノテーションが付けられたメソッドを実行します。 アノテーションにテストシナリオで定義した各ステップを記載します。 また、変数として用いたい箇所は{}で変数の型を括ると、テストシナリオで用いた変数を各テスト内で受け取れます。 test/**/cucumber/UsersStepdefs.java public class UsersStepdefs extends CucumberSpringConfiguration { private static E2eMethods e2eMethods = E2eMethods.getInstance(); private String userId; private final HashMap<String, String> userIds = new HashMap<>(); private Response response; private final String ADMIN_GROUP = "admin"; @When("the user {string} already existed") public void theUserIsCreated(String user) { CreateUserDto createUserDto = E2eConstants.getCreateUserDto(); String body = e2eMethods.getJsonString(createUserDto); response = e2eMethods.postUser(USERS_ENDPOINT_URL, body); userId = response.getBody().asString(); userIds.putIfAbsent(user, userId); } @And("the user {string} creates Admin Group") public void adminGroupIsCreated(String executionUser) { CreateGroupDto createGroupDto = E2eConstants.getCreateGroupDto(ADMIN_GROUP); String groupBody = e2eMethods.getJsonString(createGroupDto); response = e2eMethods.post(GROUPS_ENDPOINT_URL, groupBody, userIds.get(executionUser)); } @When("the user {string} updates the user {string} information") public void theUserUpdatesUserInformation(String executionUser, String targetUser) { UpdateUserDto updateUserDto = E2eConstants.getUpdateUserDto(); String body = e2eMethods.getJsonString(updateUserDto); response = e2eMethods.put( String.format(STRING_FORMAT_SINGLE_ID, USERS_ENDPOINT_URL, userIds.get(targetUser)), body, userIds.get(executionUser)); } @When("the user {string} gets the user {string} information") public void theUserGetTheUserInformation(String executionUser, String targetUser) { response = e2eMethods.getWithUserId( String.format(STRING_FORMAT_SINGLE_ID, USERS_ENDPOINT_URL, userIds.get(targetUser)), userIds.get(executionUser)); } @And("it returns user {string}") public void itReturnsUser(String targetUser) { GetUserDto getUserDto = e2eMethods.convertJsonStrToDataObject(response.getBody().asString(), GetUserDto.class); GetUserDto expectedGetUserDto = E2eConstants.getGetUserDto(userIds.get(targetUser)); assertThat(getUserDto.getUserId()).isEqualTo(userIds.get(targetUser)); assertThat(getUserDto.getEmail()).isEqualTo(expectedGetUserDto.getEmail()); assertThat(getUserDto.getFamilyName()).isEqualTo(expectedGetUserDto.getFamilyName()); assertThat(getUserDto.getGivenName()).isEqualTo(expectedGetUserDto.getGivenName()); assertThat(getUserDto.getUserDetail().getPhoneNumber()) .isEqualTo(expectedGetUserDto.getUserDetail().getPhoneNumber()); assertThat(getUserDto.getUserDetail().getPreferredLanguage()) .isEqualTo(expectedGetUserDto.getUserDetail().getPreferredLanguage()); } @When("the user {string} gets all users") public void adminUserGetsAllUsers(String executionUser) { response = e2eMethods.getWithUserId(USERS_ENDPOINT_URL, userIds.get(executionUser)); } @Then("it returns a status code of {int} for user") public void validateStatusCode(int statusCode) { assertThat(response.getStatusCode()).isEqualTo(statusCode); } } まとめ Scalar DBとSpring Bootを使ったAPIの開発方法について説明しました。 Scalar DBを使ってみたいという方のご参考になれば幸いです。 参考 Scalar DB Scalar DB design document Scalar DB supported databases Database schema in Scalar DB Getting Started with Scalar DB Spring Security事前認証シナリオ Gherkin Reference Getting Started with Cucumber
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Spring Boot と Scalar DB を用いた API の作り方②

この記事はSpring Boot と Scalar DB を用いた API の作り方①の続きになります。 Spring BootとScalar DBを用いたAPIの作り方① Spring BootとScalar DBを用いたAPIの作り方②(本記事) Spring BootとScalar DBを用いたAPIの作り方③ 目次 モデル・DTOの作成 モデル DTO リポジトリクラスの作成 Scalar DBを使ったCRUD処理の実装 リポジトリクラスのユニットテスト サービスクラスの作成 Scalar DBを使ったトランザクションの実装 サービスクラスのユニットテスト コントローラークラスの作成 コントローラークラスの実装 コントローラークラスのユニットテスト モデル・DTOの作成 モデル データベースに保持するためのデータ構造を保持するクラスとしてモデルを作成します。 Repositoryクラスで、スキーマファイルで定義したカラムに対応したデータを保存するため、クラス定数としてスキーマファイルで定義したカラムを記述します。 model/Group.java @Value @Builder public class Group { public static final String GROUP_ID = "group_id"; public static final String GROUP_NAME = "group_name"; public static final String GROUP_USERS = "group_users"; public static final String COMMON_KEY = "common_key"; String groupId; String groupName; List<GroupUser> groupUsers; String commonKey; } model/User.java @Value @Builder public class User { public static final String USER_ID = "user_id"; public static final String EMAIL = "email"; public static final String FAMILY_NAME = "family_name"; public static final String GIVEN_NAME = "given_name"; public static final String USER_GROUPS = "user_groups"; public static final String USER_DETAIL = "user_detail"; public static final String COMMON_KEY = "common_key"; String userId; String email; String familyName; String givenName; List<UserGroup> userGroups; UserDetail userDetail; String common_key; } DTO DTOはAPIで受付け・返却するデータを保持するクラスです。APIの利用者に見せるためのデータを保持します。 JSONでデータを受け付けるため、Jacksonでデシリアリズを行っております。 以下が実装したDTOの例です。 dto/GetUserDto.java @Getter @Builder @JsonDeserialize(builder = GetUserDto.GetUserDtoBuilder.class) public class GetUserDto { @JsonProperty("user_id") String userId; @JsonProperty("email") String email; @JsonProperty("family_name") String familyName; @JsonProperty("given_name") String givenName; @JsonProperty("user_groups") List<GetGroupDto> userGroups; @JsonProperty("user_detail") UserDetailDto userDetail; } それ以外にも以下のDTOを作成しました。 - CreateUserDto.java - CreateGroupDto.java - GetGroupDto.java - GroupUserDto.java - UpdateUserDto.java - UserDetailDto.java リポジトリクラスの作成 Scalar DBを使ったCRUD処理の実装 Scalar DB でデータベースに対してCRUD操作を実行する方法は以下の手順になります。 - レコードを特定するKeyオブジェクトを生成する - Get, Scan, Put, Deleteなどのオブジェクトを生成し、CRUD処理を行いたいレコードのKey、NameSpace、テーブル名、値を渡す - トランザクションを介して、Get, Scan, Put, Deleteなどのオブジェクトを渡し、CRUD処理を実行する 取得したレコードはOptional<Result>として返却され、getPartitionKey(), getClusteringKey(), getValue(”カラム名”)などのメソッドで特定の値を取得できます。 取得した値は、スキーマファイルに定義したデータ型に従って、TextValue, IntValue, BigIntValue, BooleanValueなどのデータ型として返却されます。 モデルに変換する際に、getAsString(), getAsInt(), getAsBoolean()などのメソッドを使ってモデルで定義したデータ型に変換します。 実装は以下のようになります。 repository/UserReposiotry.java @Repository public class UserRepository extends ScalarDbReadOnlyRepository<User> { public static final String NAMESPACE = "demo"; public static final String TABLE_NAME = "users"; public String createUser(DistributedTransaction tx, CreateUserDto createUserDto, String userId) { try { Key pk = createPk(userId) // パーティションキーの作成 getAndThrowsIfAlreadyExist(tx, createGet(pk)); // 同じIDを持つレコードが存在しないかをチェック Put put = // usersテーブルに登録/更新するPutオブジェクトを作成 new Put(pk) .forNamespace(NAMESPACE) // NameSpaceを指定 .forTable(TABLE_NAME) // テーブルを指定 .withValue(User.EMAIL, createUserDto.getEmail()); // 登録/更新したいデータを指定 tx.put(put); // トランザクションを介して、データを登録/更新。 return userId; } catch (CrudException e) { throw new RepositoryException("Creating a User failed", e); } } public void updateUser( DistributedTransaction tx, UpdateUserDto updateUserDto, List<GetGroupDto> userGroups, String userId) { try { Key pk = createPk(userId); // パーティションキーの作成 getAndThrowsIfNotFound(tx, createGet(pk)); // 同じIDを持つレコードが存在しないかをチェック Put put = // usersテーブルに登録/更新するPutオブジェクトを作成 new Put(pk) .forNamespace(NAMESPACE) // NameSpaceを指定 .forTable(TABLE_NAME) // テーブルを指定 .withValue(User.EMAIL, updateUserDto.getEmail()) .withValue(User.FAMILY_NAME, updateUserDto.getFamilyName()) .withValue(User.GIVEN_NAME, updateUserDto.getGivenName()) .withValue( User.USER_DETAIL, ScalarUtil.convertDataObjectToJsonStr(updateUserDto.getUserDetail())) // データオブジェクトをJSON文字列に変換 .withValue(User.USER_GROUPS, ScalarUtil.convertDataObjectToJsonStr(userGroups)) .withValue(User.COMMON_KEY, COMMON_KEY); tx.put(put); // トランザクションを介して、データを登録/更新。 } catch (CrudException e) { throw new RepositoryException("Updating a User failed", e); } } public void deleteUser(DistributedTransaction tx, String userId) { try { Key pk = createPk(userId); // パーティションキーの作成 getAndThrowsIfNotFound(tx, createGet(pk)); // 指定されたIDを持つレコードが存在するかをチェック Delete delete = new Delete(pk).forNamespace(NAMESPACE).forTable(TABLE_NAME); // usersテーブルのレコードを削除するDeleteオブジェクトを作成 tx.delete(delete); // トランザクションを介して、レコードを削除。 } catch (CrudException e) { throw new RepositoryException("Deleting a User failed", e); } } public List<User> listUsers(DistributedTransaction tx) { try { Scan scan = // インデックスキーを作成し、usersテーブルの中で同じキーを持つレコードスキャンするScanオブジェクトを作成 new Scan(new Key(new TextValue(User.COMMON_KEY, COMMON_KEY))) .forNamespace(NAMESPACE) .forTable(TABLE_NAME); List<Result> results = tx.scan(scan); // Scanを実行させ、結果をResultのリストに保持 return results.stream().map(this::parse).collect(Collectors.toList()); //ResultのリストからUserのリストを作成し、返却する } catch (CrudException e) { throw new RepositoryException("Reading Users failed", e); } } public User getUser(DistributedTransaction tx, String userId) { try { Key pk = createPk(userId); return getAndThrowsIfNotFound(tx, createGet(pk)); } catch (CrudException e) { throw new RepositoryException("Reading a User failed", e); } } private Key createPk(String userId) { return new Key(new TextValue(User.GROUP_ID, userId)); } private Get createGet(Key pk) { return new Get(pk).forNamespace(NAMESPACE).forTable(TABLE_NAME); } User parse(@NotNull Result result) { UserBuilder builder = User.builder(); return builder .userId(ScalarUtil.getTextValue(result, User.USER_ID)) .email(ScalarUtil.getTextValue(result, User.EMAIL)) .familyName(ScalarUtil.getTextValue(result, User.FAMILY_NAME)) .givenName(ScalarUtil.getTextValue(result, User.GIVEN_NAME)) .userGroups( ScalarUtil.convertJsonStrToDataObjectList( ScalarUtil.getTextValue(result, User.USER_GROUPS), UserGroup[].class)) .userDetail( ScalarUtil.convertJsonStrToDataObject( ScalarUtil.getTextValue(result, User.USER_DETAIL), UserDetail.class)) .build(); } } リポジトリクラスのユニットテスト Scalar DBでCRUD処理が実行されたか検証します。 ArgumentCaptor.forClassメソッドでArgumentCaptorのインスタンスを生成し、 verify()でモックオブジェクトのメソッドが呼び出されていることをテストするのと同時に、その呼出引数をcapture()します。 test/**/repository/UserRepositoryTest.java @SpringBootTest public class UserRepositoryTest { private static final String MOCKED_USER_ID = UUID.randomUUID().toString(); private static final String MOCKED_EMAIL = "mockedEmail"; @MockBean DistributedTransactionManager manager; @MockBean DistributedTransaction tx; @MockBean Result result; @Autowired UserRepository repository; @BeforeEach private void setUp() throws TransactionException { when(manager.start()).thenReturn(tx); } @Test public void createUser_shouldSuccess() throws TransactionException { CreateUserDto createUserDto = createUserDto(); repository.createUser(tx, createUserDto, MOCKED_USER_ID); ArgumentCaptor<Put> argumentCaptor = ArgumentCaptor.forClass(Put.class); verify(tx, times(1)).put(argumentCaptor.capture()); Put arg = argumentCaptor.getValue(); TextValue email = new TextValue(User.EMAIL, createUserDto.getEmail()); assertEquals(email, arg.getValues().get(”email”)); verify(tx).get(any()); } @Test public void createUser_userAlreadyExists() throws TransactionException { CreateUserDto createUserDto = createUserDto(); when(tx.get(any())).thenReturn(Optional.of(result)); Assertions.assertThrows( ObjectAlreadyExistingException.class, () -> repository.createUser(tx, createUserDto, MOCKED_USER_ID)); } @Test public void createUser_dbSomeProblems_CrudExceptionThrown() throws TransactionException { CreateUserDto createUserDto = createUserDto(); doThrow(CrudException.class).when(tx).put(any(Put.class)); Assertions.assertThrows( RepositoryException.class, () -> repository.createUser(tx, createUserDto, MOCKED_USER_ID)); } private CreateUserDto createUserDto() { CreateUserDtoBuilder builder = CreateUserDto.builder(); return builder.email(MOCKED_EMAIL).build(); } テストを実行します。 $ cd api $ ./gradlew test --tests UserRepositoryTest BUILD SUCCESSFUL in 3s サービスクラスの作成 Scalar DBを使ったトランザクションの実装 サービスクラスで実装するものは以下の2つになります。 - Scalar DBのトランザクションマネージャでトランザクションを開始し、成功した場合の処理と失敗した場合の処理 - アプリケーションのロジック まず、トランザクションマネージャの設定を行う必要があります。サービスクラス全体でトランザクションマネージャを利用できるようにするための設定クラスを作成します。 config/ApiConfig.java @Configuration public class ApiConfig { @Bean @Scope("singleton") DistributedTransactionManager createScalarDBTransactionManager() throws IOException { String databaseProp = "database.properties"; DatabaseConfig scalarDBConfig = new DatabaseConfig(new URL("classpath:" + databaseProp).openConnection().getInputStream()); return Guice.createInjector(new TransactionModule(scalarDBConfig)) .getInstance(TransactionService.class); } } その後、各サービスクラスでトランザクションマネージャを開始し、トランザクションを実装していきます。 以下のコードではグループを新規登録するメソッドと、グループにユーザーを新たに追加するメソッドを実装しています。 ポイントとしては、 - トランザクションマネージャをスタート (DistributedTransaction tx = db.start();) - 1つ以上のリポジトリクラスのメソッドを呼び出す - 全てのリポジトリクラスのメソッドの呼び出しに成功したらトランザクションをコミットする(tx.commit()) - 1つでも呼び出しに失敗したら、トランザクションを中止する (tx.abort()) service/GroupService.java @Service public class GroupService { private final GroupRepository groupRepository; private final UserRepository userRepository; private final DistributedTransactionManager db; @Autowired public GroupService( GroupRepository groupRepository, UserRepository userRepository, DistributedTransactionManager db) { this.groupRepository = groupRepository; this.userRepository = userRepository; this.db = db; } // グループを新規登録する public String createGroup(CreateGroupDto createGroupDto, String userId) throws TransactionException { DistributedTransaction tx = db.start(); // トランザクションマネージャをスタート try { String groupId = UUID.randomUUID().toString();; // IDの生成 groupRepository.createGroup(tx, createGroupDto, groupId); // groupRepositoryのcreateGroupメソッドを呼び出す。 tx.commit(); // 処理が成功した場合はコミットする。 return groupId; } catch (CommitException | UnknownTransactionStatusException | RepositoryException | ObjectAlreadyExistingException e) { tx.abort(); // 処理に失敗した場合はトランザクションを中止 throw new ServiceException("An error occurred when creating a Group", e); } } // グループにユーザーを追加する public void addGroupUser(String groupId, GroupUserDto groupUserDto) throws TransactionException { DistributedTransaction tx = db.start(); // トランザクションマネージャをスタート try { Group group = groupRepository.getGroup(tx, groupId); //グループIDからグループ情報の呼び出し List<GroupUser> groupUsers = //グループ情報から所属しているユーザー情報を取得 Optional.ofNullable(group.getGroupUsers()).orElse(new ArrayList<GroupUser>()); groupUsers.forEach( // すでに所属しているユーザーならば例外をスロー (existingGroupUser -> { if (existingGroupUser.getUserId().equals(groupUserDto.getUserId())) { throw new ObjectAlreadyExistingException(this.getClass(), groupUserDto.getUserId()); } })); groupUsers.add( // 新たにユーザーを追加 ScalarUtil.convertDataObjectToAnotherDataObject(groupUserDto, GroupUser.class)); groupRepository.updateGroupUsers(tx, groupUsers, groupId); // groupRepositoryのupdateGroupUsers()メソッドを呼び出し、グループ情報を更新する User user = userRepository.getUser(tx, groupUserDto.getUserId()); //ユーザー情報を取得 List<UserGroup> userGroups = Optional.ofNullable(user.getUserGroups()).orElse(new ArrayList<UserGroup>()); userGroups.add(getUserGroup(group)); // ユーザーの所属グループ一覧に新たにグループを追加 userRepository.updateUserGroups(tx, groupUserDto.getUserId(), userGroups); // userRepositoryのupdateUserGroups()を呼び出し、ユーザー情報を更新する tx.commit(); // 処理に成功した場合はコミットする } catch (CommitException | UnknownTransactionStatusException | RepositoryException | ObjectNotFoundException e) { tx.abort(); // エラーに失敗した場合はトランザクションを中止しする throw new ServiceException("An error occurred when adding a GroupUser", e); } catch (ObjectAlreadyExistingException e) { tx.abort(); throw new UserAlreadyBelongsException("This User already belongs to This Group", e); } } } サービスクラスのユニットテスト サービスクラスでトランザクションが正しく実装されているか、ユニットテストを行います。 verify()メソッドを使い、正常系・異常系でトランザクションがそれぞれcommitされているかabortされているかを検証します。 test/**/service/GroupServiceTest.java @SpringBootTest public class GroupServiceTest { private static final String MOCKED_GROUP_ID_1 = "mockedGroupId"; private static final String MOCKED_GROUP_ID_2 = "mockedGroupId2"; private static final String MOCKED_USER_ID_1 = "mockedUserId"; private static final String MOCKED_USER_ID_2 = "mockedUserId2"; @Mock UserRepository userRepository; @Mock GroupRepository groupRepository; @MockBean DistributedTransactionManager manager; @MockBean DistributedTransaction tx; GroupService groupService; @BeforeEach private void setUp() throws TransactionException { groupService = new GroupService(groupRepository, userRepository, manager); when(manager.start()).thenReturn(tx); } @Test public void createGroup_shouldSuccess() throws TransactionException { CreateGroupDto createGroupDto = getCreateGroupDto(); User user = getUser(MOCKED_USER_ID_1); when(userRepository.getUser(tx, MOCKED_USER_ID_1)).thenReturn(user); groupService.createGroup(createGroupDto, MOCKED_USER_ID_1); verify(tx, times(1)).commit(); } @Test public void createGroup_whenCommitFailed_shouldServiceException() throws TransactionException { CreateGroupDto createGroupDto = getCreateGroupDto(); User user = getUser(MOCKED_USER_ID_1); when(userRepository.getUser(tx, MOCKED_USER_ID_1)).thenReturn(user); doThrow(CommitException.class).when(tx).commit(); assertThrows( ServiceException.class, () -> groupService.createGroup(createGroupDto, MOCKED_USER_ID_1)); verify(tx).abort(); } @Test public void addGroupUser_shouldSuccess() throws TransactionException { Group group = getGroup(MOCKED_GROUP_ID_1); User user = getUser(MOCKED_USER_ID_2); GroupUserDto groupUserDto = getGroupUserDto(MOCKED_USER_ID_2); when(groupRepository.getGroup(tx, MOCKED_GROUP_ID_1)).thenReturn(group); when(userRepository.getUser(tx, MOCKED_USER_ID_2)).thenReturn(user); groupService.addGroupUser(MOCKED_GROUP_ID_1, groupUserDto); verify(tx, times(1)).commit(); } @Test public void addGroupUser_alreadyBelongingUser_shouldThrowUserAlreadyBelongsException() throws TransactionException { Group group = GroupStub.getGroup(MOCKED_GROUP_ID_1); User user = UserStub.getUser(MOCKED_USER_ID_1); when(groupRepository.getGroup(tx, MOCKED_GROUP_ID_1)).thenReturn(group); when(userRepository.getUser(tx, MOCKED_USER_ID_1)).thenReturn(user); GroupUserDto groupUserDto = GroupStub.getGroupUserDto(MOCKED_USER_ID_1); Assertions.assertThrows( UserAlreadyBelongsException.class, () -> groupService.addGroupUser(MOCKED_GROUP_ID_1, groupUserDto)); verify(tx).abort(); } private CreateGroupDto createGroupDto() { CreateGroupDtoBuilder builder = CreateGroupDto.builder(); return builder.groupName(MOCKED_GROUP_NAME).build(); } private GroupUserDto getGroupUserDto(String userId) { GroupUserDtoBuilder builder = GroupUserDto.builder(); return builder.userId(userId).type(MOCKED_TYPE).build(); } private Group getGroup(String groupId) { GroupBuilder builder = Group.builder(); List<GroupUser> groupUsers = new ArrayList<GroupUser>( Arrays.asList(GroupUser.builder().userId(MOCKED_USER_ID_1).type(MOCKED_TYPE).build())); return builder.groupId(groupId).groupName(MOCKED_GROUP_NAME).groupUsers(groupUsers).build(); } private User getUser(String userId, String groupId) { UserBuilder builder = User.builder(); UserGroup userGroup = UserGroup.builder().groupId(MOCKED_GROUP_ID_1).groupName(MOCKED_GROUP_NAME).build(); List<UserGroup> userGroups = new ArrayList<UserGroup>(Arrays.asList(userGroup)); return builder.userId(userId).userGroups(userGroups).build(); } テストを実行します。 $ cd api $ ./gradlew test --tests GroupServiceTest BUILD SUCCESSFUL in 3s コントローラークラスの作成 コントローラークラスの実装 エンドポイントの設計で定義したエンドポイントに従い、Httpリクエストごとに関連するサービスクラスのメソッドを呼び出し、Httpレスポンスを返します。 実装の流れとしては - @〇〇MappingアノテーションでパスをHTTPメソッドを指定 - @RequestBodyアノテーションでHttpRequestからのJSONデータをDTOにマップし、Javaオブジェクトにデシリアライズ - @PathVariableアノテーションでパス内の変数を受け取る - 受け取った値をサービスクラスに渡す controller/UserController.java @RestController @RequestMapping("/users") public class UserController { private static final String PATH_USER_ID = "user_id"; @Autowired UserService userService; @PostMapping() @ResponseStatus(HttpStatus.CREATED) public String createUser(@RequestBody CreateUserDto createUserDto) throws TransactionException { return userService.createUser(createUserDto); } @PutMapping("/{user_id}") @ResponseStatus(HttpStatus.OK) public void updateUser( @PathVariable(PATH_USER_ID) String userId, @RequestBody UpdateUserDto updateUserDto) throws TransactionException { userService.updateUser(userId, updateUserDto); } @DeleteMapping("/{user_id}") @ResponseStatus(HttpStatus.OK) public void deleteUser(@PathVariable(PATH_USER_ID) String userId) throws TransactionException { userService.deleteUser(userId); } @GetMapping("/{user_id}") @ResponseStatus(HttpStatus.OK) public GetUserDto getUser(@PathVariable(PATH_USER_ID) String userId) throws TransactionException { return userService.getUser(userId); } @GetMapping() @ResponseStatus(HttpStatus.OK) public List<GetUserDto> listUsers() throws TransactionException { return userService.listUsers(); } } controller/GroupController.java @RestController @RequestMapping("/groups") public class GroupController { private static final String PATH_GROUP_ID = "group_id"; private static final String PATH_USER_ID = "user_id"; @Autowired GroupService groupService; @PostMapping() @ResponseStatus(HttpStatus.CREATED) public String createGroup( @RequestBody CreateGroupDto createGroupDto) throws TransactionException { return groupService.createGroup(createGroupDto); } @PutMapping("/{group_id}/group-users") @ResponseStatus(HttpStatus.OK) public void addGroupUsers( @PathVariable(PATH_GROUP_ID) String groupId, @RequestBody GroupUserDto groupUser) throws TransactionException { groupService.addGroupUser(groupId, groupUser); } @PutMapping("/{group_id}/group-users/{user_id}") @ResponseStatus(HttpStatus.OK) public void deleteGroupUser( @PathVariable(PATH_GROUP_ID) String groupId, @PathVariable(PATH_USER_ID) String userId) throws TransactionException { groupService.deleteGroupUser(groupId, userId); } @DeleteMapping("/{group_id}") @ResponseStatus(HttpStatus.OK) public void deleteGroup(@PathVariable(PATH_GROUP_ID) String groupId) throws TransactionException { groupService.deleteGroup(groupId); } @GetMapping("/{group_id}/group-users") @ResponseStatus(HttpStatus.OK) public List<GroupUserDto> listGroupUsers(@PathVariable(PATH_GROUP_ID) String groupId) throws TransactionException { return groupService.listGroupUsers(groupId); } @GetMapping() @ResponseStatus(HttpStatus.OK) public List<GetGroupDto> listGroups() throws TransactionException { return groupService.listGroups(); } } コントローラークラスのユニットテスト test/**/controller/UserControllerTest.java @WebMvcTest(UserController.class) public class UserControllerTest { private static final String BASE_URL_PATH = "/users"; private static final String MOCKED_USER_ID = "6695bdd7-ccb3-0468-35af-e804f79329b2"; private MockMvc mockMvc; @MockBean private UserService userService; @MockBean private UserRepository userRepository; @MockBean DistributedTransactionManager manager; @Autowired UserController userController; @Autowired private ObjectMapper objectMapper; @Autowired private WebApplicationContext context; @MockBean private AuthenticationService authenticationService; @BeforeEach public void setup() { mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); } @Test void createUser_shouldSuccess() throws Exception { CreateUserDto createUserDto = UserStub.getCreateUserDto(); when(userService.createUser(createUserDto)).thenReturn(MOCKED_USER_ID); mockMvc .perform( post(BASE_URL_PATH) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(createUserDto))) .andExpect(status().isCreated()); } @Test void updateUser_shouldSuccess() throws Exception { UpdateUserDto updateUserDto = UserStub.getUpdateUserDto(); mockMvc .perform( put(BASE_URL_PATH + "/" + MOCKED_USER_ID) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateUserDto))) .andExpect(status().isOk()); } @Test void deleteUser_shouldSuccess() throws Exception { mockMvc.perform(delete(BASE_URL_PATH + "/" + MOCKED_USER_ID)).andExpect(status().isOk()); } @Test void getUser_shouldSuccess() throws Exception { mockMvc.perform(get(BASE_URL_PATH + "/" + MOCKED_USER_ID)).andExpect(status().isOk()); } @Test void listUsers_shouldSuccess() throws Exception { mockMvc.perform(get(BASE_URL_PATH)).andExpect(status().isOk()); } } $ cd api $ ./gradlew test --tests UserControllerTest BUILD SUCCESSFUL in 3s 次回はSpring Securityを使ったアクセス制御とCucumberを使ったE2Eテストの仕方について説明します。 Spring BootとScalar DBを用いたAPIの作り方③
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Spring Boot と Scalar DB を用いた API の作り方①

TL;DR 株式会社Scalarが開発している分散トランザクションマネージャScalar DBを使ってトランザクション処理を行うAPIを作りました。 その中で学んだScalar DBの特徴やScalar DBを使ったAPIの開発手法について説明していこうと思います。 長くなりますので3回に分けて投稿します。 Spring BootとScalar DBを用いたAPIの作り方①(本記事) Spring BootとScalar DBを用いたAPIの作り方② Spring BootとScalar DBを用いたAPIの作り方③ 完成したプロジェクトはGitHubに公開してあります。 目次 Spring Boot で Scalar DB を使う理由 Scalar DBの概要 Scalar DBを使うメリット Spring BootにおけるAPIアーキテクチャと各レイヤーの役割 各レイヤーの役割 Scalar DBの各レイヤーでの実装 APIアーキテクチャ サンプルアプリケーションの概要 要件 環境構築 ディレクトリ構成 データモデルの設計 エンドポイントの設計 Spring BootでのScalar DBの導入方法 ライブラリのインストール プロパティファイルの作成 スキーマファイルの作成 データスキーマのセットアップ Spring Boot で Scalar DB を使う理由 Scalar DB の概要 Scalar DBは、ACID準拠でないデータベースに対して、ACID準拠のトランザクションを可能にするトランザクションマネージャです。 Scalar DBは下の図のように、Storage Abstraction LayerとStorage AdaptersによってStorageとして利用するデータベースへのアクセスを抽象化しているため、Scalar DB を利用するアプリケーションはストレージとして利用するデータベースの違いを意識せず、同じプログラムで様々なデータベースが利用できます。 Scalar DB では、複数の異なるデータベースでも、1つのデータベースとして扱うことができます。このため、同じプログラムで、複数の異なるデータベースに対するACIDトランザクション処理が行えます。 現在対応しているデータベースはCassandra、Cosmos DB、DynamoDB、MySQL、PostgreSQLになります。 サポートしているデータベース 詳しい説明は公式ドキュメントをどうぞ Scalar DBを使うメリット Spring Bootでは@Transactionアノテーションを付与してトランザクション処理を実装することが多いと思います。 Scalar DB を使ったトランザクションの実装を行うと次のような利点があります。 データベースの種類にかかわらず、トランザクション分離レベルを Strict Serializable にできる 異なるデータベース/ストレージでも同じコードで実装できる 別々の、あるいは異なるデータベース間でも、同じインターフェイスで、同じ分離レベルで実装することができる。 データベースの種類にかかわらず、トランザクション分離レベルを Strict Serializable にできる @Transactionアノテーションによってトランザクションを実装する場合、トランザクション分離レベルは利用するデータベース/ストレージに依存します。 一方、Scalar DBはトランザクション分離レベルがRead Committed Snapshot Isolationに設定されており、最大でStrict Serializableの分離レベルまで設定可能です。 そのため、Serializable サポートが限定的なデータベースでも対応することができます。 異なるデータベース/ストレージでも同じコードで実装できる 上述したように、Scalar DBはデータベース/ストレージへのアクセスを抽象化しているため、アプリケーション側でデータベース/ストレージの種類を気にする必要はありません。そのため、Scalar DBがサポートしているデータベース/ストレージであれば同じプログラムで実装できます。 また、後述する設定ファイルを書き換えるだけでコードを一切変更することなく、データベース/ストレージを変更できます。 別々の、あるいは異なるデータベース間でも、同じインターフェイスで、同じ分離レベルで実装することができる。 @Transactionアノテーションでは、異なるデータベース/ストレージ間でACIDトランザクションを実行することはできません。 一方、Scalar DBはマルチストレージ対応が行われており、異機種間データベース間、パーティション間、テーブル間といったトランザクションの制約を受ける環境でも、同じインターフェイスで、最大 でStrict Serializable の分離レベルで実装することができます。 ※ 参考: String Serializable Spring BootにおけるAPIアーキテクチャと各レイヤーの役割 各レイヤーの役割 Spring Bootは、以下の4つレイヤーが存在し、各レイヤーがその真下または真上のレイヤーと通信するレイヤードアーキテクチャに従います。 Presentation Layer Presentation LayerはControllerクラスで構成され、HTTPリクエストを処理し、JSONパラメーターをオブジェクトに変換し、リクエストを認可してBusiness Layerに転送します。 Business Layer Business Layerはビジネスロジックを処理します。Serviceクラスで構成され、Presentation Layerからのデータを処理し、Persistence Layerに転送し、その結果をPresentation Layerに返します。また、認証やValidationの処理も行います。 Persistence Layer Persistence Layerは、Repositoryクラスで構成され、データベースとの接続を行い、Business Layerから転送されたデータのCRUD処理を依頼し、その結果をBusiness Layerに返します。 Database Layer Database LayerはPersistence Layerから転送されるデータと依頼に対して、CRUD処理を実行します。 Scalar DBの各レイヤーでの実装 Scalar DB は、Persistence Layerで記述した処理を、Business Layerで一連の処理として実行したい処理をトランザクションとしてまとめて実行します。 - Persistence Layerでは、Scalar DBのメソッドを使いデータベースのテーブルに対するCRUD処理を記述します。 - Business Layerでは、Scalar DBのメソッドを使いトランザクションの処理を記述します。 APIアーキテクチャ 以上を踏まえて、Spring BootにおけるAPIアーキテクチャは以下のようになります。 DTOはHTTPリクエストから転送、または返却するためのデータを保持します。Modelはデータベースに永続化するためのデータを保持し、MapperでDTOとModelのデータをマッピングしながら、Controllerクラス、Serviceクラス、Repositoryクラスにデータを渡していきます。 サンプルアプリケーションの概要 ユーザーは、どのグループに所属するのかという情報を保持し、グループにはどのユーザーが所属しているのかというメンバー情報を保持しています。 このため、ユーザーの所属グループを変更する場合、ユーザーとグループの両方を同時に更新する必要があります。 ※ 一般的に、NoSQLデータベースを用いた実装では、第三正規化が行われないため、ユーザーとグループの双方で情報を持ち、どちらの参照要求にも対応できるように実装します。 ユーザーは、管理者権限と一般権限があり、所属しているグループによって、権限を割り当てアクセス制御を行います。 要件 全ユーザーは自身の情報を登録できる 一般ユーザーは自分自身の情報の取得、更新、削除ができる 管理者は全てのユーザー情報の取得、更新、削除ができる 管理者および一般ユーザーはグループを作成できる。作成したユーザーは自動的にそのグループに所属する 一般ユーザーは所属するグループに対して、メンバーの追加・脱退、メンバー一覧の取得、グループの削除ができる 管理者は全てのグループに対してメンバーの追加・脱退、メンバー一覧の取得、グループの削除ができる 一般ユーザーおよび管理者は全てのグループを一覧取得できる 環境構築 Spring Boot 2.5.6 Scalar DB 3.3.0 Java 8 Cassandra 3.11 ディレクトリ構成 . ├── api | ├── build.gradle │   ├── src │   │   ├── main │   │   │   ├── java │   │   │   │   └── com │   │   │   │   └── example │   │   │   │   └── api │   │   │   │   ├── Application.java │   │   │   │   ├── config │   │   │   │   ├── controller │   │   │   │   ├── dto │   │   │   │   ├── exception │   │   │   │   ├── model │   │   │   │   ├── repository │   │   │   │   ├── service │   │   │   │   └── util │   │   │   └── resources │   │   │   └── database.properties │   │   └── test │   │   ├── java │   │   │   └── com │   │   │   └── example │   │   │   └── api │   │   │   ├── controller │   │   │   ├── cucumber │   │   │   ├── repository │   │   │   ├── security │   │   │   ├── service │   │   └── resources └── tools │  ├── scalardb-schema-loader-3.3.0.jar │  └── schema │  └── schema.json └── docker-compose.yml データモデルの設計 今回作成したAPIのデータモデルは以下のようになります。 ユーザーとグループ管理を NoSQLであるCassandra を用いて行うケースを想定しています。 Userテーブルには、どのグループに所属しているかという情報を保持し、Groupテーブルにはどのようなユーザーが所属しているのかという情報を保持しています。 このため、どちらかのテーブルを更新する際には、双方に存在するレコードを同時に更新する必要があります。 エンドポイントの設計 エンドポイントは以下のように設計しました。 URI HTTPメソッド Description /users POST ユーザーを登録する /users GET ユーザーを一覧取得する /users/{user_id} GET ユーザー情報を取得する /users/{user_id} PUT ユーザー情報を更新する /users/{user_id} DELETE ユーザーを削除する /groups POST グループを登録する /groups GET グループ一覧を取得する /groups/{group_id}/group-users PUT グループにユーザーを追加する /groups/{group_id}/group-users GET グループに所属するユーザーを一覧取得する /groups/{group_id}/group-users/{user_id} PUT グループからユーザーを脱退させる /groups/{group_id} DELETE グループを削除する Spring BootでのScalar DBの導入方法 Scalar DBは、ライブラリをインストールし、プロパティファイルとスキーマファイルを作成し、スキーマローダーを使ってプロパティファイルとスキーマファイルを読み込ませることによってデータベースに名前空間とテーブルを作成することができます。 ライブラリのインストール Scalar DBのライブラリはMaven Centralから利用可能です。GradleやMavenなどのビルドツールを使ってインストールを行ってください。 build.gradle dependencies { implementation group: 'com.scalar-labs', name: 'scalardb', version: '3.3.0' } pom.xml <dependency> <groupId>com.scalar-labs</groupId> <artifactId>scalardb</artifactId> <version>3.3.0</version> </dependency> プロパティファイルの作成 Scalar DBがデータベース/ストレージと接続を行うためのプロパティファイルを作成します。 今回はローカル環境でCassandraを使用します。ファイル名はdatabase.propertiesとします。 Scalar DBがサポートしているデータベース 各データベースでのプロパティファイルの設定方法を参照してください。 database.properties # Comma separated contact points scalar.db.contact_points=localhost # Port number for all the contact points. Default port number for each database is used if empty. scalar.db.contact_port=9042 # Credential information to access the database scalar.db.username=cassandra scalar.db.password=cassandra # Cassandra storage implementation scalar.db.storage=cassandra スキーマファイルの作成 スキーマファイルはJSONファイルで作成します。今回はschema.jsonとしておきます。 schema.json { "demo.groups": { "transaction": true, "partition-key": [ "group_id" ], "clustering-key": [], "columns": { "group_id": "TEXT", "group_name": "TEXT", "group_users": "TEXT", "common_key": "TEXT" }, "secondary-index": [ "common_key"] }, "demo.users": { "transaction": true, "partition-key": [ "user_id" ], "clustering-key": [], "columns": { "user_id": "TEXT", "email": "TEXT", "family_name": "TEXT", "given_name": "TEXT", "user_detail": "TEXT", "user_groups": "TEXT", "common_key": "TEXT" }, "secondary-index": [ "common_key" ] } } demoの部分がCassandraのキースペースになります。 demoの後のピリオドに続く、groupsやusersがテーブル名になります。 トランザクションを行いたい場合はtransactionをtrueにします。 その後、設計したデータモデルに合わせて、partition-key, clustering-keyを指定し、columnsにキーとデータタイプを記述します。Scalar DBでサポートしているデータタイプはDatabase schema in Scalar DBを参照してください。 また、今回はテーブルデータを全件取得するためにsecondary-indexを作成しました。 本来、Cassandraにおいてセカンダリ・インデックスの使用は推奨されませんが、今回作成するAPIはデータ件数が多くないこと、更新頻度が高くないカラムであることから使用することにしました。 データスキーマのセットアップ 作成したプロパティファイルと、スキーマファイルからデータベースにデータスキーマのセットアップを行います。 まずはCassandraが起動していることを確認してください。 今回は、dockerで起動させました。 docker-compose.yml version: '3.8' services: cassandra: image: arm64v8/cassandra:3.11 container_name: "cassandra-1" volumes: - ./docker/cassandra-data:/var/lib/cassandra ports: - "9042:9042" $ docker-compose up -d Scalar DBのGitHubからインストールしたScalarDBと同じバージョンのスキーマローダーをダウンロードします。 各ファイルの配置場所はディレクトリ構成を参照してください。 $ java -jar tools/scalardb-schema-loader-3.3.0.jar --config api/src/main/resources/database.properties --coordinator -f tools/schema/schema.json 以下が実行結果です。 ******************************** ******************************** [main] INFO com.scalar.db.schemaloader.core.SchemaOperator - Creating the table groups in the namespace demo succeeded. [main] INFO com.scalar.db.schemaloader.core.SchemaOperator - Creating the table users in the namespace demo succeeded. テーブルが作成できました。念の為、cqlで確認してみましょう。 $ docker exec -it cassandra-1 cqlsh cqlsh> DESC demo.users CREATE TABLE demo.users ( user_id text PRIMARY KEY, before_common_key text, before_email text, before_family_name text, before_given_name text, before_tx_committed_at bigint, before_tx_id text, before_tx_prepared_at bigint, before_tx_state int, before_tx_version int, before_user_detail text, before_user_groups text, common_key text, email text, family_name text, given_name text, tx_committed_at bigint, tx_id text, tx_prepared_at bigint, tx_state int, tx_version int, user_detail text, user_groups text ) cqlsh> DESC demo.users CREATE TABLE demo.groups ( group_id text PRIMARY KEY, before_common_key text, before_group_name text, before_group_users text, before_tx_committed_at bigint, before_tx_id text, before_tx_prepared_at bigint, before_tx_state int, before_tx_version int, common_key text, group_name text, group_users text, tx_committed_at bigint, tx_id text, tx_prepared_at bigint, tx_state int, tx_version int ) 無事、データスキーマのセットアップが完了しました。 次回は、具体的なAPIの実装方法について説明していきます。 Spring BootとScalar DBを用いたAPIの作り方②
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

プロジェクト間共通ライブラリを GitLab Package Registry に登録して使う

そこそこ以上の規模のプロジェクトを作る際、どうしても複数プロジェクト間で共有する共通ライブラリ(*.jarなど)を作りたいことがあると思うのですが、GitLabには GitLab Package Registry という 共通ライブラリをGitLabに登録できる便利な機能 があります。 通常であれば、 Sonatype Nexus や JFrog といったアーティファクトマネージャを別個で立てなければならないのですが、これを使えばGitLab一つで済むので非常に楽です。 従来は有料版のみの機能だったのですが、なんと 13.3 から全エディションで利用可能になっています。 そういうことで、実際に仕事で使ってみたのですが、ややドキュメントが分かりづらかったので、概要を適当にまとめておきます。 結局 GitLab Package Registry とはどういう機能なのか? 公式ドキュメント: https://docs.gitlab.com/ee/user/packages/package_registry/ GitLab上に *.jar (Maven) や *.dll (.NET) などを登録できるプライベートなパッケージレジストリを持つことができます。自前でGitLabを運用している場合は他機能と同様、パッケージファイルそのものはS3上にオフロードすることが可能です。 ここでいうパッケージというのは、下記を指します。 Maven (Java) NPM (JavaScript) NuGet (.NET) PyPi (Python) 汎用ファイル置き場 以下はまだ開発中 (2022/01/11時点) Ruby Gems (Ruby) Composer (PHP) Go (プロキシ) Debian packages Conan (C/C++) Helm chart この記事ではMaven (Gradle)で構成していきます。 パッケージ種類ごとにかなり設定値や方法が異なるので、他のパッケージを登録する際はドキュメントをチェックしてください。 一般的な利用方法としては、リポジトリ上に共有ライブラリプロジェクトを作成して、そのプロジェクトのCIがパスしたらビルドパイプラインの最後にそのプロジェクトのパッケージレジストリに、バージョン付きでライブラリバイナリをpushして利用するのがほとんどだと思います。 弊社でも画面とAPIの共通仕様を共有ライブラリプロジェクトとして作成して、両者のコード生成に利用しています。 Maven レジストリの場所(エンドポイント)・認証方法 プライベートなパッケージレジストリなので、利用にはレジストリのURLおよび、レジストリにログインするためのユーザIDとパスワードによる認証が必要です。 レジストリのURL (エンドポイントURL) リポジトリレベルのレジストリ (登録・参照用) パッケージはすべてプロジェクト内のレジストリに登録していきます。 ドキュメントを見ると、グループやインスタンス(GitLab)レベルでレジストリが生えてそうな気がしますが、そいつらは参照専用のレジストリであって、結局のところパッケージはプロジェクト以外には登録できません。ハマりポイントです。 具体的には下記のようなURLがプロジェクトの(登録用の)MavenレジストリのURLになります。 https://gitlabのURL/api/v4/projects/プロジェクトID/packages/maven # e.g. プロジェクトID=100であれば # https://gitlabのURL/api/v4/projects/100/packages/maven グループ・インスタンス(GitLab)レベルのレジストリ (参照用) 各プロジェクトに登録されたパッケージを参照する際はプロジェクト単位・グループ単位・GitLab全体(インスタンスレベル)といった複数の粒度でパッケージを参照することができます。 これは、Mavenがたまたまそうなっているだけで、他のパッケージの種類だと使えない粒度があります。たとえば、npmだとグループレベルのレジストリはありません。 具体的には下記のようなURLで参照することが可能です。 (プロジェクト単位のURL形式はさっきと同じ) グループ単位 https://gitlabのURL/api/v4/groups/グループIDもしくはグループ名/-/packages/maven # e.g. グループID=64であれば # https://gitlabのURL/api/v4/groups/64/-/packages/maven # e.g. グループ名=foo/barであれば # https://gitlabのURL/api/v4/groups/foo%2Fbar/-/packages/maven GitLab全体(インスタンスレベル) https://gitlabのURL/api/v4/packages/maven ※ Mavenの場合は "パッケージ名" = "グループ名/プロジェクト名" という一致をしているパッケージ名じゃないとインスタンスレベルのURLは利用不可 (要らぬ制約のように見えますが、gitlab.comをホスティングする運用を考えれば当然の制約ではあります) Mavenのパッケージを登録する時はプロジェクト単位のURL、参照する時はグループ単位のURLを使うのが一般的 かなと思います。別にプロジェクトのリポジトリをグループ内で使いまわしてもいいですが…… レジストリの認証方法 Mavenパッケージレジストリにおいて、ログインのための認証情報は下記の3パターンのいずれかを利用します。 プライベートトークン ユーザID: Private-Token パスワード: GitLabのユーザ設定よりapiスコープをつけたプライベートトークンを発行したもの デプロイトークン ユーザID: Deploy-Token パスワード: プロジェクトの設定より発行したread_package_registry / write_package_registry スコープをつけたデプロイキー CIトークン ユーザID: Job-Token パスワード: CI/CDパイプライン中のジョブ実行中、 CI_JOB_TOKEN という環境変数に入っている値 (※ トークンの権限範囲はリポジトリにpushしたユーザに準じます) ローカルの開発環境ではプライベートトークン、GitLab CI/CD実行中はCIトークンを使えば問題ありません。 デプロイトークンは外部コントリビューターとのパッケージ読込・登録を行う際に利用すればいいでしょう。 GradleでGitLab Package Registry (Maven)を使ってみよう Gradle (Groovy) でGitLab Package Registryを使ってみます。 手動でパッケージ登録してみよう 下記を build.gradle なりに記述して ./gradlew publish を実行するだけです。 build.gradle publishing { publications { mavenJava(MavenPublication) { from components.java } } repositories { maven { url "https://GitLabのURL/projects/プロジェクトID/packages/maven" credentials(HttpHeaderCredentials) { name = "Private-Token" value = "プライベートトークン" } authentication { header(HttpHeaderAuthentication) } } } } なお、実際プライベートトークンをそのまま build.gradle に記述するのはセキュリティ上大いに問題があるので、 ~/.gradle/gradle.properties などの外部ファイルに記述を移すことをおすすめします。(Gradleから読み込めれば記述場所はなんでもいいです) build.gradle credentials(HttpHeaderCredentials) { name = "Private-Token" value = myPrivateToken // <= "プライベートトークン" } ~/.gradle/gradle.properties myPrivateToken=プライベートトークン Gradle からパッケージを参照してみよう グループレベルでパッケージを参照してみます。 リポジトリの部分に下記を追加すればOKです。 build.gradle maven { name "GitLab" url "https://GitLabのURL/api/v4/groups/グループID/-/packages/maven" credentials(HttpHeaderCredentials) { name = "Private-Token" value = "プライベートトークン" } authentication { header(HttpHeaderAuthentication) } } 登録同様、 "プライベートトークン" 部分は build.gradle に書かないほうがいいでしょう。 GitLab CI/CD させてみよう 上記までの例では、プライベートトークンのみ利用していましたが、build.gradleにプライベートトークンベタ書きでもしない限り、この設定をGitLab CI/CDにはそのまま使えません。 GitLab CI/CDでは CI_JOB_TOKEN という環境変数に入っている値を Job-Token というユーザー名とともに使用する必要があります。 なので、やや冗長ですが、 build.gradle 上では下記のような記述で Private-Token と Job-Token 両者を共存させる必要が出てきます。 build.gradle(例としてリポジトリ参照) maven { name "GitLab" url "https://GitLabのURL/api/v4/groups/グループID/-/packages/maven" credentials(HttpHeaderCredentials) { def ciToken = System.getenv("CI_JOB_TOKEN") // CI_JOB_TOKEN が指定されている場合とされていない場合で指定を分ける if (ciToken) { name = "Job-Token" value = ciToken } else { name = "Private-Token" // myPrivateToken をそのまま参照すると // ~/.gradle/gradle.properties に指定がない場合に落ちるので間接的に参照する value = project.properties["myPrivateToken"] } } authentication { header(HttpHeaderAuthentication) } } もしくは、事前に ~/.gradle/gradle.properties にユーザ名とパスワードを用意しておく構成にしてもいいです。CIではビルド前にこのファイルを用意するステップを追加すればいいでしょう。 (こちらの手法であれば、さらにDockerでコンテナをビルドしたいといった場合に RUN --mount=type=secret を使ってシークレットを一時的にマウントする手法にも応用できます) ~/.gradle/gradle.properties gitlabRepositoryUsername=foo gitlabRepositoryPassword=プライベートトークン (or ジョブトークン) build.gradle(例としてリポジトリ参照) maven { name "GitLab" url "https://GitLabのURL/api/v4/groups/グループID/-/packages/maven" credentials(HttpHeaderCredentials) { name = gitlabRepositoryUsername value = gitlabRepositoryPassword } authentication { header(HttpHeaderAuthentication) } } ちなみにCI/CDで登録したパッケージは契機になったブランチ・コミットハッシュが表示されるようになります。便利ですね! (上段が手動、下段がCI/CDで登録) まとめ そういうことでGitLab Package Registryの簡単な使い方でした。 これのほかにも、GitLabには Dockerイメージを登録できる GitLab Container Registry docker pull のキャッシュを取ってくれる Dependency Proxy 将来的にいろんなパッケージに対応してくれるのを期待 Terraformのtfstateの管理をしてくれる GitLab managed Terraform State といったソフトウェア開発を支援する機能があり、GitLab一本でソフトウェアプロジェクト周りの管理が完結できるようになってきています。 規模の小さいプロジェクトであればGitLab一つあれば完結するのではと思います。GitLabの今後にますます期待です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む