EC2をCloudFormationとAnsibleで構成管理する

エキサイト株式会社のみーです。

最近はコンテナばかり触っていますが、要件によってはEC2の非コンテナ環境で構築しなければいけないこともあると思います。そういう時に出てくるのが、構成管理どうしよう、という悩み。

ヘルパースクリプトを活用する

AWSに触れ始めた頃、CFnで全てを完結させたいという謎のモチベーションがありました。CFnのヘルパースクリプトを使うことで、その目的は達せられます。

docs.aws.amazon.com

例えば、SESにリレーするようにPostfixを設定したEC2を構築するとなると、以下のような感じ。

Resources:
  EC2Instance:
    Type: AWS::EC2::Instance
    Metadata:
      AWS::CloudFormation::Init:
        config:
          files:
            /etc/cfn/cfn-hup.conf:
              content: !Sub |
                [main]
                stack=${AWS::StackId}
                region=${AWS::Region}
                interval=3
              mode: "000400"
              owner: root
              group: root
            /etc/cfn/hooks.d/cfn-auto-reloader.conf:
              content: !Sub |
                [cfn-auto-reloader-hook]
                triggers=post.update
                path=Resources.EC2Instance.Metadata.AWS::CloudFormation::Init
                action=/opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource EC2Instance --region ${AWS::Region}
                runas=root
              mode: "000400"
              owner: root
              group: root
            /tmp/sasl_passwd:
              content: |
                [email-smtp.us-east-1.amazonaws.com]:587 foo:bar
              mode: "000600"
              owner: root
              group: root
          commands:
            01_postconf:
              command: |
                postconf -e \
                  "relayhost = [email-smtp.us-east-1.amazonaws.com]:587" \
                  "smtp_sasl_auth_enable = yes" \
                  "smtp_sasl_security_options = noanonymous" \
                  "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd" \
                  "smtp_use_tls = yes" \
                  "smtp_tls_security_level = encrypt" \
                  "smtp_tls_note_starttls_offer = yes" \
                  "smtp_tls_CAfile = /etc/ssl/certs/ca-bundle.crt"
            02_cp_sasl_passwd:
              command: cp /tmp/sasl_passwd /etc/postfix/sasl_passwd
            03_postmap:
              command: postmap hash:/etc/postfix/sasl_passwd
            04_chown_db:
              command: chown root:root /etc/postfix/sasl_passwd.db
            05_chmod_db:
              command: chmod 600 /etc/postfix/sasl_passwd.db
          services:
            sysvinit:
              cfn-hup:
                enabled: true
                ensureRunning: true
                files:
                  - /etc/cfn/cfn-hup.conf
                  - /etc/cfn/hooks.d/cfn-auto-reloader.conf
              postfix:
                enabled: true
                ensureRunning: true
                files:
                  - /tmp/sasl_passwd
    Properties:
      ImageId: !Ref ImageId
      KeyName: !Ref KeyName
      SubnetId: !Ref SubnetId
      AvailabilityZone: !Ref SubnetAz
      InstanceType: !Ref InstanceType
      SecurityGroupIds:
        - !Ref SecurityGroupId
      UserData:
        Fn::Base64: !Sub |
          #!/bin/bash -xe
          yum update -y aws-cfn-bootstrap
          /opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource EC2Instance --region ${AWS::Region}
          /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource EC2Instance --region ${AWS::Region}

設定ファイルやコマンドなどをテンプレート内に記述するわけですが・・・見ての通り、決して美しいとは言えません。もちろん最適化の余地はあるのですが、このまま使い続ける気にはなりません。本来の目的は構成管理であって、テンプレートを美しく記述することではないわけです。

ということで、別の方法を考えることに。

Ansibleを使う

AWSの構成管理サービスといえばAWS OpsWorksですね。PuppetやChefを使い慣れているのであればベターな選択かと思います。

aws.amazon.com

なのですが、エキサイトでは構成管理ツールにAnsibleを採用することが多かったりします。個人的にもAnsibleは使いやすくて好きなので、今回はOpsWorksは使いません。

AWSでAnsibleプレイブックを実行する場合、SSMドキュメントの AWS-ApplyAnsiblePlaybooks を利用するのがおすすめです。
事前にS3にプレイブックをアップロードしておき、あとはマネコンなどからポチるだけで実行されます。とても快適。

docs.aws.amazon.com

以下のサンプルでは、ansible-playbook-${AWS::Region}-${AWS::AccountId} というS3バケットを用意しておき、playbook.zip という名前で対象のプレイブックをアップロードしていることが前提になっています。

Resources:
  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref ImageId
      KeyName: !Ref KeyName
      SubnetId: !Ref SubnetId
      AvailabilityZone: !Ref SubnetAz
      InstanceType: !Ref InstanceType
      SecurityGroupIds:
        - !Ref SecurityGroupId

  ApplyAnsiblePlaybooks:
    Type: AWS::SSM::Association
    Properties:
      Name: AWS-ApplyAnsiblePlaybooks
      AssociationName: sample-association
      WaitForSuccessTimeoutSeconds: 300
      Targets:
        - Key: InstanceIds
          Values:
            - !Ref EC2Instance
      OutputLocation:
        S3Location:
          OutputS3BucketName: !Sub ansible-playbook-${AWS::Region}-${AWS::AccountId}
          OutputS3KeyPrefix: log
      Parameters:
        SourceType:
          - "S3"
        SourceInfo:
          - !Sub |
            {"path": "https://ansible-playbook-${AWS::Region}-${AWS::AccountId}.s3-${AWS::Region}.amazonaws.com/playbook.zip"}
        InstallDependencies:
          - "True"
        PlaybookFile:
          - "playbook.yml"
        ExtraVariables:
          - "SSM=True"
        Check:
          - "False"
        Verbose:
          - "-v"

これをデプロイすれば、良い感じにEC2インスタンスが作成されて、良い感じにAnsibleプレイブックが実行されます。プレイブックを変更した場合は、SSMのステートマネージャーから即座に適用できます。いいね。

トラブルシューティング

上記のテンプレート、EC2を閉域網に構築した場合には失敗します。InstallDependencies パラメータをTrueにすると、Ansibleや依存関係にあるPythonなどのミドルウェアをインストールしてくれるのですが、Amazon Linux 2の場合は以下のようなコマンドが実行されます。

sudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
sudo yum install -y ansible

思いっきり外に出ようとしているな。

が、そういうことであれば InstallDependencies パラメータはFalseにしておき、その前に AWS-RunShellScript でExtras LibraryからAnsibleをインストールしてしまえば良さそう。

  InstallAnsible:
    Type: AWS::SSM::Association
    Properties:
      Name: AWS-RunShellScript
      AssociationName: sample-association
      WaitForSuccessTimeoutSeconds: 300
      Targets:
        - Key: InstanceIds
          Values:
            - !Ref EC2Instance
      OutputLocation:
        S3Location:
          OutputS3BucketName: !Sub ansible-playbook-${AWS::Region}-${AWS::AccountId}
          OutputS3KeyPrefix: log
      Parameters:
        commands:
          - sudo amazon-linux-extras install ansible2 -y

S3のVPCエンドポイントを設定しておく必要はありますが、これなら閉域網でも問題ありませんね。

おわりに

CFnでは、AWSリソースの構成管理だけに徹するのが良いのかなと思います。銀の弾丸は存在しませんので、最適なツールを選択していきたいところです。

JUnitの@Before系アノテーションのバージョンによる違い

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

JavaJUnitを使ってユニットテスト(UT)を行うとき、各テストの前に共通で行いたい処理がある場合、 @Before から始まるアノテーションを使えば簡潔に実装することが出来ます。

ですが、 @Before 系のアノテーションには @Before , @BeforeEach , @BeforeClass , @BeforeAll などがあり、使い分けが必要です。

JUnit5以前

JUnit5以前では、 @Before@BeforeClass を使うことになります。 @BeforeEach@BeforeAll は存在していません。

@Before

@Before アノテーションを使用すると、各テストメソッド実行前に @Before アノテーションがついたメソッドが実行されます。 そのため、テストメソッドの個数分実行されます。

public class ServiceTest {
    @Before
    public void setup() {
        // ここに毎処理ごとに必要な処理を追加
    }

    @Test
    @Description("サンプルテスト1")
    public void getSample1() {
        // ここが実行される前に、 `setup` メソッドが実行されます
    }

    @Test
    @Description("サンプルテスト2")
    public void getSample2() {
        // ここが実行される前に、 `setup` メソッドが実行されます
    }
}

@BeforeClass

@BeforeClass アノテーションを使用すると、初回のテスト実行前に @BeforeClass アノテーションがついたメソッドが実行されます。 @Before がテストメソッドの処理ごとに実行されるのに対し、 @BeforeClass はテストクラスの処理ごとに実行されるため、クラスが1つであればメソッドがいくつあっても1回しか実行されません。 また関連し、staticメソッドとしてしか定義できません。

public class ServiceTest {
    @BeforeClass
    public static void setup() {
        // 初回に必要な処理を追加
    }

    @Test
    @Description("サンプルテスト1")
    public void getSample1() {
        // ここが実行される前に、 `setup` メソッドが実行されます
    }

    @Test
    @Description("サンプルテスト2")
    public void getSample2() {
        // サンプルテスト1の前で `setup` メソッドが実行されている場合、この処理の前に `setup` メソッドは実行されません
    }
}

JUnit5以後

JUnit以後はアノテーション名が変わり、 @Before@BeforeEach に、 @BeforeClass@BeforeAll になりました。 IntelliJ IDEAの補完等にも出てこなくなってしまうので、注意しましょう。

名前は変わりましたが、使い方は同じです。

まとめ

JUnitのバージョンで名前が変わるため、 @Before 系のアノテーションについて調べ、いざ使ってみようと思ったらエラーになった…ということも起きうると思います(実体験)。 機能自体はとても有用で使いどころもあるので、ぜひ使っていきましょう。

RedisCacheConfigの設定

エキサイト株式会社 メディア事業部エンジニアの中尾です。

SQL Serverからデータを取得し、redisにデータをキャッシュさせようとしたら以下のエラーが出ました。

Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module \"com.fasterxml.jackson.datatype:jackson-datatype-jsr310\" to enable handling

どうやらdatatype-jsr310が効いていないようです。

https://github.com/FasterXML/jackson-databind/issues/2983

issuesでもあったようですね。

objectMapperをカスタマイズする

redisとのやり取りの部分をobjectMapperの設定を追加してやります。

ポイントは、redisにキャッシュする際のデータの書き読み込みする際のobjectMapperの設定をGenericJackson2JsonRedisSerializerに設定しているところです。 SerializationFeature.WRITE_DATES_AS_TIMESTAMPSをすることでISO8601 形式にシリアライズします。

@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {

    /**
     * ElastiCacheではconfigコマンドの実行が禁止されているため、「ConfigureRedisAction」を「NO_OP」に設定しておく
     *
     * @return ConfigureRedisAction
     */
    @Bean
    public ConfigureRedisAction configureRedisAction() {
        return ConfigureRedisAction.NO_OP;
    }

    /**
     * Redisの設定
     *
     * @param factory
     * @return ReactiveRedisTemplate
     */
    @Bean
    public ReactiveRedisTemplate reactiveRedisTemplate(LettuceConnectionFactory factory) {
        StringRedisSerializer keySerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(this.objectMapper());
        RedisSerializationContext.RedisSerializationContextBuilder<String, Object> builder =
                RedisSerializationContext.newSerializationContext(keySerializer);
        RedisSerializationContext<String, Object> context = builder.value(valueSerializer).build();
        return new ReactiveRedisTemplate<>(factory, context);
    }

    /**
     * Redisの中に平文でデータを保存するための設定
     *
     * @return RedisCacheManagerBuilder
     */
    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
        return builder -> {
            var map = Arrays.stream(CacheKeyType.values())
                    .collect(
                            Collectors.toMap(
                                    e -> e.getKey()
                                    , e -> RedisCacheConfiguration
                                            .defaultCacheConfig()
                                            .entryTtl(Duration.ofSeconds(e.getTtl()))
                                            .disableCachingNullValues()
                                            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.string()))
                                            .serializeValuesWith(RedisSerializationContext.SerializationPair
                                                    .fromSerializer(new GenericJackson2JsonRedisSerializer(this.objectMapper())))
                            ));
            builder.withInitialCacheConfigurations(map);
        };
    }

    /**
     * redisにキャッシュする際のデータの書き読み込みする際のobjectMapperの設定
     *
     * LocalDateTime などの Date and Time API 関連のフィールドを扱う
     * 不明なプロパティがあっても無視
     * オブジェクト情報を追加する
     *
     * @return
     */
    private ObjectMapper objectMapper() {
        return new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                .activateDefaultTyping(BasicPolymorphicTypeValidator.builder()
                                .allowIfBaseType(Object.class)
                                .build(),
                        ObjectMapper.DefaultTyping.EVERYTHING)
                ;
    }

    /**
     * キャッシュからのデシリアライズに失敗した場合は、
     * エラーにはせずに対象のメソッドをそのまま呼ぶ
     * その結果はキャッシュされる
     *
     * @return
     */
    @Override
    @Bean
    public CacheErrorHandler errorHandler() {

        return new SimpleCacheErrorHandler() {

            @Override
            public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
                if (isSerializationError(exception)) {
                    return;
                }

                throw exception;
            }

            private boolean isSerializationError(RuntimeException exception) {
                if (exception instanceof SerializationException) {
                    return true;
                }

                Throwable cause = exception.getCause();
                return (cause != null && cause instanceof SerializationException);
            }
        };
    }
}

設定を追加したら以下のようにキャッシュされるようになりました。

[
    "クラス名",
    {
        "id": "0000000",
        "longValue": [
            "java.lang.Long",
            7261626
        ],
        "date": [
            "java.time.LocalDateTime",
            "2021-05-25T06:17:30.743"
        ],

Android 12におけるスプラッシュスクリーン

こんにちは。エキサイト株式会社 Androidエンジニアの克です。

Androidアプリでは、起動時に最初の画面が表示されるまで多少の時間がかかり、真っ白の画面がしばらく続いてしまうということがよくあります。

そのため、最初の画面が表示されるまでアプリのロゴを表示しておくというのはよく使われる手法です。(以下スプラッシュスクリーンと呼称)

Android 12ではこのスプラッシュスクリーンにより拡張性が加わりましたが、注意しなければならない点もあったので紹介させていただこうと思います。

※ 今回紹介する内容は Android 12 Beta におけるものです。正式版では仕様が変わっている可能性もあるため詳細については公式のドキュメントを参照してください。

Android 7までの実装

公式で推奨しているようなやり方は特にはありませんでしたが、概ね下記のようにテーマの差し替えという形で実装されてきました。

<!-- theme.xml -->
<resources>

    <!-- 通常のテーマ -->
    <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar" />

    <!-- スプラッシュスクリーンのテーマ -->
    <style name="AppTheme.Splash">
        <item name="android:windowBackground">@drawable/splash_screen</item>
    </style>

</resources>
<!-- splash_screen.xml -->
<?xml version="1.0" encoding="utf-8"?>
<layer-list
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:opacity="opaque">

    <!-- 画像をそのまま使うと全体に引き伸ばされてしまうので、layer-listを使う -->

    <item>
        <color android:color="#FFF" />
    </item>

    <item>
        <bitmap
            android:gravity="center"
            android:src="@drawable/splash_image" />
    </item>

</layer-list>
<application
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:theme="@style/AppTheme.Splash"> <!-- スプラッシュスクリーンのテーマを指定  -->
    <activity
        android:name=".MainActivity"
        android:exported="true">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
</application>
// MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // スプラッシュスクリーンのテーマから通常のテーマに戻す
        setTheme(R.style.AppTheme)

        setContentView(R.layout.activity_main)
    }
}

こうすることで最初の画面が立ち上がるまではスプラッシュスクリーンが表示され、立ち上がった後は通常の表示に切り替わります。

Android 8からの実装

Android 8からは、スプラッシュスクリーン用のAPIとして windowSplashscreenContentが追加されました。

こちらをテーマに設定することで、テーマの切り替えをしなくても自動でスプラッシュスクリーンの表示と非表示をやってくれます。

<resources>

    <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <item name="android:windowSplashscreenContent">@drawable/splash_screen</item>
    </style>

</resources>

Android 12からの実装

Android 12の端末で起動されたアプリは、なんとデフォルトでアプリアイコンがスプラッシュスクリーンとして表示されます。

そのためアプリアイコンをそのままスプラッシュスクリーンに表示するだけの場合は実装は不要です。

別の画像を表示したい場合はAndroid 8からの場合と同様に windowSplashscreenContentを使用します。

Android 12では、他にもSplash screensという形で専用のAPI群が追加され、スプラッシュスクリーンの細かい表示内容や表示時間などのカスタマイズが可能になりました。 developer.android.com

Android 12での注意点

デフォルトでスプラッシュスクリーンが表示されることは喜ばしいことでもありますが、注意点として windowBackgroundで画像を表示していた場合については無視されます。

また、テーマの設定ではなくスプラッシュスクリーン用の画面を別途用意していた場合、スプラッシュスクリーンが2回表示されてしまうことにも繋がります。

さらに、これはtargetSdkVersionAndroid 12未満に設定されている場合でも適用されます。

よってスプラッシュスクリーンを自前で実装しているアプリについては、Android 12の正式版がリリースされる前に動作を確認しておいたほうがよいでしょう。

まとめ

昔はスプラッシュスクリーンの実装方法も統一されておらず、無駄に長い時間ユーザーを拘束してしまうような実装になっている場合もありました。

OSが新しくなるにつれて画一された実装方式になり、よりシンプルで拡張性のある表現をすることができるようになってきています。

スプラッシュスクリーンはブランドイメージを強調するという側面もあるかもしれませんが、ユーザーはロゴを見るためにアプリを起動するのではないということを念頭に置き、使いやすいアプリにしていきたいですね。

🎉Twitterアカウントを開設しました!🎉

f:id:excitech:20210526151104p:plain

お知らせ

こんにちは。excite新卒デザイナーの山﨑です。

この度excitechという技術発信のTwitterアカウントを開設しました!

twitter.com

じゃ〜ん!

f:id:excitech:20210526143256p:plain

アイコンとヘッダーは内波さんという直属のメンターさんが製作してくれました🙏めちゃくちゃかっこいいです!

個人的にロゴの元になったAvenirという書体が大好きなので、すごく嬉しいです〜!Avenirは最強💪

こちらのテックブログの更新情報や、イベントについても随時発信していく予定なので、是非フォローしてみてください!

それでは!

Javaのメソッドの返り値に、オブジェクトを使うべきかインターフェースを使うべきか

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

Javaで配列を扱う時、すべての配列のインターフェースである List と、具体的に実装したオブジェクトである ArrayListLinkedList 等があります。 メソッドの返り値で配列を返したいときはこれらのどれでも使うことが出来ますが、実際にどれを使うべきなのでしょうか?

今回は、メソッドの返り値にインターフェースを使うべきか、それともオブジェクトを使うべきかについて説明していきます。

Javaの配列のインターフェースとオブジェクト

例えばPHPを使っている人にとっては、配列はそれ以上でもそれ以下でもありません。 ですが実はJavaには、「配列」と一口に言っても内部の保存方法によって種類が存在します。

例えば ArrayListLinkedList という形式がありますが、保存方法の違いにより、 ArrayList はランダムアクセスに強く、 LinkedList は要素の追加・削除に強いという特長があります。 そのため、その配列に対してどんな操作をしたいかによって、使う配列の形式を使い分けるのが良いでしょう。

そして、それらはすべて List というインターフェースを実装したものです。

さて、以上のことを踏まえると、配列を返すメソッドを考える時、ざっくり以下の2通りがあることがわかります。

// 返り値が、インターフェースである List
public List<String> getList() {
    return new ArrayList<String>();
}
// 返り値が、オブジェクトである ArrayList
public ArrayList<String> getList() {
    return new ArrayList<String>();
}

どちらでもコードとしては正しく動きますが、どちらを使うべきなのでしょうか? 実はこれは常に正しい方法が決まっているわけではなく、状況次第で変わってきます。

返り値の決定方法

まずは、それぞれのメリットを見ていきます。

インターフェースを使うメリット

  • 各オブジェクトそれぞれに固有のメソッドを使えなくなるので、配列オブジェクトを別の種類に変更しやすい
  • 空配列を使う場合のみに使用する emptyList 等が併用できる

オブジェクトを使うメリット

  • 各オブジェクトに固有のメソッドが使える
  • 使用するオブジェクトの種類を固定できる

以上のことから、まず各オブジェクトに固有のメソッド(例えば、 ArrayList にしか存在しないメソッド)を使いたい場合は、必ず返り値はオブジェクトにする必要があります。 また、パフォーマンス上どうしても使用するオブジェクトを固定する必要がある場合は、返り値をオブジェクトにしてもいいかもしれません。

それ以外の場合は、コードの柔軟性を考えると基本的にはインターフェースを使うのが良いかと思います。 また、仮にパフォーマンス上オブジェクトを固定する場合でも、メソッドの内部で負荷の高い処理が完結する場合や、よほどコードを固くする(ルールをがっちり決める)必要がない限りは返り値はインターフェースで十分なのではないでしょうか。 多くの場合は、コードの柔軟性確保のほうが全体としてメリットが上がる場合が多いです。

まとめ

どうしてもオブジェクトを使わなければならない状況以外では、コード柔軟性のためにインターフェースを使うほうが良いでしょう。

こうしたインターフェースとオブジェクトの仕組みは、配列だけでなくマップ(インターフェースの Map とオブジェクトの HashMap )等にもありますので、そういった状況でも同じ考え方が通用するはずです。

restTemplateでElasticsearchに問い合わせる

エキサイト株式会社 メディア事業部エンジニアの中尾です。

rest-high-level-clientのようなライブラリではなく、restTmeplateでElasticsearchに問い合わせる方法を説明します。

https://mvnrepository.com/artifact/org.elasticsearch.client/elasticsearch-rest-high-level-client

使う場面として、Elasticsearch5系のバージョン以下の場合、Elasticsearchで使っているオプションがライブラリで扱われていない(auto_generate_synonyms_phrase_query)などあるので、

DAOとして、以下のようにJsonのNodeを渡します。

@Component
@RequiredArgsConstructor
public class ElasticSearchDaoImpl implements ElasticsearchDao {

    @Value("${spring.elasticsearch.url}")
    public String url;

    private final RestTemplate restTemplate;

    private final ObjectMapper objectMapper;

    @Override
    public JsonNode searchRequest(JsonNode jsonNode) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        HttpEntity<String> entity = new HttpEntity<String>(jsonNode.toString(), headers);
        ResponseEntity<String> exchange =
                restTemplate.exchange(this.url, HttpMethod.POST, entity, String.class);

        if (exchange.getStatusCode() != HttpStatus.OK) {
            throw new ElasticsearchException(exchange.getBody());
        }
        try {
            return objectMapper.readTree(exchange.getBody());
        } catch (JsonProcessingException e) {
            throw new ElasticsearchException(e.getMessage());
        }
    }
}

どんなJsonNodeを渡すかというと、以下のようにElasticsearchに問い合わせるのに必要そうな値をセットするデータクラスを用意し、SearchSourceBuilder.toStringでstringにした後、objectMapperでJsonNode化します。

JsonNode jsonNode = objectMapper.readTree(elasticSearchForm.toString());
@Data
@Accessors(chain = true)
public class ElasticSearchForm {

    /**
     * Elasticsearchから取得するインデックス
     */
    private String index;

    /**
     * Elasticsearchから取得するカラム
     */
    private String[] includes;

    /**
     * Elasticsearchから取得しないカラム
     */
    private String[] excludes;

    /**
     * 検索条件
     */
    private BoolQueryBuilder boolQueryBuilder;

    /**
     * offset
     */
    private int from = 0;

    /**
     * limit
     */
    private int size = 10;

    /**
     * order
     */
    private FieldSortBuilder fieldSortBuilder;

    public String toString(){
        return new SearchSourceBuilder()
                .fetchSource(
                        includes,
                        excludes)
                .query(boolQueryBuilder)
                .from(from)
                .size(size)
                .sort(fieldSortBuilder)
                .toString();
    }
}

JsonNode化すれば、不要なところは以下のように消すことができます。

※本当はもっとネストしていると思いますが、簡略しています

((ObjectNode) jsonNode.get("query").........remove("auto_generate_synonyms_phrase_query");

あとは、このJsonNodeを先程のDAOの引数に入れるだけです。

レスポンスについて、以下のようにstreamを使えば、必要なデータのListが作れると思います。 他に必要なデータは適宜JsonNodeから取得してください。

                    StreamSupport.stream(
                            response.get("hits").get("hits").spliterator(), false)
                            ).map(e -> {
                                 // 処理
                                 }
                            )
                            .collect(Collectors.toList());

Elasticsearchに問い合わせるJsonはネストが深く、文字列結合やMapだとどうしても見通しが悪くなる場合があります。

Builderを使えるところは使って、それをjsonに変換した方が見通しがよくなると思うので、よかったら使ってください。

Javaのカスタムバリデーションで2つ以上のプロパティをチェックするアノテーション

エキサイト株式会社 メディア開発の佐々木です。

現在、SpringBootで2つ以上のプロパティをチェックするカスタムバリデーションを共有します。

アノテーションの定義

下記のようにカスタムアノテーションを定義します。 (@Constraint(validatedBy = NotBlankAny.NotBlankAnyValidator.class) ここの部分はエラーになります)

@Documented
@Target(value = {ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NotBlankAny.NotBlankAnyValidator.class)
public @interface NotBlankAny {
    String message() default "please {fields} not empty.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String[] fields();
}

バリデーション定義

次にバリデーション部分を定義します。

    class NotBlankAnyValidator implements ConstraintValidator<NotBlankAny,Object> {

        private String[] fields;

        @Override
        public void initialize(NotBlankAny constraintAnnotation) {
            this.fields = constraintAnnotation.fields();  // ※1
        }

        @Override
        public boolean isValid(Object value, ConstraintValidatorContext context) {
            if (Objects.isNull(value)) {
                return false;
            }
            BeanWrapperImpl beanWrapper = new BeanWrapperImpl(value);
            return Stream.of(fields).allMatch(e -> StringUtils.isNotBlank((String)beanWrapper.getPropertyValue(e))); //  ※2
        }
    }

※1アノテーション内で指定されたフィールドの文字列が配列で入ってきます。※2で一つずつ取り出してチェックします。

使い方

対象のデータクラスに、 @NotBlankAny(fields = {"フィールド名1","フィールド名2"}) を定義すると、そのデータを使ってバリデーションがかかります。

@RestController
@RequestMapping
@RequiredArgsConstructor
@Slf4j
public class DemoController {

    @GetMapping
    public Mono index(@Valid Form form) {
        return Mono.defer(() -> Mono.just(form));
    }

    @Data
    @NotBlankAny(fields = {"firstName","lastName"})  // こんな感じで設定します
    static class Form {
        private String firstName;
        private String lastName;
    }
}

これでチェックができるので、再利用性はかなり高いバリデーションができると思います。

最後に

フィールド複数のバリデーションを使いたいときって結構あると思います。メールアドレスの再入力とか、パスワードを同じもの2つ入れるとか。実際にチェックのところは、一致や異なるものやどれかひとつみたいなものは、バリデーション側での実装でバリエーションは増やせると思います。

宣伝

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

www.wantedly.com

おまけ

全体のコード

@Documented
@Target(value = {ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NotBlankAny.NotBlankAnyValidator.class)
public @interface NotBlankAny {
    String message() default "One of {fields} must not be empty";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String[] fields();

    class NotBlankAnyValidator implements ConstraintValidator<NotBlankAny,Object> {

        private String[] fields;

        @Override
        public void initialize(NotBlankAny constraintAnnotation) {
            this.fields = constraintAnnotation.fields();
        }

        @Override
        public boolean isValid(Object value, ConstraintValidatorContext context) {
            if (Objects.isNull(value)) {
                return false;
            }
            BeanWrapperImpl beanWrapper = new BeanWrapperImpl(value);
            return Stream.of(fields).allMatch(e -> StringUtils.isNotBlank((String) beanWrapper.getPropertyValue(e)));
        }
    }
}

SQL Serverのwith(NOLOCK)の挙動について

エキサイト株式会社 メディア事業部エンジニアの中尾です。

最近はSQL Serverを使うことが多いので、SQL Serverでよく使われているwith(NOLOCK)の挙動について説明できたらと思います。

with(NOLOCK)とは

簡単にいうと、トランザクションレベルを強制的にREAD UNCOMMITTEDな状態でselect文が使えます。

。。

。。。

難しいですね。

図で説明しましょう。

デフォルトのトランザクションレベル

SQL Server以外のMySQL,PostgresもデフォルトはREAD COMMITTEDと呼ばれる、トランザクションAの中でCUDを発行しても、別のトランザクションからはトランザクションAの変更内容が見えない状態です。

以下の図のように、ID=3のレコードの内容を取得しようとすると、トランザクション開始前の松本一郎が取得できます。

f:id:excite-naka-sho:20210513222720p:plain

しかし、SQL ServerのREAD COMMITTEDはちょっと違います。

READ COMMITTEDにスナップショットという概念が

SQL ServerのREAD COMMITTEDは行のバージョン管理がされていません。 つまり、トランザクションA内でCUDを発行すると、ほかのトランザクションから変更内容は見えないのですが、変更内容をコミット(or ロールバック)するまで待機してしまいます(行ロック)

以下の図のように、ID=3のレコードの内容を取得しようとすると、トランザクション開始しているレコードを取得する場合、コミット(or ロールバック)するまで、名前が取得できません。

f:id:excite-naka-sho:20210513222410p:plain

これを解消するためにはスナップショットを有効にしないといけません。

スナップショットとは、行ごとのバージョン管理をするという意味です。

以下の図のように、ID=3のレコードの更新前のデータをテンポラリに退避しており、ほかのトランザクションから問い合わせをされても、その退避内容を返します。

f:id:excite-naka-sho:20210513222744p:plain

行ごとのバージョン管理をするということは、もちろんtempdbのサイズが大きくなるので、

これからスナップショットを有効にする場合は、パフォーマンスに気をつけてください。

以下のalter文のどちらかを発行することで有効になります。

ALTER DATABASE MyDatabase  
SET ALLOW_SNAPSHOT_ISOLATION ON  

→オンライン中に実行可能で、select文を使う前に、SET TRANSACTION ISOLATION LEVEL SNAPSHOT を実行することで、スナップショットが使えるようになります。

ALTER DATABASE MyDatabase  
SET READ_COMMITTED_SNAPSHOT ON  

→オンライン中に実行不可能ですが、一度実行すると、全てのクエリでスナップショットが有効になります。

もしかしたら、SQL Server以外のRDBはデフォルトでスナップショットという概念で行ごとのバージョン管理をしているかもしれません。

詳細は以下のURLを参考にしてください。

SQL Server でのスナップショット分離 - SQL Server | Microsoft Docs

AWS Fault Injection Simulatorを使って障害を発生させる

エキサイト株式会社のみーです。

今年の3月より利用可能となったAWS Fault Injection Simulator、FISを使ってフォールトインジェクション実験をしてみました。

aws.amazon.com

いわゆるカオスエンジニアリングです。フォールトインジェクション実験をするツールとしてはNetfilxのChaos Monkeyなどが有名ですが、FISを利用することでマネージドに実施できるようになりました。

さて、CFnオタクなのでテンプレートを書いてみたのですが、書き方に少しハマってしまいました。AWS CLIで実行するときも同様にハマる可能性があるため、備忘録として残しておこうと思います。

最終的なCFnテンプレート

試行錯誤の結果、以下のようになりました。RDSインスタンスを再起動させて、強制的にフェイルオーバーを発生させるという実験内容です。

Resources:
  FISExperimentTemplate:
    Type: AWS::FIS::ExperimentTemplate
    Properties:
      Description: "RDS failover experiment"
      RoleArn: !GetAtt IAMRole.Arn
      Actions:
        RebootInstance:
          ActionId: aws:rds:reboot-db-instances
          Parameters:
            forceFailover: true
          Targets:
            DBInstances: MyInstances
      Targets:
        MyInstances:
          ResourceType: aws:rds:db
          ResourceArns:
            - arn:aws:rds:ap-northeast-1:123456789012:db:hoge-piyo
          SelectionMode: ALL
      StopConditions:
        - Source: none
      Tags:
        Env: !Ref Env

ここに至るまで、1時間くらい悩みました。

書き方がよくわからない

最初はActionsの書き方(特にTargets)がわからず、公式サンプルを参考に以下のように書いていました。

Actions:
  RebootInstance:
    ActionId: aws:rds:reboot-db-instances
    Parameters:
      forceFailover: true
    Targets:
      Instances: MyInstances

すると怒られちゃうわけ。

Resource handler returned message: "Unexpected target "Instances" found in action "RebootInstance".

なるほどわからんInstances というキーがダメなのか、MyInstances というバリューがダメなのか、よくわからん。
っで、一旦諦めてマネコンから作ってみたのですが、そこで原因判明。

f:id:ex-mii:20210520010640p:plain

↑の赤枠内が、先ほどのActionsで指定するTargetsの Key/Value ということ。TargetのResourceTypeによって特定のキーを書く必要があるっぽい。
公式サンプルを見てみると、TargetのResourceTypeがEC2の場合には Instances と書かれていました。なるほど、だからエラーになっていたのか。

docs.aws.amazon.com

他にも少し癖がありますので、公式サンプルにはしっかりと目を通しておきましょう。

おわりに

今回の RDSインスタンスでフェイルオーバーを発生させる という実験はRDSのマネコンからも試すことができます。EC2の停止実験なども同じ、マネコンからポチポチできますね。あれ、FISいらなくね?と思いませんか?

いえいえ。そうではなくて、あくまでもカオスエンジニアリングの一環として、継続的に実験を実行していくことがFISを利用する目的だと考えます。

システムが複雑になり、内部実装がブラックボックスになって・・・そんな中で重大な障害が発生すると現場はてんやわんやです。きっと誰もが1度は経験したことあるはず。
そうならないためにも日々小さな障害を意図的に発生し続けて、「サービスを停止させない」から「障害が発生しても自動で復旧する」へと意識を変えていく必要があるのかなと思いました。

FISはそのための手段の1つ。FISを活用して、より強いシステムやアプリケーションを育てていきたいものですね。

【Flutter】コピペで使える!ダイアログのデザイン集

はじめまして! エキサイト株式会社で長期インターンをしている井関です。  

まだまだ、Flutterを勉強中ですが、アウトプットや繰り返し使えるためにどんどん書いていきます👍  

目次

 

Widgetを作成する

基本的にコピペでできるとは思いますが、違うプロジェクトでペーストしたら、おそらくエラーが発生すると思います。なので、フォーマットを作っておくので、それを参考にしてもらっても構いません。初期サンプルコードを使用しているので、わかりやすいと思います。いくつか削除していますが、少しの修正なので気にせずに。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'ダイアログ'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'ダイアログ',
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          //押したら、ダイアログを出現させる。
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

その中の、OnPressedにメソッドを書いていきます。

   onPressed: () {
    //押したら、ダイアログを出現させる。
    },

ちなみに、画面はこんな感じです。

f:id:Ryutaro_isekiii:20210507184405p:plain
写真1

①まるばつクイズ風(横に均等に並べる)

Widget Dialog() {
    showDialog(
        context: context,
        builder: (_) {
          return AlertDialog(
            title: Text(
                '第一問目',
                textAlign: TextAlign.center),
            content: Text(
                 '南極で暮らす恐竜がいた。○か×か?'
                ,textAlign: TextAlign.center),
            actions: [
              Padding(
                padding: const EdgeInsets.only(
                    left: 70,
                    right: 20,
                    bottom: 20
                ),
                child: FlatButton(
                  onPressed: () {
                    //○だった時の処理を記載
                  },
                  child: Text(
                    '○',
                    style: TextStyle(
                      color: Colors.blue,
                      fontSize: 40,
                    ),
                  ),
                ),
              ),
              Padding(
                padding: const EdgeInsets.only(
                  right: 70,
                  left: 20,
                  bottom: 20,
                ),
                child: FlatButton(
                  onPressed: () {
                    //×だった時の処理を記載
                  },
                  child: Text(
                    '×️',
                    style: TextStyle(
                        color: Colors.red,
                        fontSize: 40),
                  ),
                ),
              ),
            ],
          );
        }
    );
  }

f:id:Ryutaro_isekiii:20210517165002p:plain
実際のダイアログ

<解説>

actions: []は、基本的に、右寄せになってしまう。(基本的に右寄せがフォーマットなのかなと思っています。) mainAxisSize: MainAxisSize.centerやCenter() を入れても変わりませんでした。勉強していつか解明できるといいな〜〜 そのため、Paddingを活用することで無理やり入れみてました。

//左側
 Padding(padding: const EdgeInsets.only(left: 70,right: 20,,bottom: 20,),
//右側
 Padding(padding: const EdgeInsets.only(right: 70,left: 20,bottom: 20,),

②3択クイズ風(縦に並べる)


 Widget Dialog() {
    showDialog(
        context: context,
        builder: (_) {
          return AlertDialog(
            title: Text(
                '第一問目',
                textAlign: TextAlign.center),
            content: Text(
                'ティラノサウルスの全身の骨の数は?'
                ,textAlign: TextAlign.center),
            actions: [
              Column(
                children: [
                  FlatButton(
                    padding: const EdgeInsets.symmetric(horizontal: 100),
                    onPressed: () {
                      //①だった時の処理を記載
                    },
                    child: Text(
                      '①約350本',
                      style: TextStyle(
                        fontSize: 20,
                      ),
                    ),
                  ),

                  FlatButton(
                    padding: const EdgeInsets.symmetric(horizontal: 100),
                    onPressed: () {
                      //②だった時の処理を記載
                    },
                    child: Text(
                      '②約500本',
                      style: TextStyle(
                        fontSize: 20,
                      ),
                    ),
                  ),

                  FlatButton(
                    padding: const EdgeInsets.symmetric(horizontal: 100,),
                    onPressed: () {
                      //③だった時の処理を記載
                    },
                    child: Text(
                      '③約820本',
                      style: TextStyle(
                        fontSize: 20,
                      ),
                    ),
                  ),
                ],
              ),
            ],
          );
        }
    );
  }
}

f:id:Ryutaro_isekiii:20210510182353p:plain
実際のダイアログ

<解説>

Column()を入れて、縦に並べました。そして、均等に並べるために、Paddingを使用しました。

padding: const EdgeInsets.symmetric(horizontal: 100),

おまけ

Paddingについて

ここで簡単にPaddingの種類を紹介します。

padding: EdgeInsets.all(30),  //全体に30ピクセル隙間を空ける。
padding: EdgeInsets.only(top: 30)   //上のみに30ピクセル隙間を空ける。

・種類
上 top: 30,
右 right: 30,
下 bottom: 30,
左 left: 30,

と4種類あります。自由に使ってみるといいと思います。

padding: EdgeInsets.symmetry(horizontal: 30);   //水平に30ピクセル隙間を空ける。

horizontal => 水平 = 右と左と考えると分かりやすいと思います。

vertical => 垂直 = 上と下と考えると分かりやすいと思います。

最後に

まだFlutter初心者の私ですが、同じ境遇な方にとっていい手助けになるよう、わかりやすさにこだわってこれからも書いていこうと思います!!!業務中以外にも個人開発でFlutterを勉強している生活を送っています。新しい発見の毎日なので、ワクワクしながらコードを書いています😁

いつか、個人で開発しているアプリが紹介できるくらいいいものになった際に、紹介できたらいいなと思います!!

一通り作成してみて、趣味で作成している個人開発に利用したいと思うようになりました笑

エキサイトでは、一緒に働けるモバイルアプリエンジニアを募集しています! もし興味がありましたら是非こちらのリンクから、お話しましょう! www.wantedly.com

Javaにおける、効率の良い一覧データの回し方

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

コードを書いている時、たまに「一覧データの中から条件に合ったものを持ってくる」ことが必要になる場合があります。 一覧データの件数自体が大したことがなかったり、1~2件程度を持ってくるのであれば filter を使えばいいですが、一覧データの件数や試行回数が多くなってくると、パフォーマンスが気になってきます。

今回は、高パフォーマンスで一覧データから合致データを持ってくる方法を紹介します。

一般的にどうするか

一般的に、一覧データの中から条件にあったものを持ってくる場合は以下のようにすると思います。

@Value
public class SampleModel {
   Integer id;
   String data;
}

public class Sample {
    private SampleModel doSample() {
        List<SampleModel> sampleModelList = List.of(
            new SampleModel(1, "aaa"),
            new SampleModel(2, "bbb"),
            new SampleModel(3, "ccc"),
            new SampleModel(4, "ddd"),
            new SampleModel(5, "eee")
        );
        Integer expectedId = 2;

        return sampleModelList
            .stream()
            .filter(sampleModel -> sampleModel.id == expectedId)
            .collect(Collectors.toList())
            .get(0);
    }
}

5件の SampleModel の中から、 id が2のものを取得してくる処理です。 この条件であれば、これで十分でしょう。

ですが、以下の場合はどうでしょうか?

@Value
public class SampleModel {
   Integer id;
   String data;
}

public class Sample {
    private List<SampleModel> doSample() {
        List<SampleModel> sampleModelList = List.of(
            new SampleModel(1, "aaa"),
            new SampleModel(2, "bbb"),
            new SampleModel(3, "ccc"),
            new SampleModel(4, "ddd"),
            new SampleModel(5, "eee"),
            // 大量にデータが入る
            new SampleModel(9999, "end")
        );
        List<Integer> expectedIdList = List.of(
            2,
            5,
            87,
            // 大量にデータが入る
            8799
        );

        return expectedIdList
            .stream()
            .map(expectedId -> doFilter(expectedId, sampleModelList))
            .collect(Collectors.toList());
    }

    private SampleModel doFilter(Integer expectedId, List<SampleModel> sampleModelList) {
        return sampleModelList
            .stream()
            .filter(sampleModel -> sampleModel.id == expectedId)
            .collect(Collectors.toList())
            .get(0);
    }
}

大量にある SampleModel の中から、指定された大量の id 一覧に合致するものを持ってくる処理です。 動作自体はこれで問題ありませんが、各 List が大量にある上に、 Listmap の中で Listfilter 処理を行っているため、パフォーマンス的に良くないことは容易に想像できます。

では、このパフォーマンスを改善するにはどうすれば良いでしょうか。

HashMapを使って改善する

一覧データを扱う上で、Javaには List 形式の他に Map 形式と呼ばれるものがあります。

List 形式が、

0 -> sampleModelA
1 -> sampleModelB
2 -> sampleModelC
...

のように、自動的に割り振られた数値キーの一覧であるのに対し、 Map 形式は

ID1 -> sampleModelA
ID2 -> sampleModelB
ID3 -> sampleModelC
...

のように、明示的に指定したキーを元にした一覧です。

Map の中でもその Map の保存形式ごとに種類があり、 HashMapハッシュ値を使って保存しているため、ランダムアクセスに強い保存形式の Map になります。

この「ハッシュ値を使っているためにランダムアクセスに強い」という HashMap の特性を使うことで、先程のコードのパフォーマンスを改善できます。

具体的には、以下のような処理を行います。

@Value
public class SampleModel {
   Integer Id;
   String data;
}

public class Sample {
    private List<SampleModel> doSample() {
        Map<Integer, SampleModel> sampleModelMap = new HashMap<Integer, SampleModel>() {
            {
                put(1, new SampleModel(1, "aaa"));
                put(2, new SampleModel(2, "bbb"));
                put(3, new SampleModel(3, "ccc"));
                // 大量にデータが入る
                put(9999, new SampleModel(9999, "end"));
            }
        };
        List<Integer> expectedIdList = List.of(
            2,
            5,
            87,
            // 大量にデータが入る
            8799,
        );

        return expectedIdList
            .stream()
            .map(expectedId -> sampleModelMap.get(expectedId))
            .collect(Collectors.toList());
    }
}

HashMap を使ってフィルタリングを行うようにした結果、ひたすら Listfilter を行っていたときに比べてパフォーマンスが上がったはずです。

最後に

扱うデータ量が少ないのであればパフォーマンスをそこまで考慮する必要はないですが、大量のデータになってくると、段々とパフォーマンスも考慮する必要が出てきます。 Javaには同じ一覧データを扱う上でも様々な選択肢があるので、一度確認してみてもいいかもしれません。

MyBatis + FreeMarkerを使用した環境でレコード追加時にAuto IncrementされたIDを取得する

f:id:excite-kazuki:20210513111813p:plain

エキサイト株式会社 21卒 バックエンドエンジニアの山縣です。 MyBatisとFreeMarkerを使用した環境において、INSERT文を実行したときにAuto IncrementされたIDが取得できない問題とその解決策についてまとめました。

MyBatisとFreeMarkerを使用した環境

現在のチームでは、簡単なSELECT文やINSERT文であれば、MyBatis Generatorで生成したマッパーを使用してSQLを実行しています。 しかし、結合をする必要があるクエリーやその他複雑なクエリーは、FreeMarkerを使用するようにしています。 MyBatisとFreeMarkerに加えて、下記環境にて開発をしています。

詳細は下記記事にまとめています。 tech.excite.co.jp

問題点:MyBatis Generatorで自動生成生成したマッパーでINSERTを実行するとIDが取得できない

下記の3つをカラムに持つ書籍テーブルがあり、book_idはAuto IncrementされるIDであるとします。

  • 書籍ID(book_id)
  • タイトル(title)
  • 著者(author)

このとき、自動生成したマッパーを使用してレコードを追加するときは下記のように記述します。

@Repository
@RequiredArgsConstructor
public class BookRepositoryImpl {
    private final BookMapper mapper;
    
    public Long create(String title, String author) {
        final Book book = new Book();
        book.setTitle(title);
        book.setAuthor(author);
        return mapper.insertSelective(book);
    }
}

自動生成したマッパーのコードを見ても、intが返り値となっているため、なんだかIDが返ってきそうな感じがします。

@Generated(value="org.mybatis.generator.api.MyBatisGenerator")
default int insertSelective(Book record) {...}

しかし、insertSelectiveの返り値はINSERT文で影響を与えた件数が返るため、IDが返ってくるわけではありません。 そのため、なんらかの方法でIDを取得する必要があります。

解決策:OUTPUT句を使用する

SQL Serverでは、OUTPUT句を使用することで、INSERT / DELETE / UPDATE で追加 / 削除 / 更新された行のデータを取得することができます。 ここでは、INSERT文でレコードを追加したときのAuto IncrementされたIDを取得するために使用します。

docs.microsoft.com

まず、下記のようにftlファイルを記述します。

INSERT INTO book (
    title,
    author
)
OUTPUT
    inserted.book_id
VALUES (
    <@p name="title"/>,
    <@p name="author"/>
);

次に、Javaでは下記のようなカスタムマッパーを記述します。 このように記述することで、ftlファイルに記述したSQLを実行することができ、book_idを返り値として受け取ることができます。

@Mapper
public interface BookCustomMapper {
    @Lang(FreeMarkerLanguageDriver.class)
    @Select("insert_book_return_book_id.ftl")
    Long insert(
            @Param("title") String title,
            @Param("url") String author
    );
}

ここで、INSERT文を実行しているのに、@Select("insert_book_return_book_id.ftl")SELECT文のアノテーションを使用していることに注意する必要があります。 @Insertを使用してしまうと、OUTPUT句で指定したbook_idを取得することができません。

補足

現在のプロジェクトでは上記の解決策でAuto IncrementされたIDを取得しています。 上記解決策の他にIDを取得することができる方法についてまとめます

  1. 生成したマッパーに@SelectKey@Option("useGeneratedKeys = true") を付与する
  2. generatorConfig.xml<table> <generatedKey ... /> </table>を記述する
  3. INSERT文を実行した後に、SELECT SCOPE_IDENTITY()を実行する

1については、MyBatis Generatorで生成したマッパーにアノテーションを付与しなくてはならないため、 マッパーを再度自動生成したときにアノテーションが消えてしまうといった問題があります。

2については、INSERT文を実行したときにIDが欲しいと思ったタイミングで、generatorConfig.xmlXMLを記述し、その後自動生成を行うため手間がかかります。

3については、自分の環境で再現できなかったため断念しました。

まず、下記のようなカスタムマッパーを作成しました。 次に、生成したマッパーを使用してINSERT文を実行した後に、get_last_insert_id()を実行したところ、IDを取得することができなかったです。(nullになってしまいました) 本件についてわかる方がいたら教えていただけると嬉しいです!

@Mapper
public interface LastInsertIdCustomMapper {
    @Select("SELECT SCOPE_IDENTITY()")
    Long get_last_insert_id();
}

おわりに

MyBatisとFreeMarkerを使用した環境において、INSERT文を実行したときにAutoIncrementされたIDが取得できない問題とその解決策についてまとめました。 「IDの取得くらい簡単にできるだろう」と思っていましたが、想定していたよりも時間を使ってしまいました。 本記事を見ていただいた方のお役に立てれば幸いです。

UTってどこまでやればいいんだろう?(ポエム)

エキサイト株式会社 メディア事業部エンジニアの中です。

SpringBootでUTをやる方法をいくつか紹介しましたが、、、実際にはどこまでやればいいんだろうと、ちょっとポエムを記載します。

tech.excite.co.jp

tech.excite.co.jp

tech.excite.co.jp

tech.excite.co.jp

上記の記事にコントローラー、DB、外部APIを実行するテストの基本形を記載しています。

が、

が、

が、

果たして基本形だけで本当に良いのでしょうか?

例えば、

  • コントローラー
    • 画像ファイルをアップロードするなど、ファイルの送受信
    • セッション情報の返却
  • DB
    • CRUDのそれぞれのテスト
    • 複数のTBLを結合しているクエリ
  • 外部API
    • 画像ファイルをアップロードするなど、ファイルの送受信
    • 実行するためにheaderやhostなど、設定が複雑

など、基本形のケースではないパターンがあります。

本当に全部やりますか?

大変じゃないですか?

UTにこだわると、コードは数分の修正で終わったのに、テストコードを修正するのに1日かかるとかあります。、、

非効率的じゃないですか?

UT100%は業務において、自己満足だと思います。

※ライブラリの提供など、例外あり

品質と効率のバランスをプロジェクトで認識合わせをした上でUTを進めないと、、、

間違っても

UT100%じゃないとリリースできない

みたいなことはやめましょう

SpringBoot AOP でメソッドの実行時間を計測する

エキサイト株式会社 メディア開発の佐々木です。

SpringBoot/AOPでメソッドの実行時間を計測します。計測は重要です。

依存関係の解消

Gradleで依存関係を解消します。

dependencies {
     ...
     implementation "org.springframework.boot:spring-boot-starter-aop"   // これを追加
     ...
}

マーカーアノテーション

マーカーアノテーションを作成しておきます。計測したいメソッドにマーカーアノテーションをつけるだけで計測対象になるので、とても便利です。

/**
 * メソッドの実行速度を計測する用のマーカアノテーション.
 * 実際の計測は {@link jp.co.excite.web.aop.LatencyMonitorAop} で実行.
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface LatencyMonitor {

}

RetensionPolicy が大事で、 RUNTIME をつけないと実行時に動作しないので気をつけてください。

AOPを設定

AOPを下記のように定義します。今回は@Aroundを使います。これはメソッドの開始と終了のときに処理を差し込むことができるAOPになります。

@Aspect // AOPであることを明示
@Component
@Slf4j
public class LatencyMonitorAop {

    @Around("@annotation(jp.co.excite.web.aop.LatencyMonitor)")
    public Object monitor(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();  // メソッド開始前のシステム時刻
        Object proceed = joinPoint.proceed();     // メソッド実行
        long end = System.currentTimeMillis();   // メソッド終了後のシステム時刻
        log.info("{} {}   method latency: {} ms."    // メソッドの実行時間の出力
                , joinPoint.getTarget().getClass().getName()
                , joinPoint.getSignature().getName()
                , end - start);
        return proceed;  // メソッドの内容を返却
    }
}

開始時と終了時にシステム時刻をとっておき差分をとります。

メソッドに付与

計測したいメソッドにはじめに定義したマーカーアノテーションをつけるだけです。

@Controller
@RequestMapping
public class RootController {

    @GetMapping("fast")
    @LatencyMonitor // アノテーションをつけるだけ
    public String fast(){
        return "index";
    }

    @GetMapping("slow")
    @LatencyMonitor  // アノテーションをつけるだけ
    public String slow() throws InterruptedException {

        Thread.sleep(2000);

        return "index";
    }
}

実行ログです。

2021-05-13 00:45:07.245  INFO 17967 --- [nio-8080-exec-4] jp.co.excite.web.aop.LatencyMonitorAop   : jp.co.excite.web.controller.RootController fast   method latency: 0 ms.
2021-05-13 00:45:11.836  INFO 17967 --- [nio-8080-exec-5] jp.co.excite.web.aop.LatencyMonitorAop   : jp.co.excite.web.controller.RootController slow   method latency: 2004 ms.

それぞれ、計測されています。

最後に

SpringAOPを使ってメソッドの中身を変更せずに、実行時間を計測する機能を作ってみました。AOPはとても便利ですが、どこで何が行われてるかわからなくなるので、マーカーアノテーション等をトリガーにした作りにするのがよさそうです。AOPは補助的な処理にとても向いてると思うので、これも拡張していければと思います。

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

www.wantedly.com