ArchUnitを入れて、プロジェクト内のアーキテクチャルールをテストする

エキサイト株式会社メディア事業部エンジニアの佐々木です。弊社アドベントカレンダー5日目を担当させていただきます。メディア事業部では、SpringBootを用いて日々ソフトウェア開発を行っていますが、サービスが大きくなったり人が増えたりするとアーキテクチャが障害にならないまでも部分的に守られていないことが起こります。Javaの世界ではArchUnitというアーキテクチャをテストしてくれるものがあり、弊社での利用を紹介いたします。

www.archunit.org

前提

openjdk 21.0.1 2023-10-17 LTS
OpenJDK Runtime Environment Corretto-21.0.1.12.1 (build 21.0.1+12-LTS)
OpenJDK 64-Bit Server VM Corretto-21.0.1.12.1 (build 21.0.1+12-LTS, mixed mode, sharing)


------------------------------------------------------------
Gradle 8.5
------------------------------------------------------------

Build time:   2023-11-29 14:08:57 UTC
Revision:     28aca86a7180baa17117e0e5ba01d8ea9feca598

Kotlin:       1.9.20
Groovy:       3.0.17
Ant:          Apache Ant(TM) version 1.10.13 compiled on January 4 2023
JVM:          21.0.1 (Amazon.com Inc. 21.0.1+12-LTS)
OS:           Mac OS X 12.5 aarch64


  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.2.0)

アプリケーションのサンプルコード

今回は、アプリケーションのアーキテクチャとしてサービス階層でトランザクションを貼るようなものだとします。要件としては、トランザクションを貼る際に接続するデータベースを決定します。デフォルトでは更新系DBを参照してますが、参照系はリードレプリDBに接続を期待します。そのアノテーション@Transactional(readonly=true)となります。

下記は、@Transactionalアノテーションを貼っていないケース、@Transactional(readOnly=false)アノテーションを貼っているケースで両方とも失敗することが期待値です。

@Service // @Transactionalのアノテーションを貼っていない
class DemoServiceImpl implements DemoService {

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

    @Override
    public Object get10SecondsExpirationData() throws InterruptedException {
        return "expiration 10 seconds \n";
    }

    @Override
    public Object get5SecondsExpirationData() throws InterruptedException {
        return "expiration 5 seconds \n";
    }
}

@Service
@Transactional(readOnly = false) // @Transactionalのアノテーションを貼っているのだが、readOnly=trueではない
class DemoService2Impl implements DemoService {

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

    @Override
    public Object get10SecondsExpirationData() throws InterruptedException {
        return "expiration 10 seconds \n";
    }

    @Override
    public Object get5SecondsExpirationData() throws InterruptedException {
        return "expiration 5 seconds \n";
    }
}

テストコード

class ArchitectureTest {
    @Test
    @DisplayName("@Serviceの場合には@Transactionalをつけるルール")
    void serviceAnnotationWithCacheableTest() {
        JavaClasses javaClasses = new ClassFileImporter().importPackages("jp.co.excite"); // テストをするパッケージを指定します。
        ArchRule archRule = classes()
                .that().areAnnotatedWith(Service.class)   // that() を使うと対象のクラスやメソッドを絞り込めます
                .should().beAnnotatedWith(Transactional.class);  // should()以降でアーキテクチャがどのような状態だったら正しいかを指定できます
        archRule.check(javaClasses); // アーキテクチャルールで抽出したクラスをテストします。

    }

    @Test
    @DisplayName("@Transactionalは、readonlyをつけるルール")
    void serviceAnnotationWithTransactionalHaveReadonlyProperty() {
        JavaClasses javaClasses = new ClassFileImporter().importPackages("jp.co.excite");  // テストをするパッケージを指定します。
        ArchRule archRule = classes()
                .that().areAnnotatedWith(Service.class)
                .and().areAnnotatedWith(Transactional.class)
                 
                // アーキテクチャルールが複雑な場合は、無名クラスでルールの作成できます。
                .should(new ArchCondition<>("@Serviceの場合には@Transactionalをつけて、中身はreadonly=trueにするルール") {
                    @Override
                    public void check(JavaClass item, ConditionEvents events) {
                        boolean valid = item.getAnnotationOfType(Transactional.class).readOnly();
                        if (!valid) {
                            events.add(SimpleConditionEvent.violated(item, item.getFullName()));
                        }
                    }
                });
        archRule.check(javaClasses);
    }

}

BDDのような書き方でアーキテクチャルールを書くことが可能です。

実行してみます。

ArchitectureTest > @Transactionalは、readonlyをつけるルール FAILED
    java.lang.AssertionError at ArchitectureTest.java:46

ArchitectureTest > @Serviceの場合には@Transactionalをつけるルール FAILED
    java.lang.AssertionError at ArchitectureTest.java:26

AdventcalendarApplicationTests > contextLoads() FAILED
    java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
        Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException at ConstructorResolver.java:802
            Caused by: org.springframework.beans.factory.NoUniqueBeanDefinitionException at DependencyDescriptor.java:218

@Transactionalは、readonlyをつけるルール FAILED @Serviceの場合には@Transactionalをつけるルール FAILED

期待通りテストは失敗しています。

まとめ

アーキテクチャをドキュメントに残したり、レビューで修正するのはとても大切なことですが、すり抜けてしまうことはあります。アプリケーションとしては動作してしまうので、見つけたときは修正にコストがかかってしまったりしますので、こういうテストが自動化できるのは大変ありがたいと思います。ドキュメント化をおろそかにすることなく、継続的にArchUnitの充実も指定校と思います。

最後に

エキサイトではフロントエンジニア、バックエンドエンジニア、アプリエンジニアを随時募集しております。長期インターンも歓迎していますので、興味があれば連絡いただければと思います。

募集職種一覧はこちらになります!(カジュアルからもOK) www.wantedly.com

Spring Bootのアノテーションによるバリデーションを、任意の値に対して行う方法

こんにちは。 エキサイト株式会社の三浦です。

こちらは、エキサイトホールディングス Advent Calendar 2023の11日目の記事になります。

qiita.com

良ければ他の記事もどうぞ!

さて、Spring Bootでは、アノテーションを使ってクラス内の特定のフィールドに対してバリデーションを掛けることができます。

今回は、そのアノテーションによるバリデーションを、クラスのフィールドに限らず任意の値に掛ける方法を紹介します。

アノテーションによるバリデーション

Spring Bootでは、Controllerにおいて、アノテーションを使って以下のように値にバリデーションを掛けることができます。

package sample.controller;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.stereotype.Controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping
public class SampleController {

    @GetMapping("sample")
    public String sample(@Validated @ModelAttribute SampleClass sampleClass) {
        return "sample_page";
    }

    @Data
    public static class SampleClass {
        @NotBlank
        private String sampleField;
    }
}

今回は、 sampleField が空白だとバリデーションで弾かれる、というものです。

結果は以下のようになります。

/sample へのアクセス時

/sample?sampleField=aa へのアクセス時

このように、Controllerであれば非常に簡単にアノテーションを掛けることができるのですが、では任意の値にこのアノテーションの内容でバリデーションを掛けるにはどうすれば良いのでしょうか?

任意の値に、アノテーションを使ったバリデーションを掛ける方法

任意の値にバリデーションを掛ける際は、少々手順としては迂回が入りますが、それでも難しい方法ではありません。

package sample.controller;

import jakarta.validation.Validator;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping
@RequiredArgsConstructor
public class SampleController {
    // 自分のタイミングでバリデーションを行えるバリデータ
    private final Validator validator;

    @GetMapping("sample")
    public String sample() {
        final String validateText = "";

        if (!this.isValid(validateText)) {
            throw new RuntimeException("invalid");
        }

        return "sample_page";
    }

    public Boolean isValid(String sampleField) {
        final SampleClass sampleClass = new SampleClass();
        sampleClass.setSampleField(sampleField);

        return validator
                .validate(sampleClass)
                .isEmpty();
    }

    @Data
    public static class SampleClass {
        @NotBlank
        private String sampleField;
    }
}

Spring Bootでは、 Validator という、先程Controllerの引数で自動的に行なっていたバリデーションを任意のタイミングで実行できる機能が存在します。

それを使って、好きなタイミングでバリデーションを行うだけです。

  1. バリデーション用のクラスを作る
  2. バリデーション用のクラスにバリデーションを掛けたいデータを入れる
  3. バリデーションを実行する

のように、一度データをクラスに詰めるということはしなくてはいけませんが、それでもそこまで大した手間なくアノテーションのバリデーションを任意の値に掛けることができるようになりました。

validateText に空白文字を入れた場合

ここではControllerのクラスで実行していますが、もちろんController以外でも使用可能です。

最後に

空白文字のバリデーションくらいであればアノテーション以外でも適切なバリデーション方法はあるでしょうが、複雑なバリデーションや、アノテーションでしか実装されていないバリデーションなどがある場合はこういった方法が有効な場面もあるでしょう。

そのような場合にぜひ活用してみてください!

GA4のクリックイベントの追跡とデバッグ:2023年版

 Google Analytics4 event 2023

エキサイト株式会社デザイナーの鳥井です。 この記事はエキサイトホールディングス Advent Calendar 2023の8日目の記事です。

この記事では、Google アナリティクス 4(GA4)で収集するウェブサイト上でのクリックイベントの追跡とデバッグの方法を紹介します。

💡GA4では外部リンクのクリックイベントは「click」としてデフォルトで追跡されます。しかし内部リンクのクリックイベントのトラッキングはデフォルトでは設定されていません。内部リンクのクリックをトラッキングする場合はカスタムイベントを設定してある状態とします。今回確認&デバッグするのは外部リンクの「click」または独自に作成したカスタムイベント名です。UAのgtagで収集していたクリックイベントタグがある場合は[管理]→[データ表示]→[カスタム定義]でイベントを追加します。

1. GA4のリアルタイムレポート

GA4にログインして[レポート]→[リアルタイム]→[イベント数]→[click]またはカスタム定義で登録されたパラメーター名を確認します。サイト内で過去 30 分間に発生したイベントを確認できます。

2. GA4のイベントレポート

GA4にログインして[レポート]→ライフサイクル[エンゲージメント]→[イベント]から確認します。イベントパラメーターを探す時は🔍検索では検索できないので表のイベント名の+から探します。 前日までのデータなのでリアルタイムには確認できません。

💡クリックイベントがクリックしているにもかかわらず表示できない時は[管理]→[データの収集と修正]→[データフィルタ]から内部トラフィックなどの除外を行なっていないか確認します。

3. GA4 DebugView

GA4にログインして[管理]→[データの表示]→[DebugView]から開発用デバイスでイベントの取得状況をリアルタイムに確認できます。

手順

  1. tagassistant.google.comにアクセスし確認したいドメインを追加
  2. サイトを開きクリックイベントの確認の場合はリンクをクリック
  3. DebugViewで30分以内にリアルタイムのイベントを確認する。Tag Assistantのページでもどのようなイベントがどんな値を送っているか確認できます。Google Tag Managerのプレビューモードと同様です。

Google Analyticsのデバッグビューの画面で直近発生したイベントが時間軸に表示されている
↑ Google Analytics4のデバッグビューの画面

Tag Assistantのページ、発火したイベントが並んでいてクリックすると詳細を見ることができる
↑ Tag Assistant

Chrome拡張機能(Google Analytics Debugger)

Google Analytics Debuggerをインストールするとサイトを開いた状態で拡張機能をクリックするだけでDebugViewをスタートできます。 chromewebstore.google.com

アプリのイベントを開発デバイスで確認する場合

firebase.google.com

4. Chromeデベロッパーツール

Chromeでサイトを開きデベロッパーツール→[Network]を開きます。検索ボックスに「collect」と入れ検索し該当の測定IDの[ペイロード]を開くと送信しているデータを確認できます。

5. GA4でクリックイベントを日別に集計

GA4にログインして[探索]からレポートを作成します。 行に日付を配置し列にイベント名、値にイベント数を入れます。他にも必要な情報があれば追加します。

まとめ

GA4では自動収集のイベント以外は過去に遡って取れないのでレポート等に必要なイベントが取得できているかどうか事前の確認が重要です。以上、GA4のイベントの確認や閲覧方法を紹介しました。

iOS / Android ネイティブアプリから Flutter に移行するときに Pigeon を使ってデータマイグレーションする

エキサイト株式会社の@mthiroshiです。 エキサイトホールディングス Advent Calendar 2023の8日目を担当します。

エキサイトでは、 iOS / Android ネイティブアプリの Flutter によるリビルドを行ってきました。 ローカル DB でデータを管理するアプリの場合、ネイティブ版のデータを Flutter 版にマイグレーションする必要があります。

今回は、Flutter アプリのデータマイグレーションの事例を紹介します。

ネイティブアプリの前提

今回のアプリ移行では、移行前のネイティブ版と移行後の Flutter 版でアプリケーション ID が同一であることを前提としています。 それはつまり、アプリストア上では同一のアプリと見なされ、アプリのアップデートでネイティブから Flutter に移行が起きる状態となります。

各ネイティブアプリのローカル DB には、下記のライブラリを採用していました。

各プラットフォームで使われているライブラリは異なるため、それぞれでデータ取得や移行の処理が必要になります。

今回は、既存のネイティブ実装を流用できれば開発コストが低いと考え、Flutter からiOS / Android のネイティブ実装を呼び出すアプローチを検討しました。

Pigeon とは

Pigeon は、Flutterとネイティブコードとの型安全な通信を実現するためのパッケージです。

pub.dev

その他のネイティブコードとの通信を行う手法として、MethodChannel があります。

api.flutter.dev

MethodChannel では、ネイティブメソッドの呼び出しを文字列で行ったり、返り値が String になってしまったりと、使いにくい部分があります。 Pigeon を使うことで、インターフェースや型を定義できるので、Flutter とネイティブの通信が安全に実装しやすくなります。

Pigeon を使ったデータマイグレーション

今回は下記のバージョンで実装を行いました。

pigeon: ^11.0.1

大まかな流れとしては、下記の通りです。

  • ネイティブ - Flutter 間で使うインターフェースの定義
  • ネイティブでインターフェースの実装を定義
  • Flutterからインターフェースを呼び出す

Pigeon インターフェースの定義

まず、Flutterとネイティブコードのインターフェースやデータクラスを定義します。

下記がサンプルコードです。

@ConfigurePigeon(
  PigeonOptions(
    dartOut: 'lib/pigeon/pigeon.g.dart',
    kotlinOut: 'android/app/src/main/java/com/example/demo/pigeon/Pigeon.g.kt',
    kotlinOptions: KotlinOptions(
      package: 'com.example.demo.pigeon',
    ),
    objcHeaderOut: 'ios/Runner/Pigeon/Pigeon.h',
    objcSourceOut: 'ios/Runner/Pigeon/Pigeon.m',
    swiftOut: 'ios/Runner/Pigeon/Pigeon.g.swift',
    swiftOptions: SwiftOptions(),
  ),
)
@HostApi()
abstract class DemoAppHostApi {
  String loadUserId();

  List<Book> loadBookHistory();
}

class Book {
  Book(
    this.title,
    this.author,
  );

  String title;
  String author;
}

DemoAppHostApi を abstract class で定義し、ネイティブのローカル DB からデータを取得するためのメソッドを用意します。 @HostApi アノテーションを付与することで、ネイティブ用のコードを生成します。 コード生成するときのファイル名を指定するため、 @ConfigurePigeon アノテーションPigeonOptions でそれぞれファイルパスを指定します。 必要であれば、データクラスも定義します。

Pigeon の設定ファイルを用意したら、下記のコマンドを実行します。

flutter pub run pigeon --input pigeon.dart

--input pigeon.dart は、 上記の設定ファイルを入力値として与えています。 このコマンドによって、PigeonOptions で設定した内容を基に Flutter, Swift, Kotlin それぞれのプラットフォームに合わせたインターフェース、データクラスが生成されます。 ネイティブコードでは、このインターフェースを使ってその実装を定義し、Flutter 側でそのインターフェースを呼び出します。

iOS のインターフェース実装

生成された Pigeon のインターフェースを使って iOS の ローカル DB のデータ取得を実装します。

import Foundation
import RealmSwift

extension FlutterError: Error {}

class DemoAppHostApiImpl: NSObject, DemoAppHostApi {
    
    private let realm: Realm

    init(realm: Realm) {
        self.realm = realm
    }

    func loadUserId() throws -> String {
        let user = self.realm.objects(User.self)
        
        return user.userId
    }
    
    func loadBookHistory() throws -> [Book] {
        let history = self.realm.objects(BookHistory.self)
        var migrateBookHistories: [Book] = []
        for book in history {
            migrateBookHistories.append(
                Book(
                    title: book.title,
                    author: book.author,
                    imageUrl: book.imageUrl,
                    readDate: Int64(book.viewedAt.timeIntervalSince1970 * 1000)
                )
            )
        }

        return migrateBookHistories
    }
}

インターフェースで定義したメソッドの実装を記述しています。 事例のアプリでは、Realm を使っていたので Realm オブジェクトからローカル DB に参照しています。 サンプルコードには含まれていませんが、Realm のモデル定義が必要であり、ネイティブ版アプリと同じ内容を Flutter 版のネイティブコードに移植してきます。

続いて、AppDelegate.swift に初期化の処理を追加します。

import UIKit
import Flutter
import RealmSwift
import os
import Foundation

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)
        setupHostApi()
        
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
    
    private func setupHostApi() -> Void {
            let config = Realm.Configuration(
                schemaVersion: 2
            )
            Realm.Configuration.defaultConfiguration = config
            
            let realm = try! Realm()
            let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
            DemoAppHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: DemoAppHostApiImpl(realm: realm))
    }
}

AppDelegate で Realm の初期化をしています。 事例のケースでは、ネイティブ版と Flutter ネイティブコードで Realm のバージョンに差異があったので、スキーママイグレーションを行っています。

Pigeon で生成されたコードにDemoAppHostApiSetup.setUp() が用意されているので、それを呼び出してHostApiの初期化を行います。

Android のインターフェース実装

ネイティブの Android では、greenDAO を使っていましたが、Flutter 版ネイティブコードでは、Room を使ってローカル DB に参照します。 greenDAO も Room も SQLite ラッパーとなっているため、sqlite ファイルが同じであれば同一の DB を参照できます。

Pigeon のインターフェースを実装したクラスを作り、DBからデータを取得する処理を実装します。

class DemoAppHostApiImpl(
    private val activity: Activity,
    private val database: AppDatabase,
) : DemoAppHostApi {

    override fun loadUserId(): String {
        return database.userDao().getUser().userId;
    }

    override fun loadBookHistory(): List<Book> {
        return database.bookHistoryDao().getBook().map { book ->
            Book(
                title = book.title,
                author = book.author,
                imageUrl = book.imageUrl,
                readDate = book.readDate,
            )
        }
    }
}

iOS 同様にインターフェースで用意されたメソッドの実装を記述しています。 こちらも Room のスキーマ定義等が別途必要になります。

続いて、MainActivity でマイグレーションの初期化を行います。

import androidx.room.Room
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import com.example.app.pigeon.DemoAppHostApi
import com.example.app.pigeon.DemoAppHostApi

class MainActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        setupMigration(flutterEngine)
    }

    override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
        super.cleanUpFlutterEngine(flutterEngine)
    }

    private fun setupMigration(flutterEngine: FlutterEngine) {
        val database = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java,
            'demo_db',
        )
            .allowMainThreadQueries()
            .build()
        DemoAppHostApi.setUp(
            flutterEngine.dartExecutor.binaryMessenger,
            DemoAppHostApiImpl(this, database)
        )
    }
}

AndroidDemoAppHostApi.setUp() が Pigeon よって生成されているので、呼び出して初期化します。

Flutter から Pigeon のインターフェースを呼び出す

ネイティブコードの実装を終えたら、Flutter から Pigeon のインターフェースを利用して、ローカルDBからデータを取得します。

class Migration {
  Migration();

  final api = DemoAppHostApi();

  final userRepository = UserRepository();

  final bookRepository = BookRepository();

  Future<void> migrate() async {
    try {
      final userId = await api.loadUserId();

      await repository.saveUserId(userId);

      final bookHistory = await api.loadBookHistory();

      await repository.saveBookHistory(bookHistory);
    } on Exception catch (e) {
       // エラー処理
    }
  }
}

Flutter からは、iOS / Androidを意識せずにインターフェースを呼び出せます。 データ取得後、Flutter の保存処理にデータを渡せばマイグレーション完了です。

まとめ

今回は iOS / Android ネイティブアプリで作成したローカル DB のデータを Flutter 版アプリに移行する際に、Pigeon を使う事例を紹介しました。 MethodChannel に比べて型安全に実装ができますので、極力は Pigeon を使うことが望ましいと思います。 参考になれば幸いです。

最後に

エキサイトではフロントエンジニア、バックエンドエンジニア、アプリエンジニアを随時募集しております。長期インターンも歓迎していますので、興味があれば連絡いただければと思います。

募集職種一覧はこちらになります!(カジュアルからもOK) www.wantedly.com

参考記事

pigeon | Dart Package

MethodChannel class - services library - Dart API

Realm Home | Realm.io

GitHub - greenrobot/greenDAO: greenDAO is a light & fast ORM solution for Android that maps objects to SQLite databases.

Room を使用してローカル データベースにデータを保存する  |  デベロッパー向け Android  |  Android Developers

【Flutter】MethodChannelは直接使うことなかれ。型安全で楽に実装できる「Pigeon」の使い方のほぼ全て。 - chikach.net

【Flutter】型安全に MethodChannel でネイティブとデータ通信を行う

Javaで正規表現で指定したパターンに、対象の文字列がマッチするかどうかを判定する方法

はじめに

こんにちは、新卒1年目の岡崎です。エキサイトホールディングス Advent Calendar 2023の7日目を担当します。
今回はJavaにおいて、正規表現で指定したパターンに、対象の文字列がマッチするかどうかを判定する方法を紹介します。

Stringクラスのmatchesメソッド

Stringクラスには、matchesメソッドが用意されています。これを使って、対象の文字列が指定した正規表現のパターンとマッチするかどうか判定することができます。

以下の実装は、郵便番号の形式になっているかどうか判定するサンプルコードです。

public class Main {
    public static void main(String[] args) throws Exception {
    String string = "111-1111";
        String pattern = "^[0-9]{3}-[0-9]{4}$";
        
        System.out.println(string.matches(pattern));
    }
}

結果

true

簡単な正規表現のパターンを指定し、そのパターンに対象の文字列がマッチするかどうかを判定することのみを行うなら、この方法で十分だと思います。
しかし、Stringクラスで用意されているmatchesメソッドでは、部分一致をすることはできません。

以下は、<div>が含むかどうか判定を行うサンプルコードです。

public class Main {
    public static void main(String[] args) throws Exception {
        String string = "<div>aaa</div>";
        String pattern = "<div>";
        
        System.out.println(string.matches(pattern));
    }
}

結果

false

Patternクラス・Matcherクラス

PatternクラスとMatcherクラスを使う方法もあります。

先ほどと同様に、郵便番号の形式になっているかどうか判定するサンプルコードを以下に示します。

public class Main {
    public static void main(String[] args) throws Exception {
        String string = "111-1111";
        String regex = "^[0-9]{3}-[0-9]{4}$";
        
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(string);
        
        System.out.println(matcher.matches());
    }
}

結果

true

Patternクラスでパターンを読み込ませて、Matcherクラスでマッチするかどうか判定する方法です。

次のように繋げて記述することもできます。

public class Main {
    public static void main(String[] args) throws Exception {
        String string = "111-1111";
        String regex = "^[0-9]{3}-[0-9]{4}$";
        
        Boolean isMatch = Pattern.compile(regex).matcher(string).matches();
        
        System.out.println(isMatch);
    }
}

Matcherクラスに用意されているメソッドの一つに、find()というメソッドがあります。このメソッドでは、部分一致の判定を行うことができます。

以下は、<div>が含むかどうか判定を行うサンプルコードです。

public class Main {
    public static void main(String[] args) throws Exception {
        String string = "<div>aaa</div>";
        String pattern = "<div>";
        
        Boolean isMatch = Pattern.compile(pattern).matcher(string).find();
        
        System.out.println(isMatch);
    }
}

結果

true

Matcherクラスでは、上記のような部分一致の判定の他にも、マッチした部分の文字列を抜き出したり、置換を行ったりすることもできます。詳しくは公式ドキュメントをご覧ください。

最後に

Java正規表現で指定したパターンに、対象の文字列が一致するかどうかを判定する方法を2つ紹介しました。ここまで読んでいただきありがとうございました。

【SaaS Design Conference 2023】イベントに行ってきました!

こんにちは!SaaS・DX事業部デザイナーの鍜治本です!
エキサイトホールディングス Advent Calendar 2023 シリーズ2の7日目の記事です!
qiita.com

去年登壇していたイベント「SaaS Design Conference」に、今回は公聴者として参加してきました!
時間の都合で全てのセッションは見られなかったこともあり、昨年との規模感の違いや、今年初企画のスポンサーブースなどに焦点を当てて共有します!

去年登壇した記事はこちら tech.excite.co.jp

会場全体の雰囲気

2023年11月23日(木)「勤労感謝の日」に開かれた本カンファレンスは、昨年同様 株式会社ユーザベースさんのオフィスにて開催されました。
↓サイトからわかるおしゃれ具合↓
オフィス紹介 | 採用情報 | 株式会社ユーザベース コーポレートサイト - Uzabase

さらに、ユーザベースのデザイン組織についてのブースや展示も…!

また、昨年と比べセッションが盛りだくさんに。タイムテーブルが2本あることで、A会場B会場と分かれて実施していました。 saasdesign-conf.uzabase.com 昨年登壇した際は1会場のみで、ギャラリーもほぼいれない状態でした。それでもかなり緊張したので、今年の登壇者の皆様、オーディエンスがいっぱいいる中でやり遂げているのがすごいです…!

さらにセッションのみならず、ノベルティへのこだわりやフォトスポット設置などなど…デザイナーの心をくすぐられるアウトプットにワクワクが止まりません。

シンボルマーク入りのトート・名札・お水
ノベルティのお水から熱量が伝わります
さらに、フォトスポットへの書き込みOK。会場で感じ取った気持ちをしたためさせていただきました。(チェキの文字が見えづらいので、Xから参照させていただきました)
一人で写る鍜治本

スポンサーブースとステキノベルティ紹介

昨年との違いで、今年のSaaS Design Conferenceはスポンサーブースが設営されていました。出展していた5社それぞれが自社プロダクトの紹介・デザイン組織に関する情報を紹介、サービスなども披露していました!
今回は、ブースを巡りいただいたノベルティについてピックアップしてご紹介します。

リクルートさん(プロダクトデザイン室)

カバン・Airパンフレット・プロデザパンフレット

フリーカットのパンフがかわいい…

サイボウズさん

パンフレット・フィギュア・ふせんセット
お名前を「キントン」と呼ぶそうです(かわいい)

他にもマネーフォワードさん、baigieさん、株式会社スタメンさんもステキなブースを展開していました!
スポンサーブースの中にはエンジニアの方もいらっしゃったりと、デザイナーだけではなく企業の活気ある雰囲気もステキでした。

カンファレンスに参加してみて

昨年登壇した立場とは打って変わって、今回は公聴者として参加しました。
セッション中に「あるある」と頷いてしまったり、休憩時間に他社間との交流をでき、「SaaSに関わるデザイナー」同士で共感・繋がりを感じられる1日でした。
また、大手だから強い・組織だから正解というわけではなく、どこも「デザインをより知ってもらいたい」「デザインでより良くなるよう貢献したい」思いで日々悩みと闘っているとわかりました。どの規模の企業であっても「デザインでアクションを起こしたい」思いは変わらず、デザイナーのカタチについて考えさせられました。

エキサイトはデザインの組織化はされていないものの、各事業・サービスに紐づく形で貢献しています。事業部ごとに求められるスキルは違えど、『得意なことでつながり合うチームワークで』デザインの力を発揮しています。
現在、新卒・中途問わずメンバーを募集しているので、興味を持った方はぜひお話ししましょう〜!

www.wantedly.com

Spring Bootで、独自アノテーションを目印にAOPを行う方法

こんにちは。 エキサイト株式会社の三浦です。

こちらは、エキサイトホールディングス Advent Calendar 2023の6日目の記事になります。

qiita.com

良ければ他の記事もどうぞ!

さて、コーディングをしている時、「このメソッドが実行される際はログを取りたい」など、メソッド実行時に追加で何かをさせたいという要件はたまに存在します。

その際には「AOP」という方法が役に立つのですが、今回はSpring Bootにおいて、独自のアノテーションを目印にしてAOPを行う方法を紹介します。

AOPとは

AOPは、「Aspect Oriented Programming(アスペクト指向プログラミング)」のことです。

上記で上げた「メソッド実行時にログを取る」などの共通化可能な処理を抜き出して、通常の処理と同等に書かなくても、メソッド名やアノテーションなどを目印に裏側で実行してくれるようにするというものです。

通常

通常は、以下のようにメソッド内に毎回ログ用の処理を書く必要があります。

public void method1(String arg1, String arg2) {
    // ログ用処理
    this.logMethod(arg1, arg2);

    this.mothod1Process(arg1, arg2);
}

public void method2(String arg1, String arg2) {
    // ログ用処理
    this.logMethod(arg1, arg2);

    this.mothod2Process(arg1, arg2);
}

AOP

AOPを使えば、特定のアノテーション等を目印にして、裏側でログを取ることが可能です。

// このアノテーションを目印にログを取る
@LogMethod
public void method1(String arg1, String arg2) {
    this.mothod1Process(arg1, arg2);
}

// このアノテーションを目印にログを取る
@LogMethod
public void method2(String arg1, String arg2) {
    this.mothod2Process(arg1, arg2);
}

今回の例のように、使用箇所が少なかったり同一クラス内でしか使わない場合は、そこまでメリットが感じられないかもしれません。

しかし、使用箇所が増えたり、クラスを横断してその処理が必要になっていくにつれ、AOPによる共通化の恩恵は大きくなっていきます。

では、Spring Bootでは具体的にどのように実装すれば良いのでしょうか。

Spring BootでAOPを使う

今回は簡単に、以下の要件を考えます。

  • メソッド実行前に、メソッド名やクラス名、その引数をログに取る
  • 独自のアノテーションが付いたメソッドでのみログを取る

build.gradle

まずは build.gradle に以下の設定を行います。

なお、Spring Bootはすでに読み込んでいる前提です。

implementation 'org.springframework.boot:spring-boot-starter-aop'

独自アノテーション

次に、目印とする独自アノテーションを作成します。

@Target(ElementType.METHOD)  // メソッドのみに付与可能
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogMethod {
}

AOPの処理本体

続いて、AOPの処理本体を設定します。

@Aspect
@Component
public class LogMethodAspect {
    @Before("@annotation(LogMethod)") // @LogMethodが付いているメソッドが実行された時、処理が実行される直前に以下が行われる
    public void beforeLogMethod(JoinPoint joinPoint) {
        // クラス名とメソッド名を取得
        final String path = "{className}.{methodName}"
                .replace("{className}", joinPoint.getSignature().getDeclaringTypeName())
                .replace("{methodName}", joinPoint.getSignature().getName());

        // 引数を取得
        final String body = Arrays.toString(joinPoint.getArgs());

        // 取得した情報を、何かしらに出力する
        this.log(path, body);
    }
}

AOPを適用

ここまで来れば、後は使うだけです。

@Component
public class SampleClass {
    @LogMethod
    public void method1(String arg1, String arg2) {
        this.mothod1Process(arg1, arg2);
    }

    @LogMethod
    public void method2(String arg1, String arg2) {
        this.mothod2Process(arg1, arg2);
    }
}

これで無事、ログを取ることができます!

呼び出し例

// DIコンテナから呼び出し
sampleClass.method1("hello", "world");

ログ

今回は「ログを取る」ことを目的としましたが、もちろんそれ以外の処理を行わせることもできます。

また、

  • 特定のパッケージのメソッドを対象にする
  • 特定のメソッド名のメソッドを対象にする

であったり、

  • メソッド実行後に処理を行う
  • メソッド実行の前後に処理を行う

など、色々なやり方があります。

spring.pleiades.io

ぜひいろいろ試してみてください!

最後に

コードが複雑になればなるほど、AOPはその真価を発揮していきます。

一度理解してしまえば使いたくなること請け合いなので、使えそうな場面があったらぜひやってみてください!

JavaのSpringBootでMyBatisを利用して複数のデータソースに接続する方法

こんにちは、エキサイト株式会社の平石です。エキサイトホールディングス Advent Calendar 2023の5日目を担当いたします。

今回はJavaのSpringBootでMyBatisを利用して、複数のデータソースに接続する方法をご紹介します。

はじめに

複数のデータベースに利用したい情報が分散しており、一つの「アプリケーション」からそれらにアクセスする必要があるという場合を考えます。

このとき、考えられる対処法は主に2つあります。

  • それぞれのデータソースからデータを取得するためのAPIを別プロジェクトとして実装する
  • 複数のデータソースに一つのアプリケーションから直接アクセスできるような設定をする

1つ目の方法では、一つの「アプリケーション」から直接アクセスするデータソースを最大1つにすることができるので、複雑さの観点からは望ましいと言えます。 しかし、データベースに複数回アクセスする必要があるような状況では、呼び出しのオーバーヘッドが大きくなってしまいます。 また、単純にAPIを実装する手間もかかります。

そのような場合には、2つ目の方法も視野に入れることが考えられます。

本ブログでは、この2つ目の方法をご紹介いたします。

前提条件

本ブログでは以下の項目を前提条件としています。

  • Java、SpringBoot、MyBatisについてはある程度知っている
  • アプリケーションコードを記述する環境が整っている
  • 単一のデータソースにアクセスしてデータを取得することが可能なコードは実装している

動作確認環境

  • Java 17
  • SpringBoot v3.1.5
  • MyBatis ・・・ org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.2

問題設定

本ブログの例では、以下のような設定を用います。

データソース1 データソース2
DBの種類 MySQL 8.2 MySQL 5.7
ポート 3307 3308
接続先スキーマ schema1 schema2

今回はどちらもMySQLですが、当然ながらPostgreSQLなどの他のデータベースでも問題ありません。

application.ymlファイルへの記述

まずは、各データソースへの接続情報をapplication.ymlファイルに記述していきます。

spring:
  datasource:
    schema1:
      connection-properties:
        jdbc-url: jdbc:mysql://localhost:3307/schema1
        username: sample1-user
        password: sample1-password
      hikari-data-source-properties:
        max-lifetime: 600000
        maximum-pool-size: 5
    schema2:
      connection-properties:
        jdbc-url: jdbc:mysql://localhost:3308/schema2
        username: sample2-user
        password: sample2-password
      hikari-data-source-properties:
        max-lifetime: 600000
        maximum-pool-size: 5

もちろん、コード内に設定情報を記述しても動きます。 しかし、これらのパラメータはプロパティファイルにまとめておいた方が良いでしょう。

hikari-data-source-properties内のmax-lifetimemaximum-pool-sizeは今回の要件では必須ではないですが、カスタマイズできるようにしておくと後々便利です。

なお、キーはこの通りでなくても問題ありませんので、それぞれのデータソースに合わせて適切なキー名にしてください。 また、usernamepasswordも適宜変更してください。

接続設定項目をYAMLファイルから読み込む

次に、前の節で記述したYAMLファイルの内容を読み込むためのコードを記述します。

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "spring.datasource")
public record CustomDataSourceProperties(Properties schema1, Properties schema2) {
    public record Properties(
            ConnectionProperties connectionProperties, HikariDataSourceProperties hikariDataSourceProperties
    ) {
        public record ConnectionProperties(String jdbcUrl, String username, String password) {}

        public record HikariDataSourceProperties(
                Integer maxLifetime,
                Integer minimumIdle,
                Integer maximumPoolSize
        ) {
        }
    }
}

イメージとしては、@ConfigurationPropertiesprefixで指定したspring.datasourceの配下に、connectionPropertieshikariDataSourcePropertiesを配下にもつPropertiesレコードであるschema1schema2があるという設定になっています。

YAMLファイルのキーと見比べてみると、なんとなく記述していることは見えてくるのではないでしょうか。

実際に接続設定を行う

それでは、実際に接続のための設定を記述していきましょう。

といっても、SpringBootでMyBatisをデータベースに接続する際に、接続設定をカスタマイズするのとほとんど変わりません。

import javax.sql.DataSource;
import java.util.Objects;

import com.example.sample_project.project1.property.CustomDataSourceProperties;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.scripting.defaults.RawLanguageDriver;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
@MapperScan(
        basePackages = "com.example.sample_project.persistence.schema1",
        sqlSessionFactoryRef = "schema1SqlSessionFactory"
)
@RequiredArgsConstructor
public class Schema1DataSourceConfig {
    private static final String SCHEMA1_DATA_SOURCE_NAME = "schema1DataSource";
    private static final String SCHEMA1_DATA_SOURCE_PROPERTIES_NAME = "schema1DataSourceProperties";
    private static final String SCHEMA1_SQL_SESSION_FACTORY_NAME = "schema1SqlSessionFactory";

    private static final String DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver";

    private final CustomDataSourceProperties customDataSourceProperties;

    /**
     * schema1スキーマ用のDataSourcePropertiesを取得
     *
     * @return schema1スキーマ用のDataSourceProperties
     */
    public DataSourceProperties getSchema1DataSourceProperties() {
        return mapToDataSourceProperties(
                customDataSourceProperties.schema1().connectionProperties().username(),
                customDataSourceProperties.schema1().connectionProperties().password(),
                customDataSourceProperties.schema1().connectionProperties().jdbcUrl()
        );
    }

    /**
     * DataSourcePropertiesを取得  ①
     *
     * @param url DatabaseのエンドポイントURL
     * @return DataSourceProperties
     */
    private DataSourceProperties mapToDataSourceProperties(String username, String password, String url) {
        DataSourceProperties dataSourceProperties = new DataSourceProperties();
        dataSourceProperties.setDriverClassName(DRIVER_CLASS_NAME);
        dataSourceProperties.setUsername(username);
        dataSourceProperties.setPassword(password);
        dataSourceProperties.setUrl(url);

        return dataSourceProperties;
    }

    /**
     * schema1スキーマ用のDataSourceのプロパティを作成
     *
     * @return DataSourceのプロパティ
     */
    @Bean(name = SCHEMA1_DATA_SOURCE_PROPERTIES_NAME)
    @Primary
    public DataSourceProperties schema1DataSourceProperties() {
        return getSchema1DataSourceProperties();
    }

    /**
     * schema1スキーマ用のDataSourceを作成
     *
     * @return DataSource
     */
    @Bean(name = SCHEMA1_DATA_SOURCE_NAME)
    @Primary
    public DataSource schema1DataSource(@Qualifier(SCHEMA1_DATA_SOURCE_PROPERTIES_NAME) DataSourceProperties dataSourceProperties) {
        final HikariDataSource hikariDataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        return this.setPropertiesToHikariDataSource(hikariDataSource, customDataSourceProperties.schema1().hikariDataSourceProperties());
    }

    /**
     * HikariDataSourceにプロパティをセットする  ②
     *
     * @param hikariDataSource セット先HikariDataSource
     * @param properties セットしたいプロパティ
     * @return プロパティをセットしたHikariDataSource
     */
    private HikariDataSource setPropertiesToHikariDataSource(
            HikariDataSource hikariDataSource,
            CustomDataSourceProperties.Properties.HikariDataSourceProperties properties
    ) {
        hikariDataSource.setMaxLifetime(properties.maxLifetime());
        hikariDataSource.setMaximumPoolSize(properties.maximumPoolSize());

        if (Objects.nonNull(properties.minimumIdle())) {
            hikariDataSource.setMinimumIdle(properties.minimumIdle());
        }

        return hikariDataSource;
    }

    /**
     * schema1スキーマ用のSQLセッションファクトリを作成
     *
     * @return SQLセッションファクトリ
     * @throws Exception 例外
     */
    @Bean(name = SCHEMA1_SQL_SESSION_FACTORY_NAME)
    @Primary
    public SqlSessionFactory schema1SqlSessionFactory(@Qualifier(SCHEMA1_DATA_SOURCE_NAME) DataSource dataSource) throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);

        final SqlSessionFactory sqlSessionFactory = sqlSessionFactoryBean.getObject();
        if (Objects.isNull(sqlSessionFactory)) {
            throw new NullPointerException();
        }

        // この辺りの設定は適宜変更してください。
        sqlSessionFactory.getConfiguration().setCacheEnabled(false);
        sqlSessionFactory.getConfiguration().setMapUnderscoreToCamelCase(true);
        sqlSessionFactory.getConfiguration().setDefaultScriptingLanguage(RawLanguageDriver.class);

        return sqlSessionFactory;
    }
}

①で、データベースの接続設定をDataSourcePropertiesにセットしています。
また、②で接続の最大ライフタイムや、最大プールサイズのような設定をHikariDataSourceへセットしています。

この辺りは、接続先が1つの場合にも設定する項目です。

大きな違いはこの設定を接続先のデータベースの数だけ記述する必要があることです。 先ほどの設定は「データベース1」の「schema1」用の接続設定です。
よって、今度は「データベース2」の「schema2」用の接続設定を記述していきましょう。

import javax.sql.DataSource;
import java.util.Objects;

import com.example.sample_project.project1.property.CustomDataSourceProperties;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.scripting.defaults.RawLanguageDriver;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
@MapperScan(
        basePackages = "com.example.sample_project.persistence.schema2",
        sqlSessionFactoryRef = "schema2SqlSessionFactory"
)
@RequiredArgsConstructor
public class Schema2DataSourceConfig {
    private static final String SCHEMA2_DATA_SOURCE_NAME = "schema2DataSource";
    private static final String SCHEMA2_DATA_SOURCE_PROPERTIES_NAME = "schema2DataSourceProperties";
    private static final String SCHEMA2_SQL_SESSION_FACTORY_NAME = "schema2SqlSessionFactory";

    private static final String DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver";

    private final CustomDataSourceProperties customDataSourceProperties;

    /**
     * schema2スキーマ用のDataSourcePropertiesを取得
     *
     * @return schema2スキーマ用のDataSourceProperties
     */
    public DataSourceProperties getSchema2DataSourceProperties() {
        return mapToDataSourceProperties(
                customDataSourceProperties.schema2().connectionProperties().username(),
                customDataSourceProperties.schema2().connectionProperties().password(),
                customDataSourceProperties.schema2().connectionProperties().jdbcUrl()
        );
    }

    /**
     * DataSourcePropertiesを取得
     *
     * @param url DatabaseのエンドポイントURL
     * @return DataSourceProperties
     */
    private DataSourceProperties mapToDataSourceProperties(String username, String password, String url) {
        DataSourceProperties dataSourceProperties = new DataSourceProperties();
        dataSourceProperties.setDriverClassName(DRIVER_CLASS_NAME);
        dataSourceProperties.setUsername(username);
        dataSourceProperties.setPassword(password);
        dataSourceProperties.setUrl(url);

        return dataSourceProperties;
    }

    /**
     * schema2スキーマ用のDataSourceのプロパティを作成
     *
     * @return DataSourceのプロパティ
     */
    @Bean(name = SCHEMA2_DATA_SOURCE_PROPERTIES_NAME)
    @Primary
    public DataSourceProperties schema2DataSourceProperties() {
        return getSchema2DataSourceProperties();
    }

    /**
     * schema2スキーマ用のDataSourceを作成
     *
     * @return DataSource
     */
    @Bean(name = SCHEMA2_DATA_SOURCE_NAME)
    @Primary
    public DataSource schema2DataSource(@Qualifier(SCHEMA2_DATA_SOURCE_PROPERTIES_NAME) DataSourceProperties dataSourceProperties) {
        return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    /**
     * schema2スキーマ用のSQLセッションファクトリを作成
     *
     * @return SQLセッションファクトリ
     * @throws Exception 例外
     */
    @Bean(name = SCHEMA2_SQL_SESSION_FACTORY_NAME)
    @Primary
    public SqlSessionFactory schema2SqlSessionFactory(@Qualifier(SCHEMA2_DATA_SOURCE_NAME) DataSource dataSource) throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);

        final SqlSessionFactory sqlSessionFactory = sqlSessionFactoryBean.getObject();
        if (Objects.isNull(sqlSessionFactory)) {
            throw new NullPointerException();
        }

        sqlSessionFactory.getConfiguration().setCacheEnabled(false);
        sqlSessionFactory.getConfiguration().setMapUnderscoreToCamelCase(true);
        sqlSessionFactory.getConfiguration().setDefaultScriptingLanguage(RawLanguageDriver.class);

        return sqlSessionFactory;
    }
}

コードの構成自体は「データベース1」の「schema1」への設定と全く同じですが、

customDataSourceProperties.schema2().connectionProperties().username()

のように所々、「schema2」用の設定を読み込んでいます。

注意していただきたいのは、@Beanアノテーションを付けて各データソースについて登録したBeanは型が重複してしまうことです。
そこで、nameパラメータを指定し、DIする際にはQualifierを付けてBeanを指定しています。

このコードで注目してほしい点は、 各データソースへの接続設定クラスにつけている以下のアノテーションです。

@MapperScan(
        basePackages = "com.example.sample_project.persistence.schema1",
        sqlSessionFactoryRef = "schema1SqlSessionFactory"
)
@MapperScan(
        basePackages = "com.example.sample_project.persistence.schema2",
        sqlSessionFactoryRef = "schema2SqlSessionFactory"
)

これを、設定することで、com.example.sample_project.persistence.schema1パッケージに記述したクエリはschema1SqlSessionFactoryを使って、「データベース1」の「schema1」へ接続して実行され、

com.example.sample_project.persistence.schema2パッケージに記述したクエリはschema2SqlSessionFactoryを使って、「データベース2」の「schema2」へ接続して実行されるという設定を行うことができるのです。

つまり、それぞれのデータソースで実行されるクエリを記述するためのパッケージを分けることができ、まとまりが良くなります。

なお、指定するパッケージはご自身の使用したいものを設定してください。

これで、複数のデータソースに接続するための設定はおわりです。

2度目ですが、この設定の場合には、「データベース1」の「schema1」からデータを取得する場合には、com.example.sample_project.persistence.schema1パッケージに、「データベース2」の「schema2」からデータを取得する場合には、com.example.sample_project.persistence.schema2パッケージにクエリを記述したコード or ファイルを置くようにしてください。

おわりに

今回は、JavaのSpringBootでMyBatisを利用して複数のデータソースに接続する方法を紹介しました。

では、また次回。

参考文献

SpringBootとキャッシュライブラリCaffeineでローカルキャッシュサーバを作成する

エキサイト株式会社メディア事業部エンジニアの佐々木です。2024年アドベントカレンダー2日目を担当させていただきます。

サーバーローカルでのキャッシュはHashMap等で自作してもいいのですが、キャッシュ期限を自作するのは結構面倒なので、Caffeineを使用します。

github.com

前提

## Java

openjdk version "21.0.1" 2023-10-17 LTS
OpenJDK Runtime Environment Corretto-21.0.1.12.1 (build 21.0.1+12-LTS)
OpenJDK 64-Bit Server VM Corretto-21.0.1.12.1 (build 21.0.1+12-LTS, mixed mode, sharing)
## Gradle

------------------------------------------------------------
Gradle 8.4
------------------------------------------------------------

Build time:   2023-10-04 20:52:13 UTC
Revision:     e9251e572c9bd1d01e503a0dfdf43aedaeecdc3f
## SpringBoot

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.2.0)

設定

build.gradleはこのように設定する。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    implementation "com.github.ben-manes.caffeine:caffeine:3.1.8"  // キャッシュライブラリ Caffeineの読み込み
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

SpringBootの設定用のコードは下記のように実装する。

@Configuration
public class CacheConfig {

    /**
     * キャッシュの設定を行う.
     */
    @Bean
    public CaffeineCacheManager caffeineCacheManager(){
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        Arrays.stream(CacheType.values()).forEach(e -> {
            Cache<Object, Object> cache = Caffeine.newBuilder().expireAfterWrite(Duration.ofSeconds(e.getTtl())).build();
            cacheManager.registerCustomCache(e.getName(), cache);
        });
        return cacheManager;    
}

enum CacheType {  // キーごとにTTLを設定する用の enumを作成する

    MIN_1(CacheType.MIN_1_KEY, 60)  // 1分のキャッシュ設定
    , SECOND_30(CacheType.SECOND_30_KEY, 30); // 30秒のキャッシュ設定

    public static final String MIN_1_KEY = "MIN_1";
    public static final String SECOND_30_KEY = "SECOND_30";

    @Getter
    private final String name;
    @Getter
    private final long ttl;
    CacheType(String name, long ttl) {
        this.name = name;
        this.ttl = ttl;
    }
}

SpringBootのController/Serviceの実装は下記のようになる。

interface DemoService {

    Object get5SecondsExpirationData() throws InterruptedException;

    Object get10SecondsExpirationData() throws InterruptedException;
}

@RestController
@RequestMapping("")
@RequiredArgsConstructor
public class DemoController {

    private final DemoService demoService;

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

    @GetMapping("cacheExpiration5Seconds")
    public Object cacheExpiration5Seconds() throws InterruptedException {
        return LocalTime.now().format(formatter) + ":" + demoService.get5SecondsExpirationData();
    }

    @GetMapping("cacheExpiration10Seconds")
    public Object cacheExpiration10Seconds() throws InterruptedException {
        return LocalTime.now().format(formatter) + ":" + demoService.get10SecondsExpirationData();
    }
}

@Service
class DemoServiceImpl implements DemoService {

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

    @Override
    @Cacheable(CacheType.SECOND_10_KEY)  // 10秒キャッシュ指定
    public Object get10SecondsExpirationData() throws InterruptedException {
        Thread.sleep(10000);   // スリープ10秒
        return "expiration 10 seconds \n";
    }

    @Override
    @Cacheable(CacheType.SECOND_5_KEY)  // 5秒キャッシュ指定
    public Object get5SecondsExpirationData() throws InterruptedException {
        Thread.sleep(5000);  // スリープ5秒
        return "expiration 5 seconds \n";
    }
}

Service層のメソッドにアノテーションでそれぞれのキャッシュ時間を指定します。タイプセーフにキャッシュ指定できるようにしています。 検証用にService層内のメソッドが実行される場合は、それぞれ5秒、10秒待たされる実装になっています。

検証

5秒キャッシュの方のAPIを実行してみます。

5秒キャッシュ

キャッシュの有効期限が過ぎたタイミングで、5秒待たされるような挙動になっています。

まとめ

今回はキャッシュライブラリのCaffeineを使用して、サーバーローカルなキャッシュ機構を作成してみました。SpringBootではこのようなライブラリを使用しなければ、HashMapを使用したキャッシュが作られますが、有効期限などの設定は自前で実装することになるので、これだけの設定で作れるは便利かと思います。サーバーローカルなキャッシュを設定とリモートサーバーキャッシュの両方を使用する設定は次回ブログに書きます。

最後に

エキサイトではフロントエンジニア、バックエンドエンジニア、アプリエンジニアを随時募集しております。長期インターンも歓迎していますので、興味があれば連絡いただければと思います。

募集職種一覧はこちらになります!(カジュアルからもOK) www.wantedly.com

Javaで、文字列内に文字列変数を挿入する方法

こんにちは。 エキサイト株式会社の三浦です。

本記事は、エキサイトホールディングス Advent Calendar 2023の4日目のものになります。

qiita.com

良ければ他の記事もどうぞ!

さて、コーディングをしていると、文字列の中に変数で定義してある文字列を挿入したい場合があります。

今回は、Javaで変数挿入を行う方法を3つ紹介します。

1. + を使った連結

最も直感的なのは、 + を使った連結でしょう。

String variable = "world";

String text = "Hello " + variable + "!";

System.out.println(text);

結果

Hello world!

方法自体はシンプルで非常にわかりやすいですが、挿入したい変数が多くなればなるほど式が複雑になってしまいます。

2. String.format を使う方法

String.format を使うこともできます。

String variable = "world";

String text = String.format("Hello %s!", variable);

System.out.println(text);

結果

Hello world!

%s などを文字列の中に入れておき、第2引数以降に渡した変数の内容に置換する」というこの方法は他の言語でもよく見られる方法であり、見慣れている方も多いのではないでしょうか。

一方で、 複数の変数を挿入したい場合は、たくさんの %s 等が文字列の中に並ぶことになるため、どこにどの変数が置換されることになるのか分かりづらくなってしまうこともあります。

なおJava15以降であれば、文字列に直接 .formatted を使うことで同じことができます。

String variable = "world";

String text = "Hello %s!".formatted(variable);

System.out.println(text);

結果

Hello world!

docs.oracle.com

3. 独自の文字列で置換する方法

上記とは別に、独自の文字列で置換する方法もあります。

String variable = "world";

String text = "Hello {world}!".replace("{world}", variable);

System.out.println(text);

結果

Hello world!

{world} など独自のわかりやすい単語を文字列の中に入れておき、あとから replace を使って置換する方法です。

置換するための単語を適切に設定しておけば、どこにどの変数が置換されるかがわかりやすく、その意味では2番目の String.format を使った方法の改良版とも言えます。

ただし、もちろん置換用の単語を適当にしてしまっては逆に分かりにくくなってしまうでしょう。

また、万が一元の文章に置換用の単語と全く同じ単語が含まれていた場合、想定外の部分に変数が入ってしまうおそれもあります。

おまけ:文字列内で変数を展開することはできない

これまで3つ紹介してきましたが、もっとも理想は、以下のように文字列内で変数を展開することではないでしょうか。

String variable = "world";

String text = "Hello ${variable}!";

System.out.println(text);

ですが、残念ながら以下のように出力されます。

結果

Hello ${variable}!

Javaではこの方法は使えません。

ただし、まだプレビューではあるものの、Java21から「String Templates」として文字列内での変数展開の方法が示されています。

openjdk.org

将来この機能が正式採用されれば、非常に有力な選択肢となるでしょう。

最後に

Javaで文字列に変数を挿入する方法を3つ紹介してきました。

それぞれメリット・デメリットがあるので、適切に使い分けると良いでしょう。

また、例えばURLを組み立てるときに UriComponentsBuilder を使えば、文字列への変数挿入を行わなくてもクエリパラメータの組み立てなどを行うことが可能です。

文字列への変数挿入を行いたい場合は、そもそもまず文字列への変数挿入が必要なのかというところから考え始めても良いかもしれません。

GitHub Actionsでデプロイした時に、自動的にCSSファイルをS3にアップロードする

はじめに

こんにちは、新卒1年目の岡崎です。エキサイトホールディングス Advent Calendar 2023の4日目を担当します。

今回はGitHub Actionsを使って、Amazon ECSにアプリケーションをデプロイした時に、自動的にCSSファイルをS3にアップロードする実装を行いました。その備忘録として記事に残します。

環境

S3にCSSファイルをアップロードする

S3の機能の1つに、CSSファイルなどの静的なファイルの管理があります。S3について詳しく知りたい人は、公式ドキュメントをご覧下さい。

上記の通り、静的なファイルの管理はS3が行うので、CSSファイルに変更があった場合、S3にアップロードする必要があります。

よって今回は、任意のCSSファイルが存在するディレクトリを、アプリーションをデプロイした時に、S3に自動でアップロードする方法を紹介します。

実装

GitHub Actionsのワークフローは.github/workflows/配下のYAMLファイルに記載しています。そのファイルに、以下のような実装を行います。

- name: upload to Amazon s3 for CDN
  env:
      AWS_ACCESS_KEY_ID: アクセスキー
      AWS_SECRET_ACCESS_KEY: シークレットキー
  run: |
     aws s3 cp --recursive --region ap-northeast-1 アップロードしたいCSSファイルがあるディレクトリのpath s3: s3のpath

クライアントがCSSファイルにアクセスする方法

クライアントは、静的ファイルを取得するために、以下の手順でCSSファイルにアクセスしています。

  1. クライアントがCSSファイルにアクセスします。
  2. CloudFrontは、リクエストされたCSSファイルのキャッシュの有無を確認します。
    • キャッシュがあれば、それをクライアント側に返します。
    • キャッシュがなければ、次に進みます。
  3. CloudFrontはS3からCSSファイルを取得し、その時に取得したCSSファイルをクライアントに返します。

詳しくは公式ドキュメントに記載があります。

CloudFrontのキャッシュを削除する

CloudFrontにキャッシュがあると、新しいCSSファイルがS3にアップロードしても、クライアントは古いCSSファイルを取得してしまいます。

この問題を解決するために、CloudFrontのキャッシュを削除する必要があります。

実装

これも.github/workflows/配下のYAMLファイルに記載を行います。実装例は以下です。

- name: Clear cache in CloudFront
  env:
     AWS_ACCESS_KEY_ID: アクセスキー
     AWS_SECRET_ACCESS_KEY: シークレットキー
  run: |
     aws cloudfront create-invalidation --distribution-id ディストリビューションID --paths "キャッシュを消したいファイルが存在するディレクトリのpath"  

キャッシュの削除は、1ヶ月で1000pathまで無料で行うことができます。その後は有料になり、1pathごとに0.005USDかかります。

詳しくは公式ドキュメントに記載があるので、興味がある人は見てください。

まとめ

GitHub Actionsを使って、Amazon ECSにアプリケーションをデプロイした時に、CSSファイルをS3に自動でアップロードしたり、CloudFrontのキャッシュを削除したりする方法を紹介しました。

ここまで読んでいただきありがとうございました。

RiverpodのStreamProviderを使ってPushNotificationを実装してみた

はじめに

エキサイト株式会社の高野です。エキサイトホールディングス Advent Calendar 2023の3日目を担当させていただきます。

今回はFlutterにおけるRiverpodのStreamProviderを使ってPushNotificationを実装した話です。

動作環境

Flutter: 3.13.4

hooks_riverpod: 2.3.4

riverpod_annotation: 2.0.4

rxdart: 0.27.1

実装方法

まず始めに実際のコードを載せておきます。

@Riverpod(keepAlive: true)
Stream<PushNotificationData?> pushNotificationStream(
  PushNotificationStreamRef ref,
) async* {
  final event = await ref.watch(pushNotificationOnMessageProvider.future);
  yield ref.watch(pushNotificationProvider).onOpenMessage(event.data);
}

@Riverpod(keepAlive: true)
Stream<RemoteMessage> pushNotificationOnMessage(
  PushNotificationOnMessageRef ref,
) {
  return Rx.merge([
    Stream.fromFuture(FirebaseMessaging.instance.getInitialMessage())
        .whereNotNull(),
    FirebaseMessaging.onMessage,
    FirebaseMessaging.onMessageOpenedApp,
  ]);
}

まず一つ目のポイントとしているのは、Pushの監視状態は途中で切れてほしくないのでkeepAliveをtrueにしています。 次に以下の部分です。

  return Rx.merge([
    Stream.fromFuture(FirebaseMessaging.instance.getInitialMessage())
        .whereNotNull(),
    FirebaseMessaging.onMessage,
    FirebaseMessaging.onMessageOpenedApp,
  ]);

初回の通知確認、アプリを開いている時に通知を開いた場合、アプリを閉じている時に通知を開いた場合という3つを区別なく処理をします。

こちらの各Streamを同一StreamにするProviderを作成しました。

  final event = await ref.watch(pushNotificationOnMessageProvider.future);
  yield ref.watch(pushNotificationProvider).onOpenMessage(event.data);

そして ref.watch を使用して流れてくる値を監視し、整形してまたStreamとして流します。

ref.listen(pushNotificationStreamProvider, (_, asyncValue) {
      final data = asyncValue.valueOrNull;
      if (data == null) {
        return;
      }
      // ここに遷移処理等を書く
}

最後にこのProviderをlistenしてあげ、Push通知を開いた際の処理を書きます。

意外と行数も増えることなく書くことができました。

最後に

エキサイトではエンジニアを随時募集しています。 興味がありましたらお気軽にご連絡いただければ幸いです。

www.wantedly.com

MySQL5.7から8.0へAmazon RDS ブルー/グリーンデプロイを使って移行する

こんにちは。エキサイトでエンジニアをしている吉川です。

エキサイトホールディングス Advent Calendar 2日目の記事になります。

私が担当しているサービスで使用しているMySQL DBについて、先日5.7系から8.0系にバージョンアップを行いましたので、ご紹介させていただきます。

環境

・Amazon RDS Aurora 
・旧エンジンバージョン:5.7.mysql_aurora.2.11.2
・新エンジンバージョン:8.0.mysql_aurora.3.04.0
・クラスターの状態:1DBクラスターに、1リーダーインスタンス、1ライターインスタンスが紐付く

Amazon RDS ブルー/グリーンデプロイ手順

Amazon RDS ブルー/グリーンデプロイとは、RDSを移行する際のダウンタイムを1分かからない(=移行中に抜け漏れるデータをほぼなくせる)ようにできるサービスです。

公式リファレンス

以下のようなステップをマネジメントコンソールからポチポチで作業できます

  1. 新しいステージング環境を作成し、旧環境とステージング環境を同期
  2. ステージング環境のDBエンドポイントを旧環境のものに、旧環境のDBエンドポイントは別のものに各々変更する

早速やっていきます。

新しいステージング環境を作成し、旧環境とステージング環境を同期

  1. DBクラスターを選択→アクションから「ブルー/グリーンデプロイの作成」
  2. デプロイの識別子、新DBクラスターのバージョン、DBクラスターのパラメータ、DBインスタンスのパラメータを選択して開始
  3. 完了するとステージング環境ができ、旧環境の変更は自動で同期されるようになります

以下のような状態になればOKです。

なおステージング環境に旧環境のデータがコピーされるので、この切り替え前の時点ではそこそこ時間がかかります。 手元の環境で空のDBで試したところ1時間弱かかっていました。全部まとめて数分で終わる話かなと最初思っていましたが勘違いでした。。。(流石にそんなオイシイ話はないですね)

ハマった点

  • 現在使用しているDBクラスター用のパラメータグループについて、binlog_format=ROW(またはMIXED)にしておく必要があります
  • 現在使用しているパラメータグループが同期できていないとステージング環境を作成できません
    • もしbinlog_format=ROW(またはMIX)になっていない、またはパラメータグループを変更しても同期できていない場合は、DBインスタンスを再起動する必要があります(この再起動にはダウンタイムが発生します)
  • ステージング環境を作る時点で、そこで使用する新しいパラメータグループが必要になります。デフォルトでもOKですが、旧環境でパラメータをデフォルトから変更していたので、新規でaurora-mysql8.0用のパラメータグループを作成しました。
  • 旧環境のインスタンスクラスが新環境で使えない場合、ステージング環境を作る前にクラスを上げる必要があります
    • 5.7系ではdb.t3.smallを使っていましたが、8.0系ではdb.t3.mediumから使用可能なので、先に上げることになりました

ステージング環境のDBエンドポイントを旧環境のものに、旧環境のDBエンドポイントは別のものに各々変更する

  1. 上記の写真で「ロール = ブルー/グリーンデプロイ」となっている識別子を選択→アクションから「切り替え」
  2. 変更内容を確認したら実行
  3. 数分で完了する

以下のような状態になればOKです。

  • 旧DBインスタンス名、およびエンドポイント名には「old」が入ります
  • 新DBインスタンス名、およびエンドポイント名は旧環境と同一です
  • リージョン、AZ、クラスターの内どれがリーダーでどれがライターか、などもよしなに引き継いでくれます

移行タスク進行

私のチームでは「ライブラリ改善タイム」というのを設けていて、業務時間内にMySQLや開発言語のバージョンアップの調査・実施を行えるようにしています。頻繁にやるものではないので、必要になった際に週1回3、4時間間隔で実施する形をとっています。

今回の移行はその時間を使って行いました。またMySQL5.7系→8.0系はそこそこ破壊的な変更があり、プロダクトにどこまで影響があるかチェックするのもこの時間に行なっていました(Blue/Greenデプロイとは主旨がずれるので今回は割愛します)。

個人的に、このような機能開発とは別だけど必要なタスク、というのは専用の時間をとって一気にやった方が進むなと思いました。 チーム全体でライブラリ改善を行うので、コード書いてなくても後ろめたさがないとも感じました。

また「ライブラリ改善タイムで行ったことはドキュメントにまとめる」というルールがあるので、本番環境で実施する際や、このように社外に公開するときに役立つとも思います。

旧環境の削除

旧環境を残しておくのは料金的にはあまりよろしくないです。 インスタンスを停止させることはできますが、1週間で再起動されてしまいます。 継続して停止させたい場合は、EventBridgeなどを使って明示的に停止し続けさせることになります。

停止させ続ける必要も特にないので、今回は移行完了後1週間をメドにクラスター、インスタンスを削除するようにしました。 (念のため削除前に最終スナップショットはとっています)

さいごに

いかがだったでしょうか?これから5.7系->8.0系の移行作業を行う方、また今後のバージョンアップの際の助けとなれば幸いです。 Blue/Greenデプロイ自体は本当に簡単に済んだので、個人的には今後の破壊的変更はあまりないといいなと思っています(笑)

今年のアドベントカレンダーも見どころ満載でお届けしますので、明日以降の記事もぜひお楽しみください!

またエキサイトではデザイナー、エンジニアを絶賛募集しております! ご興味があればこちらからご連絡ください!

www.wantedly.com

SpringBootとInfinispanの組み込みモードでローカルなキャッシュ機構を作る

エキサイト株式会社エンジニアの佐々木です。エキサイトホールディングス 2023 advent calendar 1日目を担当させていただきます。

qiita.com

今回は、SpringBoot/Javaで、サーバローカルなキャッシュをRedHatが開発しているInfinispanの組み込みモードを使用し実装していきます。

前提

## Java

openjdk version "21.0.1" 2023-10-17 LTS
OpenJDK Runtime Environment Corretto-21.0.1.12.1 (build 21.0.1+12-LTS)
OpenJDK 64-Bit Server VM Corretto-21.0.1.12.1 (build 21.0.1+12-LTS, mixed mode, sharing)
## Gradle

------------------------------------------------------------
Gradle 8.4
------------------------------------------------------------

Build time:   2023-10-04 20:52:13 UTC
Revision:     e9251e572c9bd1d01e503a0dfdf43aedaeecdc3f
## SpringBoot

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.2.0)

設定

build.gradleはこのように設定する。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    implementation 'org.infinispan:infinispan-spring-boot3-starter-embedded:14.0.13.Final'  // SpringBoot3に対応したinfinispanの組み込みモードのライブラリ
    implementation 'org.infinispan:infinispan-component-annotations:10.1.8.Final'   // infinispan共通で使用するライブラリ
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

SpringBootの設定用のコードは下記のように実装する。

@Configuration
public class CacheConfig {

    /**
     * キャッシュの設定を行う.
    */
    @Bean
    public EmbeddedCacheManager embeddedCacheManager(EmbeddedCacheManager embeddedCacheManager) {
        Arrays.stream(CacheType.values()).forEach(e -> {
                    ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
                    org.infinispan.configuration.cache.Configuration configuration = configurationBuilder
                            .expiration()
                            .lifespan(e.getTtl(), TimeUnit.SECONDS) // キャッシュのTTLを設定する
                            .build();

                    embeddedCacheManager.defineConfiguration(e.getName(), configuration);  // 上記で設定した内容を CacheNameと共に、設定を行う
                }
        );
        return embeddedCacheManager;
    }
    
}

enum CacheType {  // キーごとにTTLを設定する用の enumを作成する

    MIN_1(CacheType.MIN_1_KEY, 60)  // 1分のキャッシュ設定
    , SECOND_30(CacheType.SECOND_30_KEY, 30); // 30秒のキャッシュ設定

    public static final String MIN_1_KEY = "MIN_1";
    public static final String SECOND_30_KEY = "SECOND_30";

    @Getter
    private final String name;
    @Getter
    private final long ttl;
    CacheType(String name, long ttl) {
        this.name = name;
        this.ttl = ttl;
    }
}

SpringBootのController/Serviceの実装は下記のようになる。

interface DemoService {

    Object get5SecondsExpirationData() throws InterruptedException;

    Object get10SecondsExpirationData() throws InterruptedException;
}

@RestController
@RequestMapping("")
@RequiredArgsConstructor
public class DemoController {

    private final DemoService demoService;

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

    @GetMapping("cacheExpiration5Seconds")
    public Object cacheExpiration5Seconds() throws InterruptedException {
        return LocalTime.now().format(formatter) + ":" + demoService.get5SecondsExpirationData();
    }

    @GetMapping("cacheExpiration10Seconds")
    public Object cacheExpiration10Seconds() throws InterruptedException {
        return LocalTime.now().format(formatter) + ":" + demoService.get10SecondsExpirationData();
    }
}

@Service
class DemoServiceImpl implements DemoService {

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

    @Override
    @Cacheable(CacheType.SECOND_10_KEY)  // 10秒キャッシュ指定
    public Object get10SecondsExpirationData() throws InterruptedException {
        Thread.sleep(10000);   // スリープ10秒
        return "expiration 10 seconds \n";
    }

    @Override
    @Cacheable(CacheType.SECOND_5_KEY)  // 5秒キャッシュ指定
    public Object get5SecondsExpirationData() throws InterruptedException {
        Thread.sleep(5000);  // スリープ5秒
        return "expiration 5 seconds \n";
    }
}

Service層のメソッドにアノテーションでそれぞれのキャッシュ時間を指定します。タイプセーフにキャッシュ指定できるようにしています。 検証用にService層内のメソッドが実行される場合は、それぞれ5秒、10秒待たされる実装になっています。

検証

5秒キャッシュの方のAPIを実行してみます。

5秒キャッシュ

キャッシュの有効期限が過ぎたタイミングで、5秒待たされるような挙動になっています。

まとめ

今回はInfinispanの組み込みモードを使用して、サーバーローカルなキャッシュ機構を作成してみました。SpringBootではこのようなライブラリを使用しなければ、HashMapを使用したキャッシュが作られますが、有効期限などの設定は自前で実装することになるので、これだけの設定で作れるは便利かと思います。サーバーローカルなキャッシュを設定とリモートサーバーキャッシュの両方を使用する設定は次回ブログに書きます。

最後に

エキサイトではフロントエンジニア、バックエンドエンジニア、アプリエンジニアを随時募集しております。長期インターンも歓迎していますので、興味があれば連絡いただければと思います。

募集職種一覧はこちらになります!(カジュアルからもOK) www.wantedly.com

BEAR.Saturdayで画面を作るために必要な三つのコンポーネントの概要

はじめに

こんにちは。新卒1年目の岡崎です。エキサイトホールディングス 2023 advent calendar 1日目を担当させていただきます。

qiita.com

BEAR.Saturdayでの開発で苦戦しました。
今回の備忘録では、BEAR.Saturdayで画面を作るために必要な三つのコンポーネントの概要をまとめます。

BEAR.Saturdayとは

BEAR.Saturdayとは、PHPのWebアプリケーションフレームワークです。さらに詳しく知りたい人は、公式ドキュメントをご覧ください。

注意事項

  • BEAR.SaturdayはサポートされているPHPのバージョンが7.4までなので、今後の新規利用は非推奨です。
  • BEAR.Saturdayは既にサポートが終了しています。BEAR.Saturdayの後続として、BEAR.Sundayがリリースされています。

BEAR.Saturdayの概要

クライアントがURLでアクセスし、画面に任意のデータが表示されるまでの流れは、以下のようになります。

  1. クライアント側がURLを使用して、ページにアクセスします。
  2. リソースがデータを取得し、ページにそのデータを返します。
  3. ページがビューにデータを渡し、ビューの内容が画面に表示されます。

BEAR.Saturdayでは、ページ・リソース・ビューの3つのコンポーネントが使われます。
これらの役割について、詳しく紹介していきます。

ページ

ページは、Page.phpが親クラスです。onInjectonInitonOutputの三つの要素で構成されています。

class Page_Sample_Index extends BEAR_PAGE
{
    public function onInject()
    {
        // 処理が記述される
    }

    public function onInit(array $args)
    {
        // 処理が記述される
    }
        
    public function onOutput()
    {
         $this->display();
     }
}

App_Main::run('Page_Sample_Index');

実行順番は、onInjectonInitonOutputの順で実行されていきます。

onInject

依存関係の注入や、クエリパラメーターの受け取りを行います。

onInit

リソースからデータを取得したり、取得したデータを加工したりします。

onOutput

ビューにデータを渡します。

リソース

リソースは、Ro.phpが親クラスです。外部のAPIやDBと繋いで、データの取得・作成・削除・更新を行います。
リソースは二つの要素で構成しますが、取得・作成・削除・更新でそれぞれ要素が異なります。

取得の場合

取得の場合は、onInjectonReadで構成します。onReadは、取得処理を実装します。

class Sample_List extends BEAR_Ro
{
    public function onInject()
    {
        // 処理が記述される
    }

    public function onRead($values)
    {
        // 処理が記述される         
    }
}

作成の場合

作成の場合は、onInjectonCreateで構成します。onCreateは、作成処理を実装します。

class Sample_Create extends BEAR_Ro
{
    public function onInject()
    {
        // 処理が記述される
    }

    public function onCreate($values)
    {
        // 処理が記述される             
    }
}

削除の場合

削除の場合は、onInjectonDeleteで構成します。onDeleteは、削除処理を実装します。

class Sample_Delete extends BEAR_Ro
{
    public function onInject()
    {
        // 処理が記述される
    }

    public function onDelete($values)
    {
        // 処理が記述される             
    }
}

更新の場合

更新の場合は、onInjectonUpdateで構成します。onUpdateは、更新処理を実装します。

class Sample_Update extends BEAR_Ro
{
    public function onInject()
    {
        // 処理が記述される
    }

    public function onUpdate($values)
    {
        // 処理が記述される             
    }
}

ビュー

ビューでは、SmartyというPHPのテンプレートエンジンを使います。Smartyについて詳しく知りたい人は、公式ドキュメントをご覧ください。
ここではページから渡された値の表示を使って、画面上に表示を行います。

最後に

今回は、BEAR.Saturdayで画面を作るために必要な三つのコンポーネントの概要をざっくりと紹介しました。
ここまで読んでいただきありがとうございました。