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