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で囲む必要もありません。

ごあいさつ

エキサイト株式会社の佐々木です。

本日から、エキサイト株式会社の技術ブログの運営を開始いたします。

f:id:earu:20210323231325j:plain

エキサイト株式会社の経営体制変更後、ビジネス的にもデザイン的にも技術的にも様々な面で改革が進んできており、弊社での試行錯誤をしている技術の紹介や制度や環境等について、情報をオープンにし共有していきたいと思っております。

以前のブログより、更新頻度をしていく所存でございますので、よろしくお願いいたします。

今後ともよろしくお願いいたします。