【GORM】V1からV2へアップグレードにチャレンジした話

f:id:kaminashi-developer:20210803192112j:plain こんにちは、株式会社カミナシのエンジニア @imu です。

はじめに

弊社のアプリケーションのバックエンドはGoでDBアクセスライブラリはGORMを使っています。
サービスリリース時からGORMを使っており、V1からV2にすることでパフォーマンス改善につながると思い、アップグレードにチャレンジしました。
色々詰まるところがあったので、共有を含めて何をやったのか書いてみます!

結論

早速結論ですが…
GORM V2へのアップグレードは現時点で保留となりました...
保留にした理由は以下の通りです。

  • V2で仕様変更になった関数が多く影響範囲が広い
  • V2で廃止された関数があり代替案の検証が必要である
  • そもそもAPIのテストが完全でないのでアップグレードが怖い

APIの網羅テストがあれば思い切った変更ができたのですが、完全ではないので一旦網羅テストを書いてからアップグレードすべきという結論に至りました。

ここから先はどういった変更対応をしたのかを共有したいと思います。
一部未確認の箇所がありますのでご了承ください。

対応内容

Logger, Datadog Trace, サードパーティ製のBulkInsertも変更しておりますが、あまり記事で見かけない対応を共有したいと思います。

  • PrimaryKeyの指定方法を変更
// V1
`gorm:"primary_key" json:"id"`

// V2
`gorm:"primaryKey" json:"id"`
  • jointable_foreignkey, association_jointable_foreignkeyの指定方法を変更
// V1
`gorm:"jointable_foreignkey:hoge;association_jointable_foreignkey:huga;" json:"ids"`

// V2
`gorm:"joinForeignKey:hoge;joinReferences:huga;" json:"ids"`
  • foreignkey, association_foreignkeyの指定方法を変更
// V1
`gorm:"foreignkey:hoge;association_foreignkey:huga;" json:"id"`

// V2
`gorm:"foreignKey:hoge;references:huga;" json:"id"`

詳細はこちらを確認してください。
gorm.io

  • V2でRecordNotFound()判定

エラーハンドリング方法が変わっているため、自前で共通関数を作成し、errorオブジェクトを渡して判定するようにしました。

import (
    "errors"

    "gorm.io/gorm"
)

func RecordNotFound(err error) bool {
    return errors.Is(err, gorm.ErrRecordNotFound)
}

gorm.io

  • First, Findのエラーハンドリング

RecordNotFound()メソッドがV2から廃止になったことで、エラーハンドリングで注意が必要なケースがありました。

// V2
var db *gorm.DB
user := &orm.User{}
...
err := db.Where("uuid = ?", library.UUID()).First(user).Error
pretty.Println(err)
pretty.Println(errors.Is(err, gorm.ErrRecordNotFound))

> &errors.errorString{s:"record not found"}
> bool(true)

user := &orm.User{}
err := db.Where("uuid = ?", library.UUID()).Find(user).Error
pretty.Println(err)
pretty.Println(errors.Is(err, gorm.ErrRecordNotFound))

> nil
> bool(false)

.Find().Errorとした場合、gorm.ErrRecordNotFoundにならないので注意してください。
.First().Errorにメソッドを変更するか、nilならNotFoundにするようにしましょう。

  • テーブル名取得
// V1
var db *gorm.DB
var st orm.Users
...  
pretty.Println(db.NewScope(st).TableName())

> users

// V2
var db *gorm.DB
var st orm.Users
...
stmt := &gorm.Statement{DB: db}
stmt.Model = st
stmt.Parse(stmt.Model)
pretty.Println(stmt.Table)

> users

V2でテーブル名を取得する処理は、Execute()時点で何かしらしていると踏んでコードを読んで書きました。 f:id:kaminashi-developer:20210802190729p:plain

  • カラムチェック
// V1
var db *gorm.DB
var st orm.Users
...  
pretty.Println(db.NewScope(st).HasColumn("Hoge"))

// orm.UsersにHogeカラムがあればtrue
> bool(true)

// V2
var db *gorm.DB
var st orm.Users
...  
pretty.Println(db.Migrator().HasColumn(st, "Hoge"))

> bool(true)
  • QueryExpr
// V1
var db *gorm.DB
var st orm.Users
...  
pretty.Println(db.Table("sample").Where("id = 1").QueryExpr())

> &gorm.SqlExpr{
    expr: "SELECT * FROM `sample`  WHERE (id = 1)",
    args: nil,
}

// V2
var db *gorm.DB
var st orm.Users
...  
pretty.Println(db.Table("sample").Where("id = 1"))

> V1の処理はサブクエリだと思っているのでコレで大丈夫なはず…(未確認です)

おわりに

個人的にはやりきりたいと思って取り組みましたが、思いの外時間が掛かりすぎるのと、V2に変更したときの安心感(テスト)がないので保留という着地をしました…。
限られたリソース内で出来なかった悔しさを晴らすために(?)、弊社では一緒に働くメンバーを幅広く募集しております!

特に…

  • 一つの課題に向き合うことが好きな方
  • 難易度が高い課題が好きな方
  • 作ったモノがどういった使われ方をしているのか興味があり、現場に行きたい方

です!

興味がある、話を聞いてみたい、応募したいという方はお気軽にご応募ください!

herp.careers

PMの自分が如何にユーザーの声をエンジニアの共通言語へ変えたか

f:id:kaminashi-developer:20210707104815p:plain 初めまして。株式会社カミナシでPMをやっているGTOです。
先日6/30に、マネーフォワードさんと合同で勉強会を実施しました!当日お集まりいただいた皆様、ありがとうございます。
登壇者として出席させていただき、「PMの自分が如何にユーザーの声をエンジニアの共通言語へ変えたか」というタイトルで発表しました。

カミナシでも、紙というツールを介して長年使われ続けたそのフローをいかにプロダクトで解決していくのかは大きなテーマの一つです。
そんな中でユーザーさんへの価値をどう作り、またプロダクトチームへどう伝えていったのかを話してきました。

当日の登壇でお話し出来なかった部分もあるので、一部掘り下げてこの記事内で紹介します!

登壇資料

登壇の概要

お客さんへの価値をどのように見つけ、チームと対話をし伝えるのかは最近の自分の悩みごとでした。

  • お客さんへの価値をどのように見つけ => 仮説検証
  • チームと対話 => チーム間のコミュニケーション

「使われる」プロダクトを「最小単位」でどうやって作ったらいいんだろうね?と模索の過程を話しています。

仮説のアイデア出し

仮説を作る前にまず、デザインスプリントを使いアイデアを集約させます。
MTGを毎回行うより、討論しながら一緒に知識の獲得とアイデア出しを行えるメリットがあります。

www.gv.com

通常デザインスプリントは1週間をかけてサイクルを回し切ることが多いのですが、1日で背景揃える/課題の整理/アイデア出しをやっています。
ビジネス含め、全員が揃うタイミングもなかなか合わせづらい部分もあるのでギュッと凝縮してやります。

ここはいろんな背景のメンバーを少人数集めるのが重要かなと思っていてます。後、アイデア出しなので楽しくやるのも結構大事。

f:id:kaminashi-developer:20210706085734p:plain
妄想設定作りながら、ワイワイやってる

実際に背景を揃える部分では、動画のイメージやソリューション, 戦略を共有したり、人物像が複雑に入り組んでいるものは登場人物の関係図を整理したりします。

f:id:kaminashi-developer:20210704135413p:plain
動画のイメージやソリューション, 戦略を共有
f:id:kaminashi-developer:20210704135752p:plain
登場人物の関係図を整理

手法自体は、デザインチームが主導でファシリをしたりしますが、ビジネスとプロダクトをうまく役割分けて準備をします。
それぞれの知識や手段に偏りがあればあるほど、議論としては極端に傾きいい意見が出て良いです。

f:id:kaminashi-developer:20210707105121j:plain
「そもそもこのお客さんのフローをなくしてしまう」、「AIで紙無くす」、「ハサミで切るように関係性を断ち切るUI」などが上がった

実現可否は一旦横に置き、アイデアを広げる方が思いつかなかった発想につながって良い議論が出来るのでオススメです。
出したアイデアを元に、仮説をテーブル上に並べて取り組むべき課題を選択し、前に進めていきます。

身銭を切る仮説を作る

f:id:kaminashi-developer:20210703160744p:plain

仮説を選ぶ時に意識するのは、「そのアイデアに身銭を払ってくれる」仮説かどうかだと思います。

www.amazon.co.jp

「身銭」は、本気で取り組むというあかしになり、必要な準備が整い、困難があっても投げ出さないという宣言にもなる
重要なのは相手の意見や予想ではない。あなたの製品に対し、ターゲットとなる人たちが切ってくれる「身銭」なのだ!

Google×スタンフォード NO FLOP! 失敗できない人の失敗しない技術』 アルベルト・サヴォイア 著 石井 ひろみ 訳 サンマーク出版

身銭というものは、お互いの本気で取り組むための制約です。
作り手と受け取り手のどちらもが目の色を変えていく必要があり、それは身銭によってより良い緊張感が生まれます。

なのでプロダクトを作りきる前に、アイデアや仮説から作ったプロトタイプを提げて売りに出します。
ここはデザイナーの力を借りて、アイデアを形にしていきます。

f:id:kaminashi-developer:20210706084037p:plain
デザイナーとプロトタイプ作っていく

実際に売ってみると、仮説自体の筋が良かったのかの判断がつきやすくなります。
売る前は良さそうだなと思ったものも、商談にいざ出してみると全くだめなこともあったりします。
外部に評価をされる中で大きく外し、次の手を高速で考え出しまた次に活かすこと。
失敗をすることも、学びに繋がることもたくさんあるので、ここは執着して当てるまで作りこみます。

f:id:kaminashi-developer:20210704133017p:plain
ダブルダイヤモンド的に思考を交差させる

仮説作って事実を集めて失敗し、また次のより良い仮説を作る。
発散と収束を繰り返し思考を交差させながら売りに行き、次の仮説を作っていくのが良いかなと思います。

体験をチームへ共有する

イデアや仮説や作るものも徐々に決まり、チームへ伝えていきます。
お客さんの体験をチームへどう伝えるのかの部分は、見て感じたお客さんの行動を元に整理していきます。

f:id:kaminashi-developer:20210704152508j:plain
行動を整理する
goodpatch.com

僕らはKA法に近い手法でユーザーの行動や感情から、価値を抽出し価値マップを作成します。
ユーザーで伝えたいような体験となるものになるので、設計/関係性/背景を残すようにします。

f:id:kaminashi-developer:20210704153153p:plain
MVP

機能単位では、ユーザーストーリーマッピングをしていきながら、Minimum Viable Product(最初のリリースで達成したいこと)では何を作るべきなのかを残しています。
MVPで意識するべきことは、最小の機能で、価値を最大のに出来る体験はどんなものかを定義するだと思います。

medium.com

いかに最小の労力で、一番初めのお客さんに愛してもらえるような体験をどう作るのかが観点で、単一の機能を作ることだと曲解しないようにする必要があります。
ここが誤ってしまうと、お客さんに本来提供したい運用フローが機能不足で本来提供したい価値が伝わりきらないことがあります。
それは正しい検証が出来たとは言えません。
機能ではなく本来提供したい体験や価値を実現できる最小限の機能なので、変に削りすぎないことは割と大事な観点だったりします。

まとめ

仮説検証, チーム間のコミュニケーションを正しく行うためにも、チームが正しくお客さんの価値へ向き合うことが重要だよね!ということを登壇の中で伝えてきました。

開発もなるべく作ったものがお客さんに使われて喜ばれる姿を一緒にワイワイ共有したいし、失敗した時には一緒になんで失敗したのかを真剣に振り返っていきたい。
スタートアップで僕らもまだまだ未知なことも多いし、失敗も多く重ねていきます。
そんな失敗をチームが修正できる仕組みが最高ですし、そんな強い組織を作りたい気持ちです。
仕組みを徐々に整えることで、少しでもチームが自律的にお客さんへ向かうことが重要だな、もっとやれることもあるなと振り返る登壇となりました。
事業の成長に向けてより一層引き締めていきたい所存です!

最後に宣伝です。

カミナシでは仮説検証を始めとして、他にも事業が前にすすむような技術的にも事業的にもチャレンジが出来る企業です。
幅広く全職種で募集しています。エントリーお待ちしております!!

herp.careers

では👋

GolangでのAPI GatewayとLambdaを利用した認可設定をやってみる

f:id:kaminashi-developer:20210625112557j:plain

こんにちは。エンジニアの @Taku です。

期間が空いてしまいましたが、過去にLambda オーソライザーを使った認証を作成した続きで、今回は認可の設定を行っていきたいと思います。

※本記事の内容はプロダクトと関係なく、個人的にやってみたものになります。

前回までにやったこと

前回までで、

API GatewayとLambdaオーソライザーを利用した認証の作成 kaminashi-developer.hatenablog.jp

をやった後、

② ①の認証をAWS Samを用いて構築 kaminashi-developer.hatenablog.jp をやってみました。

よろしければ上記もご参照いただけますと幸いです。

今回やること

今回は認可の設定として、ログイン後のリソースのアクセス制御を行っていきます。

認可の設計パターンにつきましては、以下記事を参考にさせていただきました。 please-sleep.cou929.nu

これまでの実装のパターンとしては、上記記事でのJWT 利用 + API Gateway パターンが近いかと思いまして、当てはめると - TokenStore = Firebase Auth - AuthService = Lambda Authorizer関数 - Service X = SAMで定義したLambda関数 になるかと思います。

認可の設計パターンについてはいくつか考えられますが例として、

  1. サービス側で権限を制御するパターン
  2. 中央管理するパターン

があり、これがベストという方式はないようなのですが、今回はAPI GatewayとLambdaオーソライザーを利用するため、2の中央管理するパターンで実装していきます。

認可の設定

アクセス制御のパターンは、以下3ユーザーで

  1. 管理者(admin) :全リソースにアクセスできる
  2. リーダー(leader):リーダー及び、他のユーザーのリソースにアクセスできる
  3. ユーザー(user) :自身の情報にのみアクセスできる

といった形で制御したいと思います。

API GatewayとLambda オーソライザーを利用した認可の方法

以後、過去からの続きで恐縮ですが、サンプルを元にGolangでLambdaオーソライザーの関数を作成した場合、 IAM policyを作成する箇所でユーザー毎にアクセスできるリソース(パス)を制御することができます。

具体的には events.APIGatewayCustomAuthorizerResponseを作成している箇所で、 events.APIGatewayCustomAuthorizerPolicyResource で、ポリシーを付与されたユーザーはここに記載されているパスにのみアクセスすることができます。

// generatePolicy IAM policyを生成する
func generatePolicy(principalId, effect string, resources []string) events.APIGatewayCustomAuthorizerResponse {
    authResponse := events.APIGatewayCustomAuthorizerResponse{PrincipalID: principalId}

    if effect != "" && len(resources) > 0 {
        authResponse.PolicyDocument = events.APIGatewayCustomAuthorizerPolicy{
            Version: "2012-10-17",
            Statement: []events.IAMPolicyStatement{
                {
                    Action:   []string{"execute-api:Invoke"},
                    Effect:   effect,
                    Resource: resources, // アクセスOKなパス
                },
            },
        }
    }
        return authResponse
}

ですので、 generatePolicyを利用してポリシーを作成する際、各権限別にアクセス可能なパスを設定することでアクセス可能なリソースを制御することができます。

実装

続いて具体的な設定です。

API Gatewayの設定

本題から外れるため詳細は省く&設定も極小にしておりますが、前回のSAMのtemplate.yamlを修正し、

  • GET: /admin (管理者のページ)
  • GET: /leaders (リーダーのページ)
  • GET: /users/XXXXXXXX (一般ユーザー個人のページ)

というリソースを追加しました。

前回のAWA SAMを使った構築で作成した template.yamlを以下のように修正します。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description:  Sample API Gateway Lambda Authorizer by Golang.
Resources:
  SampleRestApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: dev
      Description: "This is Sample Rest API"
      Cors:
        AllowMethods: "'GET, OPTIONS'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
      Auth:
        DefaultAuthorizer: MyLambdaAuthorizerFunction
        Authorizers:
          MyLambdaAuthorizerFunction:
            FunctionArn: !GetAtt LambdaAuthorizerFunction.Arn
  RootFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        def handler(event, context):
          return {
            'statusCode': 200,
          }
  # 追加ここから
  AdminFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /admin
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        import json
        def handler(event, context):
          return {
            'statusCode': 200,
            'body': json.dumps({
              'message': 'admin情報',
            }),
          }
  LeaderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /leaders
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        import json
        def handler(event, context):
            return {
            'statusCode': 200,
            'body': json.dumps({
              'message': 'leader情報',
            }),
          }
  UserFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /users/XXXXXXXX # userのuid 
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        import json
        def handler(event, context):
            return {
            'statusCode': 200,
            'body': json.dumps({
              'message': 'user情報',
            }),
          }
  # 追加ここまで
  LambdaAuthorizerFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: samAuth
      CodeUri: ./lambda_auth # buildされるが、configがアップされなかったので、`/build/関数名`のディレクトリに手動で配置
      Handler: app.lambda_handler
      Runtime: go1.x

Outputs:
  SampleRestApi:
    Description: "Sample API Gateway Lambda Authorizer by Golang."
    Value: !Sub "https://${SampleRestApi}.execute-api.${AWS::Region}.amazonaws.com/dev/"

Lambdaオーソライザー関数の設定

前々回のLambda fanctionを修正し、generetePolicyを呼んでいた箇所でリソースの設定を行います。

以前はgeneretePolicyの第3引数にevent.MethodArnを入れており、Lambdaオーソライザー関数を呼んだ際のパスが入るようになっていたため認証後はそのままアクセスできていたのですが、今回はユーザー単位でアクセスできるパスを設定します。

apiArn := "arn:aws:execute-api:ap-northeast-1:XXXXXXXXXXXX:XXXXXXXXXX/dev" // API Gatewayのパス

    switch email {
    case "admin@example.com":
        return generatePolicy("admin", "Allow", []string{apiArn + "/GET/*"}), nil // 全リソースにアクセス可能
    case "leader@example.com":
        return generatePolicy("leader", "Allow", []string{apiArn + "/GET/leaders", apiArn + "/GET/leaders*", apiArn + "/GET/users*"}), nil // leaderとuserの全リソースにアクセス可能
    case "user@example.com":
        userID := token.UID
        return generatePolicy("user", "Allow", []string{apiArn + "/GET/users/" + userID}), nil // 自分のリソースにのみアクセス可能
    case "deny@example.com":
        return generatePolicy("deny", "Deny", []string{}), nil
    case "unauthorized":
        return events.APIGatewayCustomAuthorizerResponse{}, errors.New("Unauthorized") // Return a 401 Unauthorized response
    default:
                 return events.APIGatewayCustomAuthorizerResponse{}, errors.New("Error: Invalid token")
    }

リソースに設定するパスは、コンソールでAPI Gatewayの設定を見ることで確認可能です。 f:id:kaminashi-developer:20210624212136p:plain

generetePolicyの第3引数はSliceで、複数パスを入れることができ、 *を入力して配下の全リソースにアクセス可能とすることもできます。

が、 leaders/*のように /を挟むと別リソースと判定されるようで、 leaders*とする必要があったためパス設計には注意が必要そうでした。

今回はメールアドレスでの判定ですが、Firebaseでアカウントを作成する際に情報を組み込むようにすれば、それらを基に制御をすることも可能かと思います。 (コンソールからだとカスタマイズができなかったため割愛)

検証

FirebaseAuthで以下の3ユーザーを作成し、アクセスを試みます

検証にはこれまた雑ですが、ログイン後、各API Gatewayのパスにリクエストを投げ、どのユーザーでどのレスポンスが返ってくるかHTMLファイルを利用して確認します。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Lambda Authorizer Sample</title>
  </head>
  <body>
    <h1>...Please wait</h1>
    <div id="email"></div>
    <div id="token"></div>
    <br>
    <div></div>
    <a id="admin"></a>
    <br>
    <div></div>
    <a id="leader"></a>
    <br>
    <div></div>
    <a id="users"></a>

    <!-- FirebaseSDKのインポート -->
    <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-app.js"></script>

    <!-- FirebaseAuthのインポート -->
    <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-auth.js"></script>

    <!-- configファイルのインポート -->
    <script src="./js/config.js"></script>

    <!-- axiosのインポート -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js" integrity="sha512-bZS47S7sPOxkjU/4Bt0zrhEtWx0y0CRkhEp8IckzK+ltifIIE9EMIMTuT/mEzoIMewUINruDBIR/jJnbguonqQ==" crossorigin="anonymous"></script>
    <script>
      // User情報の表示
      firebase.auth().onAuthStateChanged(function(user) {
        let h1    = document.querySelector('h1');
        let email = document.querySelector('#email');
        let token = document.querySelector('#token');
        let admin  = document.querySelector('#admin');
        let leader  = document.querySelector('#leader');
        let users  = document.querySelector('#users');

        // API Gatewayで作成したエンドポイントを設定
        // const url = "http://localhost:3000"
        const adminUrl  = "https://x7ow8vkh09.execute-api.ap-northeast-1.amazonaws.com/dev/admin"
        const leaderUrl = "https://x7ow8vkh09.execute-api.ap-northeast-1.amazonaws.com/dev/leaders"
        const userUrl   = "https://x7ow8vkh09.execute-api.ap-northeast-1.amazonaws.com/dev/users"

        if (user) {
          console.log('user', user)
          h1.innerText   = 'ログインしました';
          email.innerHTML = `メールアドレス:${user.email}`;
          user.getIdToken().then(function(accessToken) {
            console.log('accessToken', accessToken)
            token.innerHTML = `JWTトークン:${accessToken}`;

            // リクエスト送信
            axios.get(adminUrl, {
              headers: {
                'Authorization':accessToken,
              }
            }).then(res => {
              admin.innerHTML = `管理者: ${res.data.message}`;
            }).catch(error => {
              admin.innerHTML = `管理者: ${error}`;
            });

            axios.get(leaderUrl, {
              headers: {
                'Authorization':accessToken,
              }
            }).then(res => {
              leader.innerHTML = `リーダー: ${res.data.message}`;
            }).catch(error => {
              leader.innerHTML = `リーダー: ${error}`;
            });

            axios.get(userUrl + "/" + "8eMzOlFSDcVJU8U4zjwNA9pyMlr1", {
              headers: {
                'Authorization':accessToken,
              }
            }).then(res => {
              users.innerHTML = `ユーザー: ${res.data.message}`;
            }).catch(error => {
              users.innerHTML = `ユーザー: ${error}`;
            });
          }, null, ' ');
        }
      });
    </script>
  </body>
</html>

各ユーザーでログインしたところ、以下の通りアクセス可能となっているリソースのみ取得できている形になってます。

  • admin@example.com

    • 全情報が取得できている f:id:kaminashi-developer:20210624212203p:plain
  • leader@example.com

    • leader, user情報のみ取得できている f:id:kaminashi-developer:20210624212430p:plain
  • user@example.com

    • user情報のみ取得できている f:id:kaminashi-developer:20210624212408p:plain

終わりに

以上、簡単ではありますがAPI GatewayとLambdaを利用した認可設定のご紹介でした。 URLの設計が必要ですが、API GatewayとLambdaを利用することで、中央管理するパターンであれば簡単にロールベースのアクセス制御ができるように感じました。

少しでも参考になれば幸いです。

【登壇資料】製造現場を変えろ。ソフトウェアエンジニアが挑む爆速DX

f:id:kaminashi-developer:20210603133800p:plain

カミナシ・エンジニアの浦岡です。 先日、CADDiさんと共催で勉強会を開催しました。その際のカミナシ側の登壇資料を紹介します。

勉強会の概要

製造現場向けのサービスを開発しているCADDiさんと弊社カミナシが共催でオンライン勉強会を開催しました。

今回、製造現場向けのサービスを作る2社が、 ソフトウェアエンジニアが挑む爆速DXと題して開発エピソードをそれぞれ発表しました。

kaminashi.connpass.com

カミナシからは「紙のワークフローをDX化する際に行った多階層データ処理の高速化について」というタイトルで発表させていただきました。

speakerdeck.com

製造現場のDXとは

gyazo.com

製造現場のDXというと、無人化、高度化をイメージする方もいるかと思いますが、実際の製造現場ではまだまだ紙が主役です。

製造現場を変えるための、カミナシのアプローチ

gyazo.com

カミナシは、紙のワークフローをデジタル化することで製造現場のDXを加速しようしていますが、 ユーザー自身がノーコードでワークフローを作成・編集できます。

開発エピソード(性能低下の課題)

ノーコード(ロジカル)なデータを扱うことに起因した性能低下の課題が サーバー、クライアントそれぞれで発生し、それにどう対応したかを紹介せさていただきました。

サーバー側での性能低下

gyazo.com

サーバー側では、ボトルネックとなっていたDBクエリの発行回数を抑える施策を紹介しました。

クライアント側での性能低下

gyazo.com

クライアント側では、コンポーネントの無駄な描画による性能の低下に対して、memo化で対処した事例を紹介しました。

宣伝

今回、サーバー・クライアントのそれぞれでの性能低下の課題に対しての対処を開発エピソードとして発表しましたが、まだまだ小手先の対応しかできていないのが実情です。

今後のユーザー増加、機能拡張に耐えうる抜本的な改善を行うためにも、 EM/アプリエンジニア/SREと幅広く募集しています!

open.talentio.com

open.talentio.com

open.talentio.com

サービスの開発途中から、厳しめのESLintのルールを導入するためにしたこと

こんにちは、株式会社カミナシのエンジニア@tomiです。

今回は、開発途中からESLintのルールを見直す際にどうやって行ったかを書こうと思います。

開発途中からESLintを見直すとなると、膨大にあるコードがあるので一気に直すとなると、恐ろしい量の修正が必要です。

カミナシで理想的なESLintのルールに修正しようと思ったら、修正箇所が4000件を超えていました。。。

f:id:kaminashi-developer:20210524091458p:plain

とても一人で直すには無理な量です。更に修正中も開発は進むわけですから、コンフリクトも頻繁に発生するでしょう。

ESLintを見直すための道のり

では、一気に直すのは無理なので、はじめに考えたのは「段階的にルールを厳しくしていく」ことでした。

何個もルールをoffにしてあるので、それを一つずつerrorにしてプルリクを作成していく

そこで考えたのは、pre-commitを使ってコミットされるファイルに対して、新ルールのESLintを実行する方法です。

他の開発を行いながら徐々にコードがキレイになっていく仕組みを作ろうと考えました。

ただし、pre-commitでESLintを実行するだけでは駄目で、以下の要件をクリアする必要がありました。

  • 新ルールはガチガチの厳しいものにする
  • 開発環境の起動時は旧ルールで動く
  • ビルド時のlintチェックは旧ルールで動く
  • 編集したファイルのみ新ルールを適用する

既存の.eslintrc.jsonを厳しいルールに変更してしまうと、開発環境が起動しなくなったり、ビルドが失敗してしまったりと問題が起こるので、コミットされる時のみ新ルールが適用されるように書く必要があります。

新ルールだけをまとめた設定ファイルを作る

ESLintには設定ファイルを上書きして読み込める機能があるので、それを利用します。

例えば、.eslintrc.jsonは以下だとして、

{
  "env": {...},
  "parser": "@typescript-eslint/parser",
  "parserOptions": {...},
  "plugins": [...],
  "rules": {
    "no-unused-vars": "off",
    "no-empty": "off"
  }
}

ESLintを実行すると、自動で読み込まれます。

$ eslint --print-config src/App.tsx
...
  "rules": {
    "no-unused-vars": [
      "off"
    ],
    "no-empty": [
      "off"
    ]
  },
  "settings": {},
  "ignorePatterns": []
}

さらに、別ファイル.eslintrc-override.jsonを作成する

{
  "rules": {
    "no-unused-vars": "error"
  }
}

こちらには、.eslintrc.jsonでオフ設定したルールに対して、エラーになるように設定した。

.eslintrc-override.json-cオプションを使って読み込むと、

$ eslint -c .eslintrc-override.json --print-config src/App.tsx
...
  "rules": {
    "no-unused-vars": [
      "error"
    ],
    "no-empty": [
      "off"
    ]
  },
  "settings": {},
  "ignorePatterns": []
}

.eslintrc.jsonの上に.eslintrc-override.jsonが上書きされていることがわかる。

このオプションを使えば、pre-commit時だけ別ルールというのは比較的に簡単に実装できそう。

予期せぬ上書きが起こる

ただし、注意して設定を行わないと思っていたルールと違う設定になってしまう場合がある。

それが、上書きする側.eslintrc-override.jsonextendsを使うときである。

.eslintrc-override.jsonを以下のようにextendsを追加してみる。

{
  "extends": ["eslint:recommended"],
  "rules": {
    "no-unused-vars": "error"
  }
}

そして先程同様にESLintを実行すると、

$ eslint -c .eslintrc-override.json --print-config src/App.tsx

f:id:kaminashi-developer:20210524091546p:plain

**.eslintrc.jsonでoffにしていたはずが、errorになっている。**

eslint:recommendedでは内部では"no-empty": ["error"]が設定されているためである。

中身を把握してextendsを設定するなら問題ないが、意図しないルール変更が行われる匂いがプンプンする。

そこで、元々あったルールで変更したくないものがあるときは、更に別ファイルに分けて、.eslintrc.json.eslintrc-override.jsonに読み込ませるのが安全だろう。

最終的な形としては、以下の3ファイルを用意する。

// .eslintrc.json
{
  "env": {...},
  "parser": "@typescript-eslint/parser",
  "parserOptions": {...},
  "plugins": [...],
  "extends": [
    "./.eslintrc-rules.json"
  ],
  "rules": {
    "no-unused-vars": "off"
  }
}
// .eslintrc-override.json
{
  "extends": [
    "eslint:recommended",
    "./.eslintrc-rules.json"
  ],
  "rules": {
    "no-unused-vars": "error"
  }
}
// .eslintrc-rules.json
{
  "rules": {
    "no-empty": "off"
  }
}

extendsで変更したくないルールをそれぞれのファイルに読み込ませることで、上書きされる危険性がなくなる。

pre-commit時のみ新ルールを適用する

これはもう説明も不要かもしれませんが、記録に残して起きます。

CIで実行されるlintチェックは、yarn lintで実行させます。

pre-commitはhuskyを使って、.eslintrc-override.jsonを適用させます。

"scripts": {
  "lint": "eslint ./src"
}
"husky": {
  "hooks": {
    "pre-commit": "lint-staged"
  }
},
"lint-staged": {
  "./src/**/*.{js,jsx,ts,tsx}": [
    "prettier --config .prettierrc --write",
    "eslint -c ./.eslintrc-override.json"
  ]
},

これでコミットするファイルだけ厳しいルールが適用され、ルールを守らないとコミットできない仕組みができました。

4000件以上の修正はしないといけないのは変わらないですが、みんなで少しずつコードの整理をしていきましょう!!

将来的に

まだ実現できてないのですがやりたいことがあります。

それは残りのエラー件数を定期的に通知する仕組みです。

今回実装したものの課題は、このままだとずっとビルド時のlintチェックは緩いままで決して健全とは言えません。なのでいつかは新ルールがすべての場所に適用されるのが理想です。

そのためには、エラーがなくなったかを知る仕組みが必要かなと思いました。

いつか、yarn lint -c ./eslintrc-override.json srcが定期実行されて、Slackに通知される仕組みも作っておきたいなぁと思っています。

テスト駆動開発で文字列が日付か正規表現で調べる関数を実装してみた

f:id:kaminashi-developer:20210519192254j:plain

こんにちは、株式会社カミナシのエンジニア @imu です。

はじめに

テスト駆動開発(以下、TDD)は知っているけど、業務でTDD使ってみようと思ってもリリースに追われてしまい、時間が取れなかったりしてチャレンジ出来ていませんでした。今回は時間に余裕があったのでTDDを実践してみました。

環境構築

ExpoCLIのインストール
npm install -g expo-cli 

インストールが終わったらひな形を作成しましょう。 今回はManaged workflowのblank(TypeScript)を利用します。

expo init tdd_regexp
テストのパッケージをインストール

公式ドキュメントに従ってテストのパッケージをインストールします。

docs.expo.io

yarn add jest-expo @types/jest --dev

package.jsonに以下の設定を行います。

"scripts": {
  ...
  "test": "jest"
},
"jest": {
  "preset": "jest-expo"
}

TDD

目的
  • 「キレイ」で「動作する」コードを書くこと

乱雑、混乱、散らかっているコードがないように整然と保つことかなと思っています。どうしてもリリース日間に合わせるために、「コレでいいか」と動作する方向だけに力を入れないようにしないといけないですね。

テストが成功している内はその動作は保証されていると言えます。(正しい実装であるかの保証は出来ません。あくまでテストが通ることが保証されているだけです)

これによってエンジニアは自身を持って大胆かつ、積極的にリファクタリングがしやすくなると思います。

テストコード大事ですね!

3つのサイクル

基本となる開発サイクルは以下になります。

  • Red: 失敗するテストを書く
  • Green: できる限り早く、テストに通るような最小限のコードを書く
  • Refactor: コードの重複を除去する(リファクタリング

【Red】失敗するテストを書く

最初に必ず落ちるテストを書き、テストが失敗することから始めます。

【Green】できる限り早く、テストに通るような最小限のコードを書く

どんなコードでも良い(マジックナンバー、固定値等)のでテストを成功させるコードを書いていきます。

【Refactor】コードの重複を除去する(リファクタリング

意味のあるコードになっていれば終わりですが、Greenの段階で「とりあえず動いた」コードを放置してはいけません。

忙しいと思ってしまう「あとでキレイにします!」はやらないのと同義だと思うので、都度リファクタリングしていきましょう。

リファクタリングした後は、Red -> Green -> Refactorを繰り返していく手法となります。

speakerdeck.com

www.amazon.co.jp

TDDに沿ってやってみよう

今回はこのパターンだけ正となるようにします
yyyy/MM/dd
1回目

【Red】失敗するテストを書く

IsIncludesDate.ts

const isIncludesDate = (text: string): boolean => {
  const regexps: RegExp[] = []
  return regexps.some(reg => reg.test(text))
}

export default isIncludesDate

IsIncludesDate.test.ts

import isIncludesDate from './IsIncludesDate'

describe('文字列が日付か正規表現で調べる', () => {
  test('yyyy/MM/dd', () => {
    expect(isIncludesDate('2000/01/01')).toBe(true)
  })
})

テストを実行してみましょう!ここで落ちなきゃダメです笑 f:id:kaminashi-developer:20210512175750p:plain

【Green】動けば良いコードを実装

const isIncludesDate = (text: string): boolean => {
  const regexps: RegExp[] = [/^\d{4}\/\d{2}\/\d{2}$/]
  return regexps.some(reg => reg.test(text))
}

f:id:kaminashi-developer:20210512182257p:plain

【Refactor】コードの重複を除去する(リファクタリング

前のサイクルで正常になったので終わり…と言いたいところなんですが「yyyy/MM/dd」で日付じゃないパターンも想定しないといけないと思います。

それは次サイクルでやってみよう!

2回目

日付かどうか判定したい関数なので「0000/00/00」は日付と判定してほしくないことを確認します。

【Red】

describe('文字列が日付か正規表現で調べる', () => {
  test('yyyy/MM/dd - 異常値', () => {
    expect(isIncludesDate('0000/00/00')).toBe(false)
  })
})

テストを実行してみましょう! f:id:kaminashi-developer:20210512183201p:plain 日付と判定してほしくないのに関数の結果は日付を判定されていますね。 次サイクルでテストを成功させるようにしていきましょう。

【Green】

正規表現が「数値4桁/数値2桁/数値2桁」になっているので、日付の範囲になるように変更する

const isIncludesDate = (text: string): boolean => {
  const regexps: RegExp[] = [/^\d{4}\/(0?[1-9]|1[0-2])\/(0?[1-9]|[12][0-9]|3[01])$/]
  return regexps.some(reg => reg.test(text))
}

f:id:kaminashi-developer:20210512185139p:plain

【Refactor】

リファクタリングも必要ないので今回はこれで終わりです! 正規表現のパターンを追加した場合は再度1回目からやっていきましょう!

最終的なテストコード
import isIncludesDate from './IsIncludesDate'

describe('文字列が日付か正規表現で調べる', () => {
  test('yyyy/MM/dd - 正常値', () => {
    expect(isIncludesDate('2000/01/01')).toBe(true)
    expect(isIncludesDate('2000/1/1')).toBe(true)
    expect(isIncludesDate('2000/01/1')).toBe(true)
    expect(isIncludesDate('2000/1/01')).toBe(true)
  })

  test('yyyy/MM/dd - 異常値', () => {
    expect(isIncludesDate('0000/00/00')).toBe(false)
    expect(isIncludesDate('2000/13/01')).toBe(false)
    expect(isIncludesDate('2000/01/32')).toBe(false)
    expect(isIncludesDate('9999/99/99')).toBe(false)
    expect(isIncludesDate('')).toBe(false)
    expect(isIncludesDate(null)).toBe(false)
    expect(isIncludesDate(undefined)).toBe(false)
  })
})

f:id:kaminashi-developer:20210512192314p:plain

うるう年のチェックも出来れば正確になると思うので是非やってみてください!

おわりに

TDDのイメージはついたでしょうか?ロジカルな実装や修正が頻繁に入るところは、TDDを使うと良さそうです。すべてこの手法でするのは難しいですが、予めインプット、アウトプットが決まっている場合にはTDDを使って実装してきたいと思います!

正直なところ、この手順で合っているか不安ではありますが、まず第一歩が踏み出せた気がします笑

最後に弊社ではエンジニアを募集しております。 興味がある、話を聞いてみたい、応募したいという方はお気軽にご応募ください!

open.talentio.com

品質と新規開発のバランスというタイトルで登壇してきました

こんにちはカミナシでアプリケーションエンジニアをやっている沼田( @keinuma15 )です。 先日行われた Startup Issue Gym #1【開発プロセスのIssue】 にて「品質と新規開発のバランス」というタイトルで発表してきました。

概要

スタートアップのプロダクトは成長していくにつれて様々な課題にぶつかります。 その中でも0→1から1→10フェーズに変化すると既存機能を改善しつつ新機能を開発していく課題が出てきます。 今回はカミナシがこの課題どう立ち向かっているのか共有させていただきました。

speakerdeck.com

プロダクトの成長による課題の変化

Image from Gyazo

プロダクトはフェーズに応じて目指しているゴールと課題が変わってきます。

カミナシは2019年12月にピボットして今のプロダクトの開発を開始しました。 当時は0→1のフェーズだったため、顧客のどの課題を解くのかやソリューションを考え実現する必要がありました。 そこから2020年6月に正式リリースしたのが「カミナシ」です。

Image from Gyazo

カミナシは現場で働いてる全ての業界を対象としたアプリで記録業務をできるプロダクトです。 開発を開始して半年ほど経つと次第にユーザーが増え始め、既存機能だけではカバーできていない領域が増えてきました。 ユーザーにより価値を届けるためには新機能を開発しないといけないが、すでに提供している機能を改善する必要も出てきました。

狩野モデルとプロダクト開発

新機能と既存機能の開発のバランスをどうとってきたかを説明するために狩野モデルについて紹介させていただきます。

Image from Gyazo

狩野モデルは品質の種類とそれらの顧客の満足度の関係性を示したものです。 グラフには3種類の品質が書いてますが、元のモデルには5種類の品質が定義されています。

名前 充実した時の満足度 不足した時の満足度
魅力的品質 満足 不満足にはならない
一次元的品質 満足 不満足
当たり前品質 満足にはならない 不満足
無関心品質 満足にはならない 不満足にはならない
逆品質 不満足 満足

狩野モデルの品質をプロダクトに合わせて考えると魅力的品質が新機能、当たり前品質が既存機能と見ることができます。

カミナシにとって新機能は前例のプロダクトが少なく、ユーザーに提供してから業務に機能が組み込まれフィードバックが来るのに1,2ヶ月かかります。 一方で当たり前品質は、カミナシを導入する前は紙を利用して業務を行なっているため運用が止まらないことが前提になりやすいです。そのため高い当たり前品質を維持する必要があります。

これらの特徴を踏まえた上で、カミナシがそれぞれの品質にどうアプローチしてきたか紹介させていただきます。

カミナシの戦い方

カミナシは魅力品質と当たり前品質において以下の作戦を立てていました。

  • 魅力的品質:隣接した業務領域をカバーする
  • 当たり前品質:機能の再構築

魅力的品質

カミナシのメイン機能がカバーしている業務領域は作業後のチェック業務です。

Image from Gyazo

チェック業務はその後の集計、改善まで含めて効果を発揮します。 チェック業務だけカバーしていても紙の電子化に留まってしまい、ユーザーに価値を届けきれてないです。 そのため、チェック業務の次の工程にある集計業務を新機能として開発しました。

ここからは新機能を開発するフローと同じです。 カミナシでは最初にユーザーに現状の業務と課題をヒアリングし、どこに痛みがあるのかを把握します。 その結果、集計業務では分析・報告書・システム間連携の主に3種類あり、紙からEXCELファイルに転記しているため作業量が多い課題が存在することがわかりました。

次に3種類の業務をどういうソリューションで解決するか検討します。 当時はそれぞれの業務ごとに機能を開発することと業務に関係なくユーザーがカスタマイズできる機能が候補にありました。 魅力的品質は改革の性質を持っていますが、カミナシではなるべく元の業務とのギャップを小さくするようにしています。 そのため、集計業務として記録したデータを任意のEXCELで出力できるEXCEL変換機能をリリースしました。

当たり前品質

当たり前品質を下げる大きな要因として内部品質とUX品質があります。 UX品質はスタートアップのプロダクトの場合、作りっぱなしで放置されていると低くなりやすいです。

開発当初に想定していた仮説をもとに開発したものの、実際に使い初めてもらうと思っていたものと違うと感じる時があります。 カミナシでは当初想定していた仮説の間違いを正して、機能を再構築してきました。

具体例にダッシュボード機能を上げさせていただきます。

Image from Gyazo

カミナシのダッシュボードは予定されていた記録が行われているか確認する機能です。

当初の仮説では記録できていない数を知りたいと仮説をおいていたのですが、使いこなしてくれるユーザーが少ない状況でした。 そこでユーザーがどういう情報を知りたいのか声を集めたところ、どの現場でいつできてないのかを知りたいことがわかりました。 この学習をもとにダッシュボード機能を再構築し、どの現場でいつできてるかをOX表で表現してリリースしました。

当初はできてない現場が多くXが増えるのではないかという懸念がありましたが、現場の状況がわかりやすくなったというフィードバックをいただいてます。

次の課題

ここまで業務領域を広げて魅力品質を高めつつ、機能を再構築して当たり前品質を上げる例を紹介させていただきました。 今のカミナシはまた別な課題に向き合っています。

一つはプロダクトがカバーできる領域が1プロダクトの範囲を超えてきたことです。 カミナシは複数の業界にプロダクトを提供しているため、業界特化の業務に対応しきれていません。 これらを解決するために新プロダクトを開発していく予定です。

もう一つはプロダクトの内部品質の課題が増えてきたことです。 コードの自動テストを追加したりインフラの刷新をしていかないといけません。

これらの課題に興味がある方はEM/アプリエンジニア/SREと幅広く募集しているのでぜひお話しましょう。

open.talentio.com open.talentio.com open.talentio.com