@CookieValueでクッキーの値を取得するとき、URLエンコードされる問題の解消

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

Spring BootでCookieの値を取得する時、Controllerで

@CookieValue(name = "hoge", required = false) String hoge

で取得できると思います。

しかし、Stringで受け取る際、勝手にURLデコードされて困る場合があります。

そこで、URLデコードせずに受け取る方法を説明します。

結論から申しますと、Cookieオブジェクトで取得する方法です。

ユースケース

  1. Cookieの値を取得したい。
  2. URLデコードされたくない

題材

コード例

    @GetMapping("test")
    public String test(
            @CookieValue(name = "hoge", required = false) String hoge
    ) {
        return hoge;
    }

入力例

Cookieは以下を設定。

hoge=hogehoge%0D%0Ahogehoge; Path=/; Expires=Sun, 10 Apr 2022 01:47:34 GMT;

出力例

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

%0D%0A→\r\nに自動的に変換されている

条件

  • URLデコードできる文字列をCookieに設定する

改善

コード例

    @GetMapping("test")
    public String test(
            @CookieValue(name = "hoge", required = false) Cookie hoge
    ) {
        return hoge.getValue();
    }

Cookieで取得し、getValue()で中身を返却

入力例

Cookieは同様な設定をする

出力例

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

エンコードされていないことがわかる。

まとめ

URLデコードされたくないケースはそんなにないかもしれませんが、 覚えておいて損はないかと思います。

MapStructで高速なオブジェクトマッピングをする

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

以前、下記の記事でJavaのオブジェクトマッピングツールでModelMapperを紹介しました。

excitech.hatenablog.com

ModelMapperは、とてもお手軽で大変便利なのですが、やや速度に問題があり大量のデータ処理等には不向きです。そこで MapStructを紹介します。

ライブラリ追加

build.gradleに下記を追加し、ライブラリを追加します。

dependencies {
  ...
        implementation 'org.mapstruct:mapstruct:1.4.2.Final'
        annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
        annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'   // lombokを併用する場合は必要
  ...
}

コード全体

コード全体は下記のようになります。

public class DemoMain {

    public static void main(String[] args) {
        InputModel inputModel = new InputModel();
        inputModel.setId(1);
        inputModel.setName("taro");

        RequestModel requestModel = RequestMapper.INSTANCE.toRequestModel(inputModel);
        System.out.println(requestModel);

        Request2Model request2Model = RequestMapper.INSTANCE.toRequest2Model(inputModel);
        System.out.println(request2Model);
    }

    @Data
    static class InputModel {
        private Integer id;
        private String name;
    }

    @Data
    static class RequestModel {
        private Integer id;
        private String name;
    }

    @Data
    static class Request2Model {
        private Integer no;
        private String title;
    }

    @Mapper
    interface RequestMapper {
        RequestMapper INSTANCE = Mappers.getMapper(RequestMapper.class);
        RequestModel toRequestModel(InputModel inputModel);

        @Mapping(source = "name", target = "title")
        @Mapping(source = "id", target = "no")
        Request2Model toRequest2Model(InputModel inputModel);
    }
}

出力結果

出力結果は下記になります。

DemoMain.RequestModel(id=1, name=taro)
DemoMain.Request2Model(no=1, title=taro)

ざっくり解説

データクラス定義

入力のクラスはこのように定義します。

    @Data
    static class InputModel {
        private Integer id;
        private String name;
    }

出力は、プロパティ名が同じものを用意します。異なったものでも可能なので、そのケースものも用意しておきます。

    // プロパティが同じクラス定義
    @Data
    static class RequestModel {
        private Integer id;
        private String name;
    }


    // プロパティが異なったクラス
    @Data
    static class Request2Model {
        private Integer no;
        private String title;
    }

マッピング処理の定義

マッピング処理はインターフェースに記述します。

    @Mapper
    interface RequestMapper {
        // Interfaceに定数を定義する
        RequestMapper INSTANCE = Mappers.getMapper(RequestMapper.class);

        // プロパティ名が同名の場合のメソッド定義
        RequestModel toRequestModel(InputModel inputModel);

        // プロパティ名が異なった場合のメソッド定義
        @Mapping(source = "name", target = "title")
        @Mapping(source = "id", target = "no")
        Request2Model toRequest2Model(InputModel inputModel);
    }

上記のようにインターフェースにアノテーションで定義を書いていきます。プロパティ名が同名である場合は、アノテーションは不要です。プロパティ名が異なった場合は、その数のアノテーション定義を書くことになります。

RequestMapperインタフェース内にオブジェクトの変換処理がまとめられるので、とても見通しがよくなります。また、ModelMapperよりはるかに高速です。(計測している記事) 毎回インターフェースを定義する必要があるので、ModelMapperよりは少々手間がかかりますが、大量データを処理する場合は、速度的に十分リターンがあると思いますので使ってみてください。

メディア開発では、中途採用をはじめ長期インターンの募集もしております。興味があればぜひお声がけください。

www.wantedly.com

CI/CDについて

エキサイト株式会社 新規事業の開発を担当している森脇です。

エキサイトでは2、3年前からオンプレからクラウドへの移行を行っています。 移行したサービス中心にCI/CDを導入するケースが増えてきています。

私が担当している新規のサービスに関しても開発当初から導入をしています。

CI/CDとは

改めてCI/CDとは、Continuous Integration / Continuous Delivery の略で、日本語だと継続的インティグレーション/ 継続的デリバリー、意味としては自動的にテストをして本番へリリースをする、もしくはリリース可能な状態にしておくことですね

CI/CDを導入すると、バグが減ったり、変更を自動的にリリースしたりすることができとても便利です。

CI/CD構成

f:id:moriwaki111:20210402095607p:plain

GitHub ActionとAWSのCodePipelineを使ってCI/CDを構築しています。 プルリクをフックにして、GitHub Actionでlinttestを実行します、テストがOKだったものに関してコードレビューをするようにしています。 (図では省略していますが、GitHub Actionとマージの間に人為的なレビューがあります)

コードレビューでLGTMになれば、mainブランチにマージされ、AWSのCodePipelineが動いて、本番環境へデプロイされるようになっています。

よかった事

  • バグが減った事
  • 仕様に注力してコードレビューが行える事 (インデントとか使ってない変数があるとかを見なくて良くなった事)
  • 自動で網羅的にテストが行える事

課題

  • フロントのテストでは、apiの通信をmock化しており、レスポンスをjsonで保存し使用している、apiの改修があった時にjsonの更新を手動で行わないといけない

課題はありつつ、CI/CDを導入すると、自分たちも楽できるし、ユーザさんにとっても質の高いサービスを提供できるので、導入がまだ行われていないサービスは是非やる良いと思います

「連想配列」と「ドメインモデル」の違い

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

エキサイトは昔からPHPがよく使われてきましたが、特に古いコードだとその中で連想配列が頻繁に利用されています。 一方で最近ではエキサイト内でもドメイン駆動設計が考慮されることが増え、連想配列ではなくドメインモデルが利用されることが増えてきました。

ここでは、「連想配列」と「ドメインモデル」の違いはなんなのか、簡単に説明していきます。

連想配列とドメインモデルの共通点

連想配列もドメインモデルも、「1~複数のデータを格納する」という役割は共通です。 例えば「記事」のデータだと、タイトル・本文・公開日など複数のデータの複合で成り立っています。 こうしたデータについて、それぞれ別々に変数に入れるのではなく、連想配列やドメインモデルといった形でまとめて保存することで、取り扱いを容易にすることができます。

連想配列

$article = [
    'title' => '記事タイトル',
    'story' => '記事本文',
    'publishDate' => '2021-01-01 00:00:00'
];

ドメインモデル

class Article {
    private $title;
    private $story;
    private $publishDate;
    
    public function __construct($title, $story, $publishDate) {
        $this->title = $title;
        $this->story = $story;
        $this->publishDate = $publishDate;
    }

    public function getTitle() {
        return $this->title;
    }

    public function getStory() {
        return $this->story;
    }

    public function getPublishDate() {
        return $this->publishDate;
    }
}

$article = new Article('記事タイトル', '記事本文', '2021-01-01 00:00:00');

連想配列とドメインモデルの違い

では次に、連想配列とドメインモデルの違いを、それぞれの利点から見ていきます。

連想配列の利点

連想配列の利点は、何と言ってもその使いやすさかと思います。 上記の例を見て分かる通り、同じデータを入れるのでも、ドメインモデルに比べて連想配列を使うほうが圧倒的に簡単にコードを書くことができます。

ドメインモデルの利点

入りうるデータが確定している

連想配列は簡単にコードを書くことはできますが、その反面好きな時に好きなようにデータを入れることができるため、どんなデータがその連想配列に入っているかが分かりにくくなってしまっています。 連想配列を使っている場合、今どんなデータが入っているかを確認するため、要所要所で var_dump を使うという方も多いのではないでしょうか。 その点ドメインモデルは、最初にクラスを作る段階で入れるデータを決めているため、どの時点であっても入りうるデータは確定しています。

IDEの補完が効く

ドメインモデルのようにプロパティやメソッドを通してデータを取得するようにすることで、IDEが補完を効かせてくれます。 連想配列だと文字列でキーを指定することになり、どうしてもtypoや勘違いによるミスが起こりえますが、ドメインモデルで書くことによってそのリスクを大幅に減らすことができます。

ドメインルールを入れることができる

例えば「タイトルは100文字以内でなくてはならない」といったルールがあったとしましょう。 記事データがいろいろなところで使われる場合は、連想配列を使っているといろいろな場所で下記の条件を書く必要が出てくるかもしれません。

// これを、コードのいろいろな場所で書く必要があるかも
if (100 < mb_strlen($article['title'])) {
    // エラー処理を書く
}

その場合、今後「タイトルは120文字以内でなくてはならない」というルールに変わった際は、この条件が書かれているすべての部分を探し出し、修正する必要があります。 エンジニアであれば一度は体験したという方も少なくないと思いますが、これは非常に大変な作業です。

一方ドメインモデルを使うと、モデルそのものにルールを書くことができます。


class Article {
    private $title;
    private $story;
    private $publishDate;
    
    public function __construct($title, $story, $publishDate) {

        // Articleモデルを作成するときにのみチェックすれば良い
        if (100 < mb_strlen($title)) {
            // エラー処理を書く
        }

        $this->title = $title;
        $this->story = $story;
        $this->publishDate = $publishDate;
    }

    public function getTitle() {
        return $this->title;
    }

    public function getStory() {
        return $this->story;
    }

    public function getPublishDate() {
        return $this->publishDate;
    }
}

このように、ドメインモデル生成時に必ずチェックするようにすれば、ルールを書く部分が1箇所だけで済み、仮にルールが変更になっても修正が非常に容易です。

最後に

以上のことから、多少最初書くのが面倒であったとしても、ドメインモデルを使うようにしたほうが利点が大きいのではないでしょうか。 またこれは、ドメイン駆動設計の概念でもあります。 今後新しく連想配列を使う機会があったら、ぜひドメインモデルで書いてみることをおすすめします。

DBのテーブル構造のアンチパターンと改善

みなさんこんにちは。 エキサイトでエンジニアをしているAです。

エキサイト内で過去に一部テーブル構造の見直しを行い、運用コストの効率化を行ったため今回はその一例をご紹介いたします。

最初にテーブル構造からしっかり考える

最初のテーブル構造は非常に大事です。今回は以下の3点を特に重視していきました。

  • テーブル構成は要件に合わせて基本的に細かく分ける
  • 後々拡張しやすい造りにする
  • テーブルを見るだけでなんのデータか把握しやすい形にする

上記は当たり前のことですが、ここを疎かにしてしまうと後々何の為に使うデータか分からなくなったり、データが増えてしまいデータの更新処理に時間がかかったりします。

後付けで謎テーブルを謎ロジックでJOINしたりで、とんでもなく複雑な運用になってしまいがちです。

テーブル定義は時間をかけて行っていったかどうかで運用の手間が全然変わります。

見切り発車で決めるのだけは絶対にやめましょう。

テーブル例

`example_table` (
id,
name,
profile_json,
snsA_profile_json,
snsB_profile_json,
url_information_json,
TypeA_json,
TypeB_json,
TypeC_json,
...
...
...
active_flag,
delete_flag)

実際に存在していたテーブルの一例です。 この場合、カラムが非常に長いため一つのテーブルに大量のカラムを作成せず、分けられる箇所は分けるようにしていきます。

問題点1 データの更新に無駄に手間がかかる

恐ろしい事にこのケースだとデータをSQLに投げる際に、jsonをカラムにそのまま突っ込んでいます。

そのため、既に登録したデータの中身の情報を変えたい時にデータを投げ直す事になり迂闊にデータを変えることができなくなります。

例えばID1のTypeAの情報を一部変更したいなどの要件があった場合、以下の操作をするしか変える手段はありません。

  • jsonの中身を把握してUPDATEをかける
  • データを再度入れ直して上書きする

この場合TypeAの情報を一部分変えるだけでも苦労します。これだけでもう地獄です。

解決策

今回はTypeAはTypeA_json用のデータをまとめたTypeAテーブルなど分けるように変更を加えました。

その後DBから受け取るAPI側でjsonに変換して送るようにする事で、TypeAJsonカラムに影響されることはなく TypeAの情報は単純なUPDATE文一つで更新できるようになりました。

問題点2 状態をカラムを持っている

今回のテーブルにはactive_flagというものが存在します。 active_flagとみたら誰もが「表示、非表示の状態を持つカラムなんだな」と思うかもしれません。

実際にこのテーブルのコメントには「アクティブフラグ」とだけ書いてありました。

パッと見0や1が書いてあるので1が表示で0が非表示なんだなーとなんとなくわかりそうです。

しばらくデータを眺めていると1や0の中に-1や-128という知らない数字が見えました。

どうやらこの表示フラグ、表示のためのフラグなのに4つのカオスな状態を持っているようです。 実際に使われていた箇所を追っていくと、

  • データが入ってきたばかりの状態 => 0
  • 管理画面で公開許可された状態 => 1
  • 管理画面で非許可にされた状態 => -1
  • 削除予定の状態 => -128

と言った形で使われていることがわかりました。

実装当時の状況はわかりませんが、恐らくはじめは1,0で表示管理されていた物が表示要件が新たに出てきたので付け足されていったのだと推測しました。

解決策

結局のところ0,1,-1,-128が要らないので、いっそ分けた方がやりやすいです。

active_flag自体はカーディナリティが低いので、example_tableのactive_flagで状態管理するのをやめました。

activeな状態のIDを下記のようにactiveテーブルに入れることで分かり辛さが解決します。

`example_active` (
id
)

activeにactiveな情報を持つIDだけ入れておけば、テーブルを見るだけでどれが今activeなのかは自明です。

さらに後から追加要件で変な状態を持たせられることもなくなります。

ちなみにアクティブかどうかと、非許可かどうか、削除するかどうかは要件がそもそも別なのでテーブルを分けるべきです。

どうしても値で状態を持たなければならなくなった場合は、当たり前の話ではありますがせめてどの値がどれを示すかのコメントはしっかり残しましょう。

その他、紹介し切れていない改善点などはまだまだありますが ご覧の方々には、最初のテーブル定義にはしっかり時間をかけるという認識を持っていただけたら、後々の運用もやりやすくなる筈なのでこれを機にテーブル構造には時間をかけて考えていただく切っ掛けになれば幸いです。

SQL Serverのdockerコンテナにバックアップ復元する方法(2020)

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

SQL Serverをローカル環境に用意するのにハマったことを記載します。

ユースケース

  1. SQL Serverを使ったローカル開発をしたい。
  2. test環境のデータを、ローカル環境に復元したい。

題材

docs.microsoft.com

1のみの場合、手順通りにやればSQL Serverを使ったローカル開発をすることできます。

しかし、2のtest環境のデータをローカル環境に復元するとき、エラーは出ることがあります。

github.com

理由は、docker-composeのvolumes mountの際、rootユーザになってしまうからです。

それを回避するためにdocker volumeを使いましょう。

docker volume --rm で明示的に削除しない限り、消えることはありません。

以下に、docker-compose.yamlの記載例を表示します。

入力例

docker-compose -f docker-compose-sqlserver.yml up -d

出力例

mcr.microsoft.com/mssql/server:2017-latest   "/opt/mssql/bin/nonr…"   4 days ago   Up 47 hours   0.0.0.0:1433->1433/tcp   tool_sqlserver_1

条件

  • testデータは、既存のtest用DBサーバーからエクスポートする

コード例

version: "3.7"

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2017-latest
    ports:
      - 1433:1433
    environment:
      ACCEPT_EULA: "Y"
      SA_PASSWORD: "abc%ABC%123"
    volumes:
      - "sqlserver-data:/var/opt/mssql/data"
      - "sqlserver-log:/var/opt/mssql/log"
      - "sqlserver-secrets:/var/opt/mssql/secrets"
volumes:
  sqlserver-data:
    driver: local
  sqlserver-log:
    driver: local
  sqlserver-secrets:
    driver: local

Lambda + Goでネストされたアプリケーションを構築

f:id:moriwaki111:20210323212504p:plain

エキサイト株式会社で新規事業の開発を行っている森脇です。

新規事業ではawsを使いシステムの構築を行っています。 当初の計画ではAPIをLambdaで作成する予定になっており開発を進めておりましたが、 幾つか課題が出てきてしまい、その時の話を書こうと思います。

言語はGoを採用し、最初は小規模の認識で開発を行っていたが、仕様がどんどん膨らみ気づいた時にはエンドポイントが150を超え、そしてやってきたリソース制限

Template format error: Number of resources, 206, is greater than maximum allowed, 200

ネストさせることで回避できることを知り、ネストするもsam buildできず、結局ネストされた各アプリケションを個別にビルドし、マージするシェルを書いて運用していました。 苦労した話を書こうと思って調べていると、いつの間にかネストされたアプリケーションのビルドができるようになっていた!!!

f:id:moriwaki111:20210323210711p:plain

前段の話が長くなりましたが、Lambdaでネストされたアプリケーションを簡単に構築できる話です

構築

2つのアプリケーションにネストさせる例です

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  lambda-go
  Sample SAM Template for lambda-go

Parameters:
  Region:
    Type: String
    Default: ap-northeast-1
  Stage:
    Type: String
    Default: Dev
  ApiDomainName:
    Type: String
    Default: api.example.com

Resources:
  # Lambda Application
  App1Application:
    Type: AWS::Serverless::Application
    Properties:
      Location: app1.yaml
      Parameters:
        Region: !Ref Region
        Stage: !Ref Stage
        ApiDomainName: !Ref ApiDomainName
  App2Application:
    Type: AWS::Serverless::Application
    Properties:
      Location: app2.yaml
      Parameters:
        Region: !Ref Region
        Stage: !Ref Stage
        ApiDomainName: !Ref ApiDomainName

アプリケーション1

app1.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  lambda-go
  Sample SAM Template for lambda-go
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 10
    Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object
      Variables:
        REGION: !Ref Region

Parameters:
  Stage:
    Type: String
  Region:
    Type: String
  ApiDomainName:
    Type: String

Resources:
  # ロール
  App1Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: app1-role
      Policies:
        - PolicyName: app1-policy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - ec2:CreateNetworkInterface
                  - ec2:DescribeNetworkInterfaces
                  - ec2:DetachNetworkInterface
                  - ec2:DeleteNetworkInterface
                Resource: '*'
      AssumeRolePolicyDocument: {
        Version: "2012-10-17",
        Statement: [
          {
            Effect: "Allow",
            Principal: {
              Service: [
                  "lambda.amazonaws.com"
              ]
            },
            Action: [
                "sts:AssumeRole"
            ]
          }
        ]
      }
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Path: /

  # Api Gateway
  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage

  # Base path mapping
  ApiGatewayBasePathMapping:
    Type: AWS::ApiGateway::BasePathMapping
    Properties:
      DomainName: !Ref ApiDomainName
      RestApiId: !Ref ApiGateway
      BasePath: app1
      Stage: !Ref ApiGateway.Stage

  # Lambda Function
  Test1Function:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: ../../test1/
      Handler: test1
      Runtime: go1.x
      Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
      Role: !GetAtt App1Role.Arn
      Events:
        GET:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /test1
            Method: GET
  Test2Function:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: ../../test2/
      Handler: test2
      Runtime: go1.x
      Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
      Role: !GetAtt App1Role.Arn
      Events:
        GET:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /test2
            Method: GET
  Test3Function:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: ../../test3/
      Handler: test3
      Runtime: go1.x
      Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
      Role: !GetAtt App1Role.Arn
      Events:
        GET:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /test3
            Method: GET

アプリケーション2

app2.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  lambda-go
  Sample SAM Template for lambda-go
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 10
    Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object
      Variables:
        REGION: !Ref Region

Parameters:
  Stage:
    Type: String
  Region:
    Type: String
  ApiDomainName:
    Type: String

Resources:
  # ロール
  App2Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: app2-role
      Policies:
        - PolicyName: app2-policy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - ec2:CreateNetworkInterface
                  - ec2:DescribeNetworkInterfaces
                  - ec2:DetachNetworkInterface
                  - ec2:DeleteNetworkInterface
                Resource: '*'
      AssumeRolePolicyDocument: {
        Version: "2012-10-17",
        Statement: [
          {
            Effect: "Allow",
            Principal: {
              Service: [
                  "lambda.amazonaws.com"
              ]
            },
            Action: [
                "sts:AssumeRole"
            ]
          }
        ]
      }
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Path: /

  # Api Gateway
  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage

  # Base path mapping
  ApiGatewayBasePathMapping:
    Type: AWS::ApiGateway::BasePathMapping
    Properties:
      DomainName: !Ref ApiDomainName
      RestApiId: !Ref ApiGateway
      BasePath: app2
      Stage: !Ref ApiGateway.Stage

  # Lambda Function
  Test10Function:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: ../../test10/
      Handler: test10
      Runtime: go1.x
      Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
      Role: !GetAtt App2Role.Arn
      Events:
        GET:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /test10
            Method: GET
  Test11Function:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: ../../test11/
      Handler: test11
      Runtime: go1.x
      Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
      Role: !GetAtt App2Role.Arn
      Events:
        GET:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /test11
            Method: GET
  Test12Function:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: ../../test12/
      Handler: test12
      Runtime: go1.x
      Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
      Role: !GetAtt App2Role.Arn
      Events:
        GET:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /test12
            Method: GET

ネスト以外のポイントとして、API GatewayのAPI マッピングを使ってネストされたアプリケーションをマッピングしています。

  ApiGatewayBasePathMapping:
    Type: AWS::ApiGateway::BasePathMapping
    Properties:
      DomainName: !Ref ApiDomainName
      RestApiId: !Ref ApiGateway
      BasePath: app2
      Stage: !Ref ApiGateway.Stage

これをすると、template.yamlで指定している、ApiDomainName(api.example.com)で各ネストされたアプリケーションにアクセスできるようになる 例えば、https://api.example.com/app1/test1 とか https://api.example.com/app2/test10 など *実際に試して頂く場合はApiDomainNameを変更して頂く必要があります

デプロイ

デプロイ時にはcapabilitiesCAPABILITY_AUTO_EXPANDを指定しデプロイをおこないます、成功するとCloudFormationでネストされたアプリケーションが表示されます

f:id:moriwaki111:20210323210818j:plain

ソース

参考までにソースを公開します

https://github.com/akihiro-moriwaki/lambda-nest-go

エキサイトに新卒入社するまでの6ヶ月間のインターンで学んだこと

はじめに

エキサイト株式会社 21年度新卒 山縣と申します。 新卒入社までの6ヶ月間を、内定者としてインターンをしてきました。

本記事では、これまでに学んできたことや、感じてきたことなどを中心にまとめていきます。

21卒のデザイナーが作成した素敵なロゴ

インターン前の技術力

インターンを始める前までは、大学での講義を除き、個人でプログラミングを学んでいました。 当時は、Qiitaの記事やはてなブックマークの記事を見たりしながら、動くものを作ったり、書籍を買ったりしていました。 下記にインターン前の技術力をまとめたとおり、特段強い技術力は持ち合わせていなかったです。

  • Java:学部の講義で1年程触った後、演習科目でAndroidアプリを作成
  • Python:3年程使用しており、少しだけ書ける
  • Go:趣味で少しだけ書いていた
  • JavaScript:Nuxt.jsでのみ使用
  • Nuxt.js:趣味でヘッドレスCMSを使用した技術ブログの作成
  • Git:基本的な使い方がわかる
  • チーム開発経験:なし (他社の短期インターンで雰囲気を掴んだ程度)

Javaの勉強

CMSの再開発をSpringBoot / Javaで行うことが決定された後、SpringBoot / Javaの勉強を始めることになりました。 社内では、これまでPHPで開発が行われてきたため、SpringBoot / Javaの知見が溜まっていませんでした。 そこで、自身の学習と並行して、SpringBoot / Javaの学んだことを社内ドキュメントにまとめていきました。 社内ドキュメントにまとめたものの一例を下記にまとめます。

  • Stream API
  • 関数型インタフェース
  • バリデーション
  • Spring AOP

Streamの簡単な使い方についてまとめたドキュメントの一部です。

社内ドキュメントの例

CMSの再開発

CMSは、フロントエンドをNuxt.js / TypeScriptで開発し、バックエンドをSpringBoot / Javaで開発しています。 フロントエンドの開発とバックエンドの開発を行き来するため、JavaのコードをTypeScriptに書いてエラーが起きてしまい、「あれれ?」となったことも多かったです。

CMSの再開発では、与えられた仕様をもとにコードを書くのではなく、自分の頭を使ってコードを書くことがほとんどでした。 頑張って書いたコードが褒められた時は嬉しかったですし、よりよいコードの書き方を教えていただいた時は「こんな書き方が!」と感動することも多かったです。

また、1つのPRに対して、119件のやり取りが行われたこともあり、熱心にコードレビューをしていただいた社員の方々には感謝の気持でいっぱいです。

1つのPRに119件のコメント

既存のシステムのリビルド

現在は、既存のPHPで書かれたBEAR.Saturdayのコードをもとに、SpringBoot / JavaAPIのリビルドを進めています。 これまでに取り組んできたことと比較すると格段に難易度が上がっており、「仕様がわからない」「処理の内容がよくわからない」といったことが多く、とても大変だと感じています。 つらいことも多いですが、アンチパターンとして受け止めて、今後のソフトウェア開発に活かしていけたらと考えています。

おわりに

以上のように、インターンではJavaの勉強や、CMSの再開発、既存のシステムのリビルドに取り組んで来ました。 6ヶ月間のインターンを通じて、実際にソフトウェア開発の現場で働くことで、チーム開発の取り組み方や、今後どのように働いていくのかのイメージを掴むことができました。 インターン生としての就業は終わり、明日4月1日からは新卒として、働いていくこととなります。

エキサイトでは、自社サービス開発に携わることができます。 エキサイトに興味をもってくださった方は、下記リンクよりお願いします!

www.wantedly.com

ModelMapperが便利

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

SpringBootで開発をしているのですが、データの詰め替えとか割と頻繁にあり、データのマッピング処理が面倒になってきます。そこで、 ModelMapper というライブラリが便利です。(ただしパフォーマンス的には良くないので、パフォーマンスに問題がある場合は、MapStruct等を使ったほうがいいです)

Gradleの設定

Gradleの依存関係を定義します。

dependencies {
     ....
     implementation 'org.modelmapper:modelmapper:2.1.1'
     ....
}

Beanの定義

Beanの定義をします。

`ModelMapperConfig.class`

@Configuration
public class ModelMapperConfig {
    @Bean
    public ModelMapper modelMapper(){
        return new ModelMapper();
    }
}

下記の2つのデータ型をマッピングしたいとします。

`Form.class`
@Data
class Form {
  @NotNull
  private Long id;
  @NotEmpty
  private String name;
}

※ Formクラスは、通常バリデーションの為、バリデーション用アノテーションがついています
`Data.class`

@Data
class Entity {
  private Long id;
  private String name;
}

この2つをマッピングするときに、下記のように実装すると簡単にマッピングできます。

@RestController
@RequestMapping
@RequiredArgsConstructor
public class DemoController {

    private final ModelMapper modelMapper;

    @GetMapping
    public Entity index(Form form) {
        Entity entity = modelMapper.map(form, Entity.class);
        return entity;
    }
}

プロパティ名が同じであることはマッピングの大事な要素ですが、簡単にマッピングすることが可能です。 パフォーマンスはそんなによくないので、バッチ処理等をするときは、MapStruct等を使うことをオススメします。

Nginxでキャッシュをする際の、Cookieに関する注意点

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

皆さんはNginxでキャッシュを使ったことがありますか? レスポンスごとキャッシュをしてくれるので、使いようによっては非常に有効な機能です。

一方で、レスポンスごとキャッシュすることに起因する注意点もあります。

今回は、私がハマったNginxキャッシュとCookieに関連する、とある問題について説明していきます。

Nginxキャッシュとは

Nginxのキャッシュは、Nginxが返すレスポンスをそのままキャッシュしてくれます。 キャッシュがヒットすればアプリケーションコードにアクセスが行かず、Nginxだけで処理が完結するため、サービスのスピードや負荷対策に大きな効果をもたらします。

proxy_cache_path /var/cache/nginx_sample1 levels=1:2 keys_zone=sample1:1m max_size=10m inactive=3m;
proxy_temp_path  /var/cache/nginx_tmp;

server {
    listen 80;
    server_name sample;

    location / {
        proxy_http_version 1.1;

        proxy_cache_valid 200 301 302 1m;
        proxy_cache sample1;

        root /var/sample/;
    }
}

ただしこの設定だと、Cookieに関連するとある事項から想定通り挙動しないことがあります。

NginxキャッシュとCookie

Nginxは、 Set-Cookie ヘッダがレスポンスに含まれる場合、デフォルトではそのレスポンスをキャッシュしてくれません。 このような時、 proxy_ignore_headers を使えば、 Set-Cookie ヘッダがあってもキャッシュしてくれます。

proxy_cache_path /var/cache/nginx_sample1 levels=1:2 keys_zone=sample1:1m max_size=10m inactive=3m;
proxy_temp_path  /var/cache/nginx_tmp;

server {
    listen 80;
    server_name sample;

    location / {
        proxy_http_version 1.1;

        # この設定で、Set-Cookieがあってもキャッシュするようにする
        proxy_ignore_headers Set-Cookie;

        proxy_cache_valid 200 301 302 1m;
        proxy_cache sample1;

        root /var/sample/;
    }
}

これで Set-Cookie ヘッダがあってもキャッシュしてくれるようになるわけですが、この設定によって問題が発生することがあります。

Nginxのキャッシュではレスポンスをそのまま返します。 それは、ヘッダにある Set-Cookie も同様です。 すなわち、Cookieの設定自体もキャッシュします。

Cookieで機密性のあるデータを取り扱っていなければ問題ありませんが、セッション情報などを載せて通信している場合、 Set-Cookie ごとキャッシュすると本来のユーザ以外にもセッション情報を送ってしまうことになり、サービスとしての動作はもちろんセキュリティ的にも問題が発生してしまいます。

上記のような問題を避けるため、キャッシュを使う際は proxy_hide_header を使って Set-Header をレスポンスに含めないようにすると良いです。

proxy_cache_path /var/cache/nginx_sample1 levels=1:2 keys_zone=sample1:1m max_size=10m inactive=3m;
proxy_temp_path  /var/cache/nginx_tmp;

server {
    listen 80;
    server_name sample;

    location / {
        proxy_http_version 1.1;

        proxy_ignore_headers Set-Cookie;

        # この設定で、Set-Cookieをレスポンスから外す
        proxy_hide_header Set-Cookie;

        proxy_cache_valid 200 301 302 1m;
        proxy_cache sample1;

        root /var/sample/;
    }
}

これによってCookieに関するセキュリティのリスクが回避できるのですが、それによってさらに別の問題が発生します。

NginxキャッシュとCookieは実質併用できない

サーバからブラウザにCookieを保存する際は、 Set-Cookie ヘッダを通して設定します。 しかし、上記のように Set-Cookie ヘッダをレスポンスに含めないように設定すると、サーバからブラウザに対してCookie設定をすることができません。 その結果、アプリケーションコード上ではCookie設定をしているはずなのに、ブラウザのCookieを見てみると正しくCookieが設定されていない、という状況が発生します。

とはいえセキュリティの観点からキャッシュ中は Set-Cookie ヘッダは返すわけには行かないので、Set-Cookie ヘッダがある場合もキャッシュしたい場合のキャッシュ使用中は、実質Cookieを保存することはできないと言っていいでしょう。

まとめ

Nginxで Set-Cookie ヘッダがある場合もキャッシュを利用する際は、

  1. セキュリティの観点から、レスポンスから Set-Cookie は外したほうが良い
  2. 結果として、サーバからブラウザに対してCookieの保存処理をすることができなくなる

ことに注意する必要があります。

このことから、Nginxのキャッシュを利用する場合、画像やCSS、JavaScriptなどの静的ファイル、あるいはCookieなどの状態を持たない非常にシンプルなWebページを対象とするのがよく、状態を持ちうるWebページを対象とするのには不向きであると言えます。 そういったページに対しては、Redis等を使ったコンテンツキャッシュを使うことをまず考え、どうしようもない時のみNginxのキャッシュを使うことを考えるのが良いでしょう。

参考

Nginx プロキシキャッシュでクッキーがついていてもキャッシュするには

SpringBoot + MyBatisのログをIntelliJで見やすく出力する方法

エキサイト株式会社 メディア事業部テクノロジー&デザイン 統括の佐々木です。

最近、Spring/Javaでメディア内の各サービスのリビルド開発を行っています。SpringBoot + MyBatisで開発を行うことが多いのですが、単なるログから実行可能なSQLを組み立てるのは割と労力がかかります。

下記のクエリは、とあるテーブルから book_id = 1 のデータを出力するときのクエリになります。

2021-03-28 17:53:21.760 DEBUG 4955 --- [oundedElastic-1] c.e.d.p.mappergen.BookMapper.selectOne   : ==>  Preparing: select book_id, created_at from book where book_id = ?
2021-03-28 17:53:21.761 DEBUG 4955 --- [oundedElastic-1] c.e.d.p.mappergen.BookMapper.selectOne   : ==> Parameters: 1(Long)

これだと、SQLとBindParamを自分で組み立てないと、実行されるSQLがわかりません。

IntelliJ mybatis-log プラグイン

log4jdbcを使ってるならそれでもいいかと思うのですが、IntelliJを使っているならmybatis-logプラグインを使うと、実行できるSQLが出力されます。これはかなり便利です。

plugins.jetbrains.com

別のタブでクエリのログのみ出力してくれるので、見やすく便利です。

f:id:earu:20210328182240p:plain

## BEGIN  name=WebApplication seq=00000004 time=2021-03-28 17:53:22
SELECT book_id, created_at
FROM book
WHERE book_id = 1
## END 

MyBatis + IntelliJを使ってる方で、これを使ってない方は入れてください。生産性があがるかと思います。

Javaで一括文字列変換

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

最近Javaで大量の文字列を一括置換する処理作ったので紹介します。

性能部分にちょっと不安がありますが。。。

wikipediaでも自動で特定のワードに対してリンクになりますが、イメージはあんな感じです。

ユースケース

特定の文字列がはいったら、youtubeの検索のリンクの変更にしたい。

入力例

 自動でyoutubeのリンクに変わります。例えばうどん、カレーです。

出力例

 自動でyoutubeのリンクに変わります。例えば<a href="https://www.youtube.com/results?search_query=うどん">うどん</a>、<a href="https://www.youtube.com/results?search_query=カレー">カレー</a>です。

条件

  • hrefにするときは、置換文字列にする
  • 置換文字列はすべて同じ処理で構わない。
  • 簡単に追加できるように
  • いったん固定文字列で構わない。
    • 「いったん」というワードを聞くと、大体あとから拡張される。

コード例

置換文字列マッピング表(DB使わない)

置換対象文字列設定

enumで事前に置換対象文字列を()で囲みます。 staticで事前にPatternを|区切りでコンパイルしているものを設定します。 あえて、説明することはないかもしれませんが、Pattern.compileを事前にstaticで宣言しておくことで 毎回コンパイルが走らなくてすみます。 WORDを増やすことで、置換対象が変わります。 このコード自体はいつでも捨てられるように実装します。

import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public enum WordTargetType {

    WORD_1("(うどん)"),
    WORD_2("(おから)"),
    WORD_3("(カレー)"),
    WORD_4("(グラタン)"),
    WORD_5("(スパゲティ)"),
    WORD_6("(素麺)"),
    WORD_7("(チャーハン)"),
    WORD_8("(佃煮)"),
    WORD_9("(トマトソース)"),
    WORD_10("(パスタ)"),
    ;

    private String word;

    private static Pattern wordTargetPattern;

    static {
        wordTargetPattern = Pattern.compile(Stream.of(WordTargetType
                .values())
                .map(e -> e.getWord())
                .collect(Collectors.joining("|")));
    }


    WordTargetType(String word) {
        this.word = word;
    }

    public String getWord() {
        return this.word;
    }

    public static Pattern getPatternCompile() {
        return wordTargetPattern;
    }
}

ドメインモデル

newするときに、置換対象文字列を設定すると、replaceTextに置換後のテキストが作成されます。 イミュータブルなモデルにするために@Getterをセットすることにします。 replaceAllを使うことで、対象の文字列が一気に変わります。

import com.exblog.core.config.WordTargetType;
import lombok.Getter;
import lombok.experimental.Accessors;

@Getter
@Accessors(chain = true)
public class WordDomainModel {

    private final static String LINK = "<a href=\"https://www.youtube.com/results?search_query=$0/\">$0</a>";

    private String replaceText = "";

    public WordDomainModel(String text){
        this.replaceText = WordTargetType
                .getPatternCompile()
                .matcher(text)
                .replaceAll(LINK);
    }

    
    /**
     * DBからコンパイルのパターンを取得する場合は、引数にパターンを増やしてnew でオブジェクトを作れば良い。
     */
    public WordDomainModel(String text, Pattern pattern){
        this.replaceText = pattern
                .matcher(text)
                .replaceAll(LINK);
    }
}

使用例

以下のように使用すると、シンプルでみやすいと思います。

    public String getWrappedWord(String text) {
        return new WordDomainModel(text)
                .getReplaceText();
    }

UT

注意点として、置換する処理はどのプログラミングを使っても性能(レスポンス速度)が遅いので、 どこまで許容するかはプロジェクトによりますが、以下の例では500msいかになるようなテストにしています。 十分な長さの文字列の変換を試して下さい。

        long start = System.currentTimeMillis();
        final WordDomainModel wordDomainModel = new WordDomainModel("うどん おから");
        long end = System.currentTimeMillis();
        Assertions.assertFalse(wordDomainModel.getReplaceText().isEmpty());
        Assertions.assertTrue(end - start < 500);

エキサイトは「PHPerKaigi 2021 」に協賛します

エキサイトは「PHPerKaigi 2021」にシルバースポンサーとして協賛します。

一昨年(PHPerKaigi 2019)、昨年(PHPerKaigi 2020)に引き続き、今年もスポンサードすることができ光栄です!

PHPerKaigi 2021について

開催日時 : 2021年3月26日(金)〜3月28日(日)

phperkaigi.jp

それでは皆さま、PHPerKaigi 2021を一緒に楽しんでいきましょう!

Springboot + OpenFeignを使ってインターフェースだけでRestClientを作る

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

RestTemplateやWebClientを使っていましたが、インターフェースだけでRestClientを作れるOpenFeignを試してみます。

Gradleの設定は下記を追加する。

dependencies {
    ...
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
    ...
}

RestClientの実装はインターフェースを定義するだけになります。

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestMapping;

@FeignClient(value = "weatherApi", url = "${weather.url}")
public interface WeatherApi {

    @RequestMapping(value = "bosai/forecast/data/overview_forecast/{cityCode}.json")
    WeatherData getWeather(@PathVariable("cityCode") String cityCode);
}

変数部分は、 application.yml に記載します。

weather.url = https://www.jma.go.jp/

データクラスは、いつもどおりLombokを使って作ります。

import lombok.Data;

import java.time.ZonedDateTime;

@Data
public class WeatherData {

    private String publishingOffice;
    private ZonedDateTime reportDatetime;
    private String targetArea;
    private String headlineText;
    private String text;
}

RestControllerクラスから、DIを使用して呼び出せば使用できます。

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("weather")
@RequiredArgsConstructor
public class WeatherController {

    private final WeatherApi zipcodeAPI;

    @GetMapping
    public Object index(@RequestParam(value = "cityCode",required = false) String cityCode){
        return zipcodeAPI.getWeather(cityCode);
    }

}

RestTemplateやWebClientを使っていましたが、これくらい簡単なら選択肢に入りそうですね。

mybatisで複数のデータソースを設定する方法について

初めまして

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

最近Javaを書くことが多いので、学んだことを記載していこうと思います。 よろしくお願いいたします。

MyBatisでデータソースを切り変える

弊社では、データベースとJavaを繋ぐlibraryにMyBatisを使用しています。 通常通り使えばそんなに罠はないのだが、 以下のユースケースが現場で発生したので工夫して実装しました。 その例を重要な部分は伏せながら説明します。 ※実装方法の一例であり、現在はもう少しスマートになっております。

ユースケース

話すと長くなるので省略しますが、負荷対策で複数のスキーマにシャーディングしているサービスがあります。 useridごとに分かれているので、MyBatisを使って自動的に振り分けられるようにライブラリーを作成する。

条件

  • データベース「スキーマA」「スキーマB」が存在する
  • どちらのデータベースも同様のTBLが存在する
  • useridが 1000000 - 1999999の場合「スキーマA」、2000000 - 2999999の場合「スキーマB」のユーザ情報が入っている。
  • 3000000以上の場合、「スキーマC」が作成される。

コード例

データソース 設定

package com.sample.database;

import javax.sql.DataSource;

import org.apache.ibatis.scripting.defaults.RawLanguageDriver;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class MySqlSessionFactory {

    public static final String DATASOURCE_PROPERTY_A = "datasource-property-a";
    public static final String DATASOURCE_PROPERTY_B = "datasource-property-b";

    /**
     * 1.
     * datasourceの設定をapplication.yamlから取得して、bean化して登録する。
     * DataSourcePropertiesの型が複数存在する場合、spring側がどれを読むかわからないため、片方に@Primaryをつける
     */
    @Bean(name = {DATASOURCE_PROPERTY_A})
    @Primary
    @ConfigurationProperties(prefix = "spring.datasource.a")
    public DataSourceProperties propertiesA() {
        return new DataSourceProperties();
    }

    @Bean(name = {DATASOURCE_PROPERTY_B})
    @ConfigurationProperties(prefix = "spring.datasource.b")
    public DataSourceProperties propertiesB() {
        return new DataSourceProperties();
    }

    private SqlSessionFactory createSqlSessionFactory(DataSource dataSource) throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);
        SqlSessionFactory sqlSessionFactory = sqlSessionFactoryBean.getObject();
        sqlSessionFactory.getConfiguration().addMappers("com.sample.database.mapper");
        sqlSessionFactory.getConfiguration().addMappers("com.sample.database.mappergen");
        sqlSessionFactory.getConfiguration().setCacheEnabled(false);
        sqlSessionFactory.getConfiguration().setDefaultScriptingLanguage(RawLanguageDriver.class);
        sqlSessionFactory.getConfiguration().setMapUnderscoreToCamelCase(true);
        return sqlSessionFactory;
    }

    public static final String SQL_SESSION_A = "sql-session-a";

    /**
     * 2.
     * sqlSessionFactoryも同様に、片方に@Primaryをつける
     * 共通設定は別にprivateメソッド作成する
     */
    @Bean(name = {SQL_SESSION_A})
    @Primary
    public SqlSessionTemplate sqlSessionFactoryA(@Qualifier(MySqlSessionFactory.DATASOURCE_PROPERTY_A) DataSourceProperties properties) throws Exception {
        final SqlSessionFactory sqlSessionFactory = createSqlSessionFactory(properties.initializeDataSourceBuilder().build());
        return new SqlSessionTemplate(sqlSessionFactory);
    }

    public static final String SQL_SESSION_B = "sql-session-b";

    @Bean(name = {SQL_SESSION_B})
    public SqlSessionTemplate sqlSessionFactoryB(@Qualifier(MySqlSessionFactory.DATASOURCE_PROPERTY_B) DataSourceProperties properties) throws Exception {
        final SqlSessionFactory sqlSessionFactory = createSqlSessionFactory(properties.initializeDataSourceBuilder().build());
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

データソースをapplication.yamlで設定し、sqlSessionFactoryを使ってSqlSessionTemplateを接続先ごとにbean化する。 接続先が増えると、beanが増えていくイメージ。 共通のテーブルが存在するので、mapperは共通のディレクトリを参照する。

データベース切り替え用リゾルバーインターフェース

package com.sample.database;

import org.mybatis.spring.SqlSessionTemplate;

public interface MySqlSessionTemplateResolver {
    SqlSessionTemplate getSqlSessionTemplate(long userid);
}

データベース切り替え用リゾルバー実体

package com.sample.database;

import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
public class MySqlSessionTemplateResolverImpl implements MySqlSessionTemplateResolver {

    private SqlSessionTemplate sqlSessionFactoryA;

    private SqlSessionTemplate sqlSessionFactoryB;

    public MySqlSessionTemplateResolverImpl(@Qualifier(MySqlSessionFactory.SQL_SESSION_A) SqlSessionTemplate sqlSessionFactoryBoard,
                                            @Qualifier(MySqlSessionFactory.SQL_SESSION_B) SqlSessionTemplate sqlSessionFactoryBoardA
                                            ) {
        this.sqlSessionFactoryBoard = sqlSessionFactoryBoard;
        this.sqlSessionFactoryBoardA = sqlSessionFactoryBoardA;
    }

    @Override
    public SqlSessionTemplate getSqlSessionTemplate(String userid) {

        MySqlSessionTemplateType mySqlSessionTemplateType = MySqlSessionTemplateType.getByBlogid(userid);

        switch(mySqlSessionTemplateType) {
            case BOARD_A:
                return this.sqlSessionFactoryBoard;
            case BOARD_B:
                return this.sqlSessionFactoryBoardA;
            default:
                throw new RuntimeException();
        }
    }
}

データベース切り替え用列挙型

package com.sample.database;

import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public enum MySqlSessionTemplateType {

    BOARD_A(1),
    BOARD_B(2),
    ;

    private int prefix;

    private static Map<Integer, MySqlSessionTemplateType> mySqlSessionTemplateTypes;

    static {
        mySqlSessionTemplateTypes = Stream.of(MySqlSessionTemplateType
                .values())
                .collect(Collectors.toMap(f -> f.getPrefix(), f -> f));
    }


    MySqlSessionTemplateType(int prefix) {
        this.prefix = prefix;
    }

    public int getPrefix() {
        return this.prefix;
    }

    public static MySqlSessionTemplateType getByBlogid(String userid) {

        final String substring = userid.substring(0, 1);
        MySqlSessionTemplateType type = mySqlSessionTemplateTypes.get(substring);
        if (Objects.isNull(type)) {
            throw new RuntimeException();
        }
        return type;
    }
}

ゾルバーから接続先をenumとして登録したクラスからuseridのプレフィックス一桁を取得し、 その接続先を設定する。

使い方

mySqlSessionTemplateResolver
.getSqlSessionTemplate(userid)
.getMapper(UserInfoMapper.class)
.selectOne(~~~~~~~~~~

とMapperを呼び出す前にgetSqlSessionTemplate(userid)で指定すれば、useridによって自動でスキーマの接続先が切り替わります。 SqlSessionTemplateはSpring が管理するトランザクション内で実行され、また Spring によってインジェクトされる複数の Mapper から呼び出すことができるようにスレッドセーフとなっているので try catchで囲む必要もありません。