OpenIDConnect+Deviseでの認証クライアントの実装

ソフトウェアエンジニアの彌冨です。
github.com

入社してからもうすぐで2年になろうとしています。
ベンチャーあるあるでいろいろとエンジニア領域外なこともやってきましたが、最近新規サービスをフルスクラッチで作り上げている中で苦労したユーザー認証の話を書きます。

 

前置き

OpenID Connectとは

こちらでは実装の話に集中するため、詳細の話は以下のスライドがわかりやすいので参考にしてください(OpenID Connect自体の話はある程度割愛させていただければと思います)

www.slideshare.net


ただ、端的に私の理解を述べると、OAuth2.0のプロトコルを拡張してシンプルなアイデンティティレイヤーを足すことで、認証と認可の両方を行うことができ、よりセキュアにアクセス管理ができる規格と考えています。


今回は以下のOpenID ConnectのAuthorization Code Flowを実現してログイン時のユーザー認証をANDPAD本体のユーザーアカウント情報を用いて行うフローを実装しました。

f:id:teru-yat:20200625211444p:plain
Authorization Code Flow

RPとはRelying PartyのことでOpenID Connectにおける認証クライアントを示します。この場合は今回の新規サービスのAPIサーバーとなります。
IdPとはOpenID Providerのことで認証サーバーを示し、今回のケースにおいてはANDPAD本体です。
Resource Serverは認証によって保護されたリソースを所有するサーバーで、今回はユーザーアカウントを持つANDPAD本体を指します。

  1. まずエンドユーザーは新規サービスの画面上でログインを行います
  2. ログインボタンを押されると、APIサーバーへログインリクエストを行います
  3. APIサーバーは、認証サーバーであるANDPAD本体のAuthorizationエンドポイントへRedirectします
  4. Redirectに従ってANDPAD本体へアクセスします
  5. ブラウザ上でANDPAD本体でログイン処理を行います
  6. ANDPAD本体は設定してある新規サービスのコールバックURLへAuthorization Codeと一緒にRedirectします
  7. Redirectに従ってAPIサーバーのコールバックURLへアクセスします
  8. APIサーバーはAuthorization Codeを使ってANDPAD本体へID Tokenをリクエストします
  9. ANDPAD本体はAuthorization Codeを確認してID TokenをAPIサーバーへ渡します
  10. APIサーバーはそのID Tokenを使ってANDPAD本体にユーザー情報をリクエストします
  11. ANDPAD本体はユーザー情報を返します
  12. 以上をもってログインが完了し、APIサーバーはログイン結果をブラウザへ返します
  13. ユーザーは無事にログインできました

 

途方にくれた話

さて、新規サービスをフルスクラッチで立ち上げようとしていたわけですが、ANDPADのプロダクトの一部であるため、それぞれ別のユーザーアカウントを作らせる仕様は嫌だなと思い、本体ANDPADのユーザーアカウントを利用しようと決めました。

幸い、ANDPAD本体では、OpenID Providerをdoorkeeperを用いて実装されていたので、新規サービスの方ではRelying Partyを実装してAuthorization Code Flowを実現することにしました。
github.com


 
実際にRelying Partyを実装するにあたり、今後社内で同様に新しいサービスにおいてユーザー認証する時に再利用できる形にしたいとは思いつつ、AWS Cognitoを使って認証を行うかDeviseに認証方式を足すか迷いました。
特に、ローカルでの開発時にAWS Cognitoを用いた場合にどうやって処理を行えるのかが不明で調べたりもしました。
いろいろと調べたりしましたがAWS Cognitoはその時点では私時点の知見が少なく、今後のことを考えるとどこまで再利用性が高いのか等が不明で、結局Deviseに組み込む形で実装を考えることにしました。

 
ただ、ここからが問題でした。

いろいろと調べたりもしましたが、実装の参考になるドキュメントがなかなか見つからず、また既存のGemがないかも調べましたが継続的にコミットがあり今後も安心して使っていけそうなものは私が探した限りでは見つかりませんでした。
英語も日本語も探しましたがわりとCognitoを使って実装してしまうケースが多いように見受けられました。


結局、Novさんの作られたopenid_connectというGemと、(Implicit FlowでAuthorization Code Flowとは別ですが)Qiitaのこちらの実装例を参考に試行錯誤しながら実装したので紹介させていただこうと思います。
github.com
qiita.com

ちなみに、ローカルでの開発に関してはdocker-composeにAPI Stubサーバーを追加して認証を擬似的に完了するように実装しました。 

実装詳細

ある程度概略になりますが、実際に先ほど説明したAuthorization Code Flowの流れを追いながら説明していこうと思います。
また、こちらの実装前に認証サーバー側でOpenID Connect 認証クライアントとしてアプリケーションの登録を行っておいてください。
 

(前準備)DeviseにOpenIDConnectの認証の登録

Deviseへopen_id_connect_authenticatableを登録しています。
Deviseでは通常userがscopeになりますが今回はこちらの都合でuser_sessionとしています。

config/initializers/devise.rb

Devise.setup do |config|
  config.warden do |manager|
    manager.default_strategies(scope: :user_session).unshift :open_id_connect_authenticatable
  end
end

Devise.add_module(:open_id_connect_authenticatable, {
  strategy: true,
  controller: :sessions,
  model: 'devise/models/open_id_connect_authenticatable',
  route: :session,
})

認証処理の開始

APIのエンドポイントを叩く際に認証がされているかをまず確認します。

app/controllers/application_controller.rb

before_action :authenticate_user_session!

認証がされていない場合認証を開始しますが、認証自体は認証サーバーであるANDPAD本体で行うのでRedirectします。
この時、stateやnonceをCSRF対策やリプレイ攻撃対策としてセッションに保存します。

app/controllers/web/v1/user_sessions/sessions_controller.rb

  def new 
    redirect_to authorization_uri
  end 

  def authorization_uri
    OpenIdConnect::UserAuthorization.new.authorization_uri(set_state, set_nonce)
  end 

    # CSRF 攻撃対策
  def set_state
    session[:state] = SecureRandom.hex(16)
  end 

    # リプレイ攻撃対策
  def set_nonce
    session[:nonce] = SecureRandom.hex(16)
  end 

この OpenIdConnect::UserAuthorizationはlib配下に作ったmoduleで、環境変数からANDPAD本体のエンドポイント情報等を使ってAuthorizeエンドポイントを返します。
Client ID, Client SecretはこのAPIサーバーをANDPAD本体側で登録した際に生成されます。
callback URLはapp/controllers/web/v1/user_sessions/sessions_controller.rbのcallbackメソッドに返ってくるように登録しておきます。

lib/utils/open_id_connect/user_authorization.rb

module OpenIdConnect
  class UserAuthorization
    def authorization_uri(state, nonce)
      OpenIDConnect::Client.new(
        identifier: <ANDPAD本体で登録時に生成されたclient id>,
        secret: <ANDPAD本体で登録時に生成されたclient secret>,
        host: <ANDPAD本体のhost>,
        port: <ANDPAD本体のport>,
        redirect_uri: <ANDPAD本体で登録時に指定したcallback URL>,
        authorization_endpoint: <ANDPAD本体のAuthorizeエンドポイント>,
      ).authorization_uri(
        response_type: 'code',
        state: state,
        nonce: nonce,
        scope: %w[openid]
      )   
    end 

Authorization Codeを受け取ったら

その後、ANDPAD本体から無事にAuthorization Codeを受け取ったら、ID Tokenを取得してユーザー情報を取得します。
callbackを叩かれたら、Deviseのauthenticate!を呼び出し、無事にユーザー情報を取得できていたら認証を完了します。

app/controllers/web/v1/user_sessions/sessions_controller.rb

  def callback
    resource = request.env['warden'].authenticate!(:open_id_connect_authenticatable, scope: :user_session)

    redirect_to <認証後のトップページURL>
  end

lib/utils/devise/strategies/open_id_connect_authenticatable.rb

      def authenticate!
        oidc_client = OpenIdConnect::UserAuthorization.new
        user_info = oidc_client.verify!(params["code"], session.delete(:nonce))

        # user_infoからuser_sessionを紐づけるもしくは登録する処理

        success!(user_session)
      end 


authenticate!内では、OpenIdConnect::UserAuthorizationのverify!が呼び出されていますがこの中でID Tokenの取得と検証、そしてユーザー情報の取得を行っています。
verify!ではissやnonceなどの検証を行うフィールドを渡してあげてください。何かしらのエラーがあるとここで例外が投げられるので認証が失敗します。

lib/utils/open_id_connect/user_authorization.rb

    def verify!(code, nonce)
      oidc_client = OpenIDConnect::Client.new(
        identifier: <ANDPAD本体で登録時に生成されたclient id>,
        host: <ANDPAD本体のhost>,
        port: <ANDPAD本体のport>,
        redirect_uri: <ANDPAD本体で登録時に指定したcallback URL>,
        authorization_endpoint: <ANDPAD本体のAuthorizeエンドポイント>,
        token_endpoint: <ANDPAD本体のTokenエンドポイント>,
        userinfo_endpoint: <ANDPAD本体のUserinfoエンドポイント>
      )   
      oidc_client.authorization_code = code
      access_token = oidc_client.access_token!

      id_token = decode_jwt_string(access_token.id_token)

      id_token.verify!(
        # 検証を行うフィールドをここで列挙
      )   

      user_info = access_token.userinfo!

      user_info
    end 


また、取得したID Tokenはそのままでは暗号化されているので復号化する必要があります。
今回の実装では、Decodeに必要なKeyをJson Web Key(JWK)で取得しています。
ここらへんは先ほど紹介させていただいたQiitaのこちらの記事でDecodeのサンプルがあったので参考にさせていただきました。
qiita.com

lib/utils/open_id_connect/user_authorization.rb

    def decode_jwt_string(jwt_string)
      OpenIDConnect::ResponseObject::IdToken.decode(jwt_string, jwk_json)
    end 

    def jwk_json
      @jwks ||= JSON.parse(
          OpenIDConnect.http_client.get_content(<ANDPAD本体のJWKS用エンドポイント>)
        ).with_indifferent_access
      JSON::JWK::Set.new @jwks[:keys]
    end

これで、無事に一連の認証のフローを通してユーザー認証を実現することができました。

ローカル環境でのAPI Stubサーバーを使った認証

次に、補足になりますが、ローカル環境でのAPI Stubサーバーの実装を簡単に紹介しようと思います。

前章まででOpenID Connectの認証クライアントはできたので、今回は認証サーバーのStubを作ります。
ローカルでANDPAD本体も起動しても良いのですが、認証のためだけにANDPAD本体も起動するとさすがにローカル環境が重くなるからです。

まず、Ruby on RailsにStub用のパスを切って以下のようにNginxのDockerfileを作ります。

api-stub/Dockerfile

FROM nginx:latest


次に、Nginxの設定ファイルを編集して認証サーバーのAuthorization、Token、UserInfo、JWKのエンドポイントを設定します。

api-stub/conf.d/default.conf

    # OpenID Connect Authorizationエンドポイント
    location /v1/open_id_connect/authorize {
        add_header Access-Control-Allow-Origin *;
        return http://<host>:<port>/v1/callback?code=<dummy code>&state=<dummy state>;
    }   

    # OpenID Connect Tokenエンドポイント
    location /v1/open_id_connect/token/ {
        add_header Access-Control-Allow-Origin *;
        error_page 405 =200 $uri;
        root   /usr/share/nginx/json;
        index  token.json;
    }   

    # OpenID Connect UserInfoエンドポイント
    location /v1/open_id_connect/userinfo/ {
        add_header Access-Control-Allow-Origin *;
        error_page 405 =200 $uri;
        root   /usr/share/nginx/json;
        index  userinfo.json;
    }   

    # OpenID Connect Decode用Keys
    location /v1/open_id_connect/keys/ {
        root   /usr/share/nginx/json;
        index  keys.json;
    }   

そのあとは、api-stub/json配下に実際のOpenID Connectで受け取っていたJSON情報を配置して、それぞれのエンドポイントを叩かれた際にJSON情報が返されるようにします。

以上で、ローカル環境でも認証を擬似的に再現して動作するように実装できました。

最後に

今回のOpenID Connectでの認証に関してはなに分時間のない中で手探りで実装したので大変でしたが、無事に実装ができてよかったです。
しかも、認証の大部分はlib配下に置くことができたので、再利用性も高く作れたのかなと思います。

正直、OAuthであればDeviseが対応してあるので楽に実装できたのかなとは思いますが、OAuthでの認証の脆弱性も認知されておりいつまで利用できたかは分からないかなと思います。
もちろん、プロダクトで必要なセキュリティと開発リソースのバランス次第だとは思いますが、今回は同様新規サービス作成時の再利用性を高めるメリットもあったので良いチャレンジになりました。


まとめになりますが、こちらの自身の実装の共有がこれからOpenID Connectの認証を行う方の参考になればと思います。
もしおかしい点があれば暖かい目で見つつご指摘いただけると幸いです。



ANDPADでは、「幸せを築く人を、幸せに。」をミッションに建築業界のみなさまを支援すべく開発に取り組んでいます。もしよければ以下もご覧ください。

engineer.andpad.co.jp


長文をお読みいただきありがとうございました。 

 

データドリブンな開発をめざして 〜モバイル用分析基盤を整備した話〜

モバイルアプリチームの工藤です。

ANDPADではこれまでユーザの声や思い描くビジョンを元にアプリの開発を進めてきましたが、徐々に機能変更や改善に関する仕事も増えてきています。現在のユーザの状況を意識して議論するシチュエーションも多々あるため、PdMより直々に早急にデータ分析基盤を用意せよとのお達しがありました。

まだまだ粗い部分もありますが、先日一区切りついたのでその対応内容をまとめようと思います

全体の方針を決める

今回の対応で最も意識したのは、複雑なことはしないで専任の担当者以外でも運用・維持ができるようにするという点です。弊社はスタートアップ企業であり、データ分析が根付いている他の企業に比べてまだまだエンジニアは多くありません。明確にアプリの分析担当者が居るわけではなく、職種に関わらずこのデータに触れる可能性があります。

そのため、将来的に突発的な運用作業や謎ルールに苦しめられることを避けられるよう気をつけながら各種設定を行いました。

大まかなデータの流れ

f:id:oct_k_kudo:20200605173304p:plain:w300

  1. アプリからユーザアクションに応じたログを送信
  2. ログデータはBigQueryに格納されるので、ROWデータをRedashで扱いやすいように加工する
  3. 作成した参照用テーブルからデータを取得し、集計してアウトプットする
  4. Slackでメンバーに更新通知する

現状の設計とドキュメント作成

開発対象の案件が確定した段階でPdMと分析要件について相談し、アウトプットのすり合わせをします。テキストで表現しづらいニュアンスなどは、適宜手書きでイメージしているグラフを書いたりしています。

実際に送るログはどの画面のどの操作なのか?を一意に特定できるようにRDBをイメージして探り探り設計していますが、データサイエンティストに相談しながら改善していきたいなと思っているところです。

設計した内容はGoogleスプレットシートにまとめていて、Confluenceにリンクを記載しています。設計書としてバージョン管理する事も考えたのですが、GItHubアカウントを持たない非エンジニアが分析することも踏まえてアクセスのしやすさを優先しました。今はまだ情報量が少ないおかげで成立している部分もあるので、将来的に運用が軌道に乗ってきた段階でVCSを利用することも検討したいです。1

アプリでログを送信する

アプリ用のログ収集ツールいくつか検討しましたが、以下の理由からGoogleAnalyticsを利用することにしました

  • Crashlyticsなどで既にFirebaseを利用している
  • Cloud MessagingやPredictionsなど、連携できる・してみたいサービスが提供されている
  • 検討したどのサービスも導入コストはそれほど変わらなかった

参考として、アプリの案件一覧画面でどの案件がタップされているか?を調査するために実装した内容を一部載せてみます。 少し長いので、興味のある方は展開してみてください。

f:id:oct_k_kudo:20200616155843p:plain:w200
iOSアプリのトップ画面

iOSの実装

FirebaseAnalyticsのラッパークラス

class AndpadAnalytics {
    private init() {}
    static let shared = AndpadAnalytics()
    
    func setUser(id: Int64){
        Analytics.setUserID(String(user.id))
    }

    func sendLogEvent(event: AndpadAnalyticsEvent) {
        let parameters = event.parameters?
            .compactMapValues({$0})
            .mapValues({"\($0)"})
        Analytics.logEvent(event.eventName, parameters: parameters)
    }
}

enum AndpadAnalyticsEvent {
    case selectContent(screenName: String, contentType: String, contentId: Int64? = nil)
    
    var eventName: String {
        switch self {
        case .selectContent: return FirebaseAnalytics.AnalyticsEventSelectContent
        }
    }
        
    var parameters: [String : Any?]? {
        switch self {
        case .selectContent(let screenName, let contentType, let contentId):
            return [
                AndpadAnalyticsConstants.Key.screenName: screenName,
                FirebaseAnalytics.AnalyticsParameterContentType: contentType,
                AndpadAnalyticsConstants.Key.contentId: contentId,
            ]
        }
    }
}

struct AndpadAnalyticsConstants {
    struct Key {
        static let screenName = "screen_name"
        static let viewName = "view_name"
        static let contentId = "content_id"
    }

    struct ScreenName {
        static let 案件一覧画面 = "案件一覧画面"
    }
    
    struct ContentType {
        static let orders = "orders"
    }
}

イベント発火時のコード

button.rx.tap
    .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background))
    .observeOn(MainScheduler.instance)
    .subscribe(onNext: { [weak self] in
        AndpadAnalytics.shared.sendLogEvent(event: AndpadAnalyticsEvent.selectContent(
            screenName: AndpadAnalyticsConstants.ScreenName.案件一覧画面,
            contentType: AndpadAnalyticsConstants.ContentType.orders,
            contentId: order.id
        ))
    })
    .disposed(by: disposeBag)

Androidの実装

FirebaseAnalyticsのラッパークラス

object AndpadAnalytics {
    private lateinit var firebaseAnalytics: FirebaseAnalytics

    // Applicationクラスで初期化しています
    fun initialize(context: Context) {
        firebaseAnalytics = FirebaseAnalytics.getInstance(context)
    }

    fun setProfile(id: Int) {
        firebaseAnalytics.setUserId(id.toString())
    }

    fun sendEvent(event: AndpadAnalyticsEvent) {
        val bundleString = event.data.keySet().joinToString("\n") { "$it: ${event.data.get(it)}" }
        Timber.d("""
event: ${event.eventName}
bundle:
$bundleString
        """)
        firebaseAnalytics.logEvent(event.eventName, event.data)
    }
}

sealed class AndpadAnalyticsEvent {
    abstract val eventName: String
    abstract val data: Bundle

    data class SelectContent(
            val screenName: String,
            val contentType: String,
            val contentId: Long? = null,
    ) : AndpadAnalyticsEvent() {
        override val eventName = FirebaseAnalytics.Event.SELECT_CONTENT
        override val data
            get() = Bundle().also {
                it.putString(AndpadAnalyticsConstants.Key.screenName, screenName)
                it.putString(FirebaseAnalytics.Param.CONTENT_TYPE, contentType)
                it.putString(AndpadAnalyticsConstants.Key.contentId, contentId.toString())
            }
    }
}

object AndpadAnalyticsConstants {
    object Key {
        const val screenName = "screen_name"
        const val viewName = "view_name"
        const val contentId = "content_id"
    }

    object ScreenName {
        const val 案件一覧画面 = "案件一覧画面"
    }

    object ContentType {
        const val orders = "orders"
    }
}

イベント発火時のコード

binding.button.setOnClickListener { 
    AndpadAnalytics.sendEvent(AndpadAnalyticsEvent.SelectContent(
        AndpadAnalyticsConstants.ScreenName.案件一覧画面,
        AndpadAnalyticsConstants.ContentType.orders,
        content.id
    ))
}

ポイントは、

  1. なるべくConstantsを作成し利用する。変数名は送信値をlowerCamelに変換したもの。ただし実装上問題なければ日本語変数も可。
  2. イベントタイプごとに構造体を作っている
  3. データのValueは必ずStringにキャストしている(今はiOSのみ)

あたりでしょうか。3つとも、 実装担当者以外が分析する際にコードを確認するシチュエーションがあるかもしれない という前提で設計されたものです。

1の日本語変数に関しては、歴史的経緯から本来の意味とプログラムの命名がかけ離れているコンポーネントもあり、プログラム名とそこで使用される分析用変数と日本語で表現された役割がバラバラになるよりは、変数と役割だけでも同じものにできれば良いという考えで割り切っています。例えば案件一覧は Ordersと表現したいもののHomeActivityとして作成されており、プログラム内で突然画面名としてOrdersが出てくるよりは日本語で案件一覧と表現したほうが理解しやすい、といった具合です。

f:id:oct_k_kudo:20200616170215p:plain
プルリクのコメント。wが草なのか苦笑いなのかはまだ聞けていません

3はキャストによるオーバーヘッドが気になる人もいるかと思います。Analytics用に作成されるテーブル定義では値の型ごとにデータが格納されるカラムが変わってしまうため2、IDだからNumberだと思っていたらStringになっているのでデータが見つからない!といったトラブルを回避できます(極力一貫したデータを取得したいため、あとから実装ミスに気づいて実装を変更するケースを減らしたいいう意図も含んでいます)。また、String以外で扱いたい場合に必ず集計側で加工するという手間を強制することで、ゴミデータ起因でSQLがある日突然動かなくなるというトラブルを無くせると期待しています。
トレードオフだとは思いますが、今回は少しでも可用性が高くなる方法を優先しました。

データを集計する

BigQuery

Firebaseから連携されたデータはプロジェクト単位かつ日付単位でテーブルが作成されるため、特定アプリに関するn日分の集計ではSQLでwhereやunionが必要です。そこまで複雑なSQLでは無い一方で、テーブルのデータ全てを参照するため3対象テーブルを間違えた際の料金はデータ量を考えると怖いものがあります。

ある程度共通のテーブルを作成すればそこからのデータ取得で実現できるクエリが多そうだったため、共通のビューとそれを使ってOS/アプリ単位のカスタムテーブルを作るクエリを作成し、スケジューリング機能を使って毎朝データの前処理をしています。

必要な日数分だけテーブルを絞り込むビュー用のSQL

SELECT
  -- 他にも必要なカラムは適宜指定する
  CAST(CONCAT(
SUBSTR(event_date, 1, 4), 
'-', 
SUBSTR(event_date, 5, 2), 
'-', 
SUBSTR(event_date, 7, 2) 
) AS date) AS event_date,
  app_info.id AS app_name,
  -- バージョンを数値に変換することで、利用側で判定しやすくしている
  (IFNULL(SAFE_CAST(SPLIT(app_info.version, ".")[
      OFFSET
        (0)] AS INT64),
      0) * 1000000) + (IFNULL(SAFE_CAST(SPLIT(app_info.version, ".")[
      OFFSET
        (1)] AS INT64),
      -- デバッグビルドではX.Y.Z-debugのようなsuffixが付与されるので、それを除外するコード
      0) * 1000) + IFNULL(SAFE_CAST( SPLIT(SPLIT(app_info.version, ".")[
      OFFSET
        (2)], "-")[
    OFFSET
      (0)] AS INT64 ),
    0) AS app_version,
  event_params AS event_params,
  user_properties AS user_properties
FROM
  `********.analytics_**********.events_*` t  -- 一部伏せ字にしています
WHERE
  -- ここで必要な日数分のテーブルを指定する。一旦は7日分
  _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
  AND FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY));

実際のところ、上記クエリの作成中はテスト実行だけで毎回料金が発生しているという事実がそれなりにストレスだったので、何より自分のためにやってよかったと実感しています

f:id:oct_k_kudo:20200616165631p:plain:w500
iOSアプリ用のクエリの実行履歴

Redash

Redashはもともと社内で一部利用実績があったため採用しました。 特に複雑なことはしておらず、クエリをスケジュール実行してダッシュボードで一覧できるようにしています。

先のサンプルコードからどの案件がタップされているか?を集計した場合のSQLを紹介します。BigQueryは配列型がいまだに慣れません

WITH dataSet AS
  (SELECT event_date,

     (SELECT unnest_event_params.value.string_value
      FROM unnest(event_params) unnest_event_params
      WHERE unnest_event_params.key = "screen_name") AS screen_name,

     (SELECT unnest_event_params.value.string_value
      FROM unnest(event_params) unnest_event_params
      WHERE unnest_event_params.key = "content_type") AS content_type,

     (SELECT unnest_event_params.value.string_value
      FROM unnest(event_params) unnest_event_params
      WHERE unnest_event_params.key = "content_id") AS content_id,

   FROM `**********.analytics_**********.custom_table_firebase_analytics_ios_app`
   WHERE event_name = "select_content"
     AND app_version >= 1002003) -- この例だとバージョン1.2.3
SELECT event_date,
       content_id,
       count(*) AS COUNT
FROM dataSet
WHERE screen_name = "案件一覧画面"
GROUP BY event_date,
         content_id
ORDER BY event_date,
         content_id;

Slack通知

RedashのSlack APPの活用も考えましたが、ダッシュボードは利用できないそうなので一旦はリマインダーをセットしてリンクを通知するようにしています。まだ具体的なアクション起こせていませんが、毎朝ダッシュボードを確認する習慣をつけ、皆がデータを意識して案件に取り組めるようになれば良いなと考えています

f:id:oct_k_kudo:20200616172234p:plain

まとめ

エンジニアが主体となって集計できるようになったことで、最近はチーム内でもデータを元に既存機能に関する議論をすることが増えてきました。今後は今のダッシュボードを充実させて総合的に良し悪しの判断できる軸を作っていきたいです。

f:id:oct_k_kudo:20200616175839p:plain:w300

とはいえ、取得したデータはまだ完全に活かしきれていないのが現状です。これからもデータドリブンな開発をしユーザビリティの高いサービスを提供するためには、今まで以上にたくさんの仲間が必要だと考えています。

もし少しでも興味がありましたら弊社採用サイトもご覧になってください。

engineer.andpad.co.jp

最後までお読みいただきありがとうございました


  1. 他の会社さんはこの辺どうしているんですかね?

  2. keyに対して、int_valueやstring_valueとしてカラム定義されています

  3. BigQueryでは最初に参照したデータ量で金額計算が行われるため、whereやlimitは金額面で考慮されません

社名変更して co.jp ドメインを複数保有する技術

SREチーム 鈴木心之介 です。 職歴の空白 を経て参画しました。

先日、株式会社オクト (88oct.co.jp) だった弊社は、社名変更し、株式会社アンドパッド (andpad.co.jp) になりました。社名変更といえば、めちゃくちゃ、それはもう大変な量の仕事がありますが、ここでは社名変更して co.jp ドメインを複数保有する話を書きます。

短い結論

いま保有している co.jp ドメインを、JPDirerct に移管しましょう。JPDirect は、 jp ドメインレジストリである株式会社日本レジストリサービス(JPRS) が運営するレジストラで、1組織1ドメインの緩和申請に対応していることを表明しています。

JPDirectの管理画面で新ドメインを仮登録すると、直ちにDNS設定を組めるようになります。仮登録から6ヶ月以内に社名変更の法人登記を完了し、1組織1ドメイン制限緩和の申請を完了する必要もあります。このため仮登録を何月何日に実施するかは、意外に重要な要素なので、スケジュールを慎重に検討してください。

長い話

弊社は旧社名のドメインを、お名前.comで購入、更新していました。

社名変更の話が本格始動したときに、ドメイン変更についての私を含めて弊社メンバーの認識は、

  • ドメイン取得時は、法人登記やハンコやら書面提出が必要であること
  • 1組織1ドメインの制限があること
  • net や xyz みたくシュッと買えるでしょ。即日で

ヤバいです。誰もわかってないです。炎上プロジェクトです。幸いにして日程はフィックスしていなかったので調べまくりました。

お名前.comのサービスの制限

お名前.comでは、「ドメイン名変更」と「登録者情報の変更(記載事項変更)」によって、新旧ドメインを一定期間、持っておくことができるとわかります。

一定期間については以下に記載があり、特に旧ドメインは6ヶ月で利用できなくなります。また「旧ドメイン名の文字列は廃止後5ヶ月間は登録不可となる」のため、その後は別の法人が取得可能となります。

help.onamae.com

まあそんなもんかと思いつつ、いや待て。旧ドメインは、会社のメンバーのメールアドレスにも使っていて、名刺にも印刷しており、お客様にも広く配っています。仮に、旧ドメイン強制廃止の6ヶ月を過ぎて、登録不可の5ヶ月も過ぎて、ぜんぜん弊社と関わりのない企業が、旧ドメインを取得されたとします。そのとき古い名刺のメアドに、お客様からメールが送られたら、たいへんややこしい事態になります。それは避けたい。

新旧の両ドメインを恒久的に保持したい

お名前.comの制約と向き合うことで、我々の本当の欲求が理解できました。新旧ドメインを、恒久的に保持したいのです。

ところで1組織1ドメインってどこから湧いてきたのですか。所与の制約みたく言ってますけど。その謎を探るべくググってみるものの、数年前の古い情報だったり、真偽の定かでない情報だったり、素性の怪しい業者の怪しいページばかりが引っかかりました。つらい。社名変更ほんとにやるんすか。そんな声が主に私から上がります。

co.jp は 1組織1ドメインしか保有できません、が、、、

ほうぼう調べて、JPRSの属性型JPドメインの規則が出所とわかり、以下の理解に至りました。勘所がなかったので遠回りになりました。

トップレベルドメインのひとつである .jpレジストリは、株式会社日本レジストリサービス(JPRS)です。お名前.comなどはレジストラです。我々平民がレジストラからドメインを購入しますと、レジストラレジストリに、「お名前.comで、株式会社オクトが、 88oct.co.jp を1年購入した。法人登記はコレです」のように情報を上げます。レジストリは、ドメインの利用状況であったり、法人登記の確認を行なったりします。

.jp ドメインは「汎用JPドメイン」。対して co.jp, go.jp, ac.jp などは「属性側JPドメイン」と呼びます。とJPRSが定義しています。

jprs.jp

ページの下のほうに「属性型(組織種別型)・地域型JPドメイン名登録等に関する規則」というリンクがあります。

jprs.jp

「第9条(登録できる属性型地域型JPドメイン名の数)」として、「登録できる属性型地域型JPドメイン名の数は、1組織について1とする」が謳われています。しかし、続く項として、合併、組織名変更、事業譲渡などの場合は、「1組織について2以上の属性型地域型JPドメイン名の登録をすることができる」とも定められています。

JPRSが、属性型jpドメインに関する規則として、そのように定めて運用しているのですね。

ところで、その規則に則って対応してくれるレジストラは?

JPRS直営のレジストラ「JPDirect」

そんな都合のいい組織があるわけが、あったわ。やたら情報の解像度の高いページがあり、問い合わせ窓口もあり、藁にすがる思いで泣きつきました。

jpdirect.jp

料金はこのようで。お名前.comに慣れた金銭感覚からすると、なかなか強気の料金です。

jpdirect.jp

ここで損得勘定です。

実のところ別法人を立てることで、お名前.comに置いたまま新旧ドメインを恒久的に保持することも、論理的に可能です。別法人なら法人登記が異なりますので。

www.onamae.com

決定打を欠き、弊社コーポレート部門の長を巻き込んでみたところ、社名変更で新旧ドメイン保持のためだけに別法人を立てるのと比べたらJPDirect安い!!との声を頂戴し、JPDirectで進めることとしました。

JPDirect にアカウント開設

https://jpdirect.jp の右上「ドメイン管理ページ」から「お客様IDの新規取得はこちら」のリンク先で、アカウント開設ができます。

f:id:sasasin_net:20200608233213p:plain

JPDirect に旧ドメイン移管

いやこれ必要か?と思ったでしょうが、JPDirectが、社名変更にかかる1組織1ドメイン制限緩和の申請の条件として、新旧ドメインをJPDirectに置いていることとあるため、移管してきます。

お名前.comとJPDirectの両方で、所定の手順に則って進めます。だいたい2時間くらいで終わります。お名前.comでのDNS設定を引き継いで、JPDirectに移管されるので、寸断もありません。

help.onamae.com jpdirect.jp

JPDirect で新ドメイン取得

ドメインは、いつでも仮登録で取得でき、DNS設定を組めるようになります。

弊社ではAWS Route 53を使っていますので、新ドメインのHosted Zoneを作り、NSレコードをJPDirect側の新ドメインに入れたら、nslookupなどで開通確認できるようになります。

なお仮登録から6ヶ月以内に「1組織1ドメイン制限緩和の申請」を完了し、本登録も完了する必要があります。

1組織1ドメイン制限緩和の申請

JPDirectが仲立ちして手続きが進みます。社名変更の法人登記手続を終えて、社名変更の事実を証明する書面等を用意して、一部はFAXで送るなど慣れがないと難しいワークフローです。

「法人登記の手続を終え」があるため、あまり早い日に新ドメインを仮登録すると、たいへん難しい状況になる可能性があります。余裕を取りすぎると、新ドメインでのメアドや名刺、コーポレートサイトの制作の日程も組みづらくなるため、仮登録をいつやるか難しさがあります。

ドメインの本登録

ドメインを仮登録した場合は、本登録する必要もあります。こちらは旧ドメインを取得し本登録された時と同様ですが、どのような書類が必要かは、JPDirectにご確認ください。

むすび

社名変更に立ち会う機会は初めてで貴重な経験でした。皆様が円滑に社名変更と co.jp を複数保有されることを祈ります。長文お読みいただきありがとうございました。

WordPressをShifterへ移行した話

はじめに

はじめまして、今年1月からSREチームに加入した千明です。

SREと名乗っているものの、前職ではバックエンドエンジニアが本職だったこともあり、インフラ構築以外にもアプリケーションの改修もしたり、幅広く業務にあたっております。

まだまだSREとしてはひよっこですが、会社に貢献できるように日々努力しているところです。

今回は直近でコーポレートサイトをリニューアルしたこともあり、AWS上で管理していたWordPressをShifterへ移行をした話をしたいと思います。

andpad.co.jp

なぜ移行したのか

主な理由は、WordPressを面倒見るSREの人的コストを削減するためです。

WordPressは本体やプラグイン脆弱性が見つかることがあるため、最新の状態であることが推奨されます。

ですが、WordPressは更新頻度が高く、更新するにも事前検証が欠かせないため、自らが全ての面倒をみると多くの人的コストがかかります。

そうなると、ANDPADの成長にコミットする時間が減ってしまうため、外部サービスへ移行することとしました。

Shifterとは

ShifterはWordPressホスティングサイトの1つで、以下のような特徴があります。

  • WordPressのサイトを静的化したもの(Artifact)をCloudFrontで公開する。
  • Artifactは世代管理されていて、以前のバージョンにロールバックできる。
  • Artifactは公開前にプレビュー環境にて動作確認できる。

www.getshifter.io

ShifterはWordPressを直接公開しないので、WordPress本体やプラグイン脆弱性を心配する必要もないですし、投稿者が更新ミスをしてもプレビュー環境で気づくことができ、間違って公開してもロールバックできるので、SREが面倒を見なくても運用ができる仕組みが揃っています。

どうやって移行したのか

基本的にはAll-in-One WP Migrationプラグインを利用してサイトのバックアップを取って、Shifterへインポートするだけでした。

ja.wordpress.org

ただし、既存サイト側の問題でいくつか改修が必要なケースがありました。

  • 固定ページで動的にページ送りをしている部分が静的化の対象にならず、アーカイブページで作り直した。
  • 投稿アーカイブページを利用するために、functions.phpの中で明示的にページを追加する必要があった。
  • 動的フォームを利用するため、別の外部サービスが必要だった。(弊社ではformrunというサービスを利用しています。)

form.run

以上、移行する際に様々な問題がありましたが、ShifterではShifter-LocalというDockerで動くLocal環境が提供されているので、手軽に事前検証や改修を行うことができました。

github.com

また、どうしても困った時でも問い合わせすると1営業日以内に返信していただけるので、とても助かりました。

移行してどうだったか

まだ移行したばかりでまだ完全に手離れできていませんが、テーマやプラグインの更新作業も投稿者側でできるようになったので、運用が定着すれば、面倒を見ることはなくなりそうです。

更新内容は事前にプレビュー環境で確認できるので、万が一プラグイン側に問題があってもロールバックできるので便利だと思います。

現在取り組んでいること

現在はサイトの品質向上や冗長性を考慮して以下の2点に取り組んでいます。

アクセスログをDatadog Logsで可視化

最近ShifterのアクセスログをS3バケットへ出力する機能が追加されました。

アクセスログをDatadog Logsに投入して可視化することで、サイトの利用状況を分析したいと考えています。

www.getshifter.io

障害時の体制整備

Shifterが何らかの原因でサービス障害が起きてしまっても、ドメインを切り替えてサイトを公開できる体制を整備しています。

ShifterのWebhookを利用して、Artifactを自動で別のホスティングサービスへデプロイして公開する仕組みを検討中です。

おわりに

今回はWordPressの話でしたが、便利な外部サービスを利用することで人的コストを減らせることができました。

ANDPADもまだまだ成長段階で、サービス開発していくために多くの人手が必要なので、SREとして管理の手間が省けてとても助かっています。

ANDPADも建設業界の方々にとって、より便利なサービスにしていきたいですね!

f:id:yuichi-chiaki:20200603132532p:plain

 

ANDPADにおけるソースコードレビューのポイント

はじめに

こんにちは、品質改善チームのrjgeです。
RubyKaigi 2019のレポートぶりに担当が回ってきたので、チーム活動の一環として行なっていたソースコードレビューのお話をしようと思います。


 

そもそもの発端

品質改善チームでは、ANDPADの開発で使用している開発言語やフレームワークのアップデート、パフォーマンスチューニング、自動テストの実装・環境整備など、新規開発を行う際に置いてけぼりにされやすい箇所のフォローを主に行なっています。

ANDPADは比較的新しいプロダクトなどの新規開発箇所についてはレポジトリが徐々に分かれてきているのですが、本体レポジトリはそれなりの規模の入り組んだソースコードになっています。

このレポジトリ、実は私の入社当時(2018年夏頃)は自動テストがほぼなく、そもそも実行しても動かない状態でした。自動テスト書いてるよ!と面接の際に会話したのですが、動かない自動テストはただの負債です。そんなこんなで、当時から今まで色々なメンバーの協力を仰ぎつつ、自動テストは当たり前に書くものだよねという文化をどうにか根付かせる草の根活動をしてきました。

その草の根活動の一環的として、先日のゴールデンウィーク明けまで本体レポジトリの全プルリクエストの総レビューを数ヶ月ほどやっていました。総レビューといっても基本的には各チームでのレビュー後に抜け漏れがないかの最終レビューをしていた形ですが、開発チーム内で一番プロダクトのソースコードを見ていた自信があります。

しかし、順調に開発メンバーが増えたことでプルリクエストの数も増え、しかし私は分身するわけにもいかず…。

一人でできることには限界がある!ということで、誰でもコードレビューができるようにと簡単なチェックリストを作成しました。

 

本題

社内向けに作ったソースコードレビューチェックリストの中で汎用的なもの、レビューする前に意識しておくといいのではないだろうかというポイントをいくつかピックアップして解説してみます。

どういったメンバーがレビュワーをするかによって多少変わると思いますが、基本的には開発メンバーは全員レビュワーができたほうがいいよねという考えのもとでの内容です。

そのため、当然では?という内容もあるかもしれませんが、その「当然」を明確にすることが大切という前提で書いています。

 

確認するプルリクエストに興味を持つ

個人的にはコードレビューって開発に比べるとあまり楽しくないです。どんどん実装したり仕様を考えたりしているときのほうがずっと楽しいのでやらなくていいならやりたくない作業です。

だからといってレビュー文化のない環境で開発したいかと言われるノー。みんなで少しずつ分担しながらいい感じに開発したいですよね。

コードレビューの一番最初の段階は、何をしているのか興味を持つことだと思っています。

個人差はあれど、興味のないものに集中するのは難しいものです。どういう機能なのか、何のために行うのか、どういうアプローチが取られているか。

そういった点を気にしながら見ると、自分の持っていない知識を知れたり、逆に相手にそれを提供できたりということに繋がりやすいように思います。

 

改善提案や指摘の際にはできる範囲で一次ソースかそれに類するものを提示する

忙しいときなど私もついやってしまうのですが、理由や具体例のない指摘はその後のコニュニケーションコストを相手に肩代わりさせることになります。

レビュワー・レビュイー共に習熟しており、お互いのことをよく知っているということであれば問題ないかもしれませんが、チーム人数が多くなればそういうわけにも行きません。

「なぜその指摘をしたのか」や「他にどういったアプローチがあるか」を記載することで不要にコミュニケーションコストを上げずに済みます。

時間は無限ではないので、自分で一次ソースを辿る時間がない場合もあるので、そこはできる範囲でということで!

ちなみに、自分で調べてみてほしいということであれば、ディスコミュニケーションが発生しやすい文字ベースでのやり取りよりも、他の方法のほうがよいかもしれません。

単純に他により良いメソッド等がある場合は、一次ソースを示すと早いです。

OSSを利用している場合はそのソースコードを示すだけで話が済みます。あるいは、手元のコンソールで再現した結果をコメントに添えるのも良いでしょう。

自分で調べたほうが身になるといった気遣いがあるかもしれませんが、そこをコードレビューで担保するかはチーム方針次第かと思います。

教えられたことであっても一次ソースを調べる人は調べますし、自分で調べた結果として一次ソースにたどり着けないまま怪しい記事を鵜呑みにする人もいるので、どういった方法が相手にあっているのかはコードレビューとは別に擦り合わせが必要になるものです。

 

忖度はしない、曲解もしない、一見してわからないものは見返す

忖度力に依存したソースコードは良くないです。
「●●さんだからここはこうやりたくてこういう実装をしたんだろうな」といった具合に考えてしまうコードはかなり忖度力が求められるコードです。

小さなプロダクト・小さなチームであれば、そういう連携が開発速度アップにつながることもありますが、そのまま巨大なプロダクト・チームになっていくと後から加わったメンバーからすると意味がわからなかったり理解するために時間がかかるコードになりやすいです。

フレームワークを利用しているのであれば、標準から大きく外れた方法をとったり、入り組んだ方法を採用するしかない場合には、ドキュメントやコメントの整備をきちんとしておくのが未来のためになると信じています。

ただ、自分(レビュワー側)の理解力が足りていない可能性も考慮する必要があります。

標準的な方法やメソッドを知らないことで理解ができない可能性もあるので、わからないと思ったら調べてみたり少し休憩してからもう一度見てみるというのも必要です。

常に100%の集中力を持続し続けられる人間のほうが少ないので、注意散漫になっていると思ったときは飲み物をとりにいってみたりトイレに行ってみたりして、少し頭をクールダウンさせるのも大切です。

 

コード規約化されていない箇所は戦争が起こらない程度に

改行やインデント、コミットサイズやコミットメッセージの内容、特定の実装方法やコメントの残し方など、開発者によって好みが分かれることがあります。

この辺りは、コード規約化をしていないなら執拗な指摘・議論をレビュー時にはしないほうがお互いのためと思っています。開発全体に関わる内容が特定のプルリクエスト上の井戸端会議で決まってしまうのは避けたいところです。

チームメンバー全員特にこだわりがないということであれば、気になるメンバーが荒れる前に規約化するのもありですし、逆にこだわりがあるメンバーが複数人いるのであればチームで一度話し合うのが良いでしょう。

Lint等も最近は色々な限度で整備されているので、人間が一つ一つ指摘しなくてもbotやエディタにいい感じに指摘させることが容易になっていますしね!

ちなみに弊社も最近rubocopとreviewdogによる自動化が行われたおかげでかなり楽になりました。


おわりに

記事を書いていて、ソースコードレビューというのは、チーム体制やプロダクトに影響を受ける部分も多々あると再認識させられました。

弊社もまだまだより良いやり方を模索していっているところではありますが、少しでもこの記事の内容が誰かの参考になれば幸いです。

私個人としてもソースコードレビューも一つの技術としてスキルを磨いていきたいなと思います。

f:id:yohei-fujii:20200602132256p:plain

 

【データ分析初心者必見】データも専門知識もなくてもクラスター分析をしてみたい!

はじめに

初めまして、ANDPADプロダクト部に所属している谷口と申します。 
普段はプロダクトの要件定義や開発スケジュール管理をしながら、プロダクトの方向性をエンジニアと決めるプロダクトマネジメントの業務をしています。

今回の記事のテーマは「初心者でもできるデータ分析」です。

この「データ分析」に関して執筆するにあたって、安宅和人さんの『シン・ニホン AI×データ時代における日本の再生と人材育成』の記載を紹介したいと思います。

これ(データ分析のリテラシー)はこれからの『読み書きソロバン』なので、高等教育を受けるような人は基本的に全員、少なくとも8割ぐらいの人は学んでおくべきです。

これを読んで、自分は「"全員"か、マジか〜」と思いつつ、最近になって大慌てでデータ分析の勉強をはじめました。
もしかすると同じように「流石にデータ分析について何も知らないのはまずいかも」と考えている人もいるかもしれません。


そこで今回は、

  • 「データ分析に興味はあるけれど、敷居が高いと思って手を出せていない」
  • 「データ分析を勉強したいと思っても、分析対象となるデータがない」

という方向けに、手始めにサンプルデータを揃えてクラスター分析という機械学習の分類手法を試す方法をこの記事でお伝えしたいと思います。

 

かく言う自分もゴールデンウィーク前はPythonを触ったことすらなかったので、気構えずに記事を読んでいただければと思っております。

 

なお、今回の記事は自分が勉強用に使用した『Python実践データ分析100本ノック』を参考にしています。

www.shuwasystem.co.jp

前提

分析のゴール

Python機械学習のライブラリ(scikit-learn)に入っているサンプルデータ「※アヤメのデータ」を使って※クラスター分析を行う。

※紺色の花を付ける多年草の一種
※外的数値から自動的に分類された、一定の特徴を持ったグループのこと。教師なしで自動的に分類されているため、人が気づかないような切り口で分類されることも多く、新しい気付きを得るヒントになる。

必要なライブラリ

Pythonでデータ分析するに当たって一通り必要なライブラリをご紹介します。

・Python3系

MacにはデフォルトでPython2.7が入っているのですが、古いバージョンのため、アップデートする必要があります。

・pandas

データ分析をサポートするためのツールが一通り揃っているライブラリです。

下記のようにデータをエクセルのような表形式に出力することができます。

f:id:taniguchi_toshiokun:20200523190311p:plain

・scikit-learn

機械学習系のライブラリです。

「線形回帰」「決定木分析」「クラスタリング」など聞いたことのある分析は大体このライブラリに一通り揃っています。
今回利用する「アヤメのデータ」のように、サンプルのデータが揃っているので、実データがない方も簡単にライブラリの挙動を試すことができます。

・matplotlib

グラフを描画するのに使用するライブラリです。

・jupyter notebook

下記のようにブラウザ上でPythonを使うことができるライブラリです。

f:id:taniguchi_toshiokun:20200510160557p:plain

 

分析手順

jupyter notebookで「アヤメのデータ」を加工する

scikit-learnの「アヤメのデータ」をインポート

# データ分析をサポートするためのツールが一通り揃っているライブラリ
import pandas as pd

# scikit-learnに付属しているアヤメのデータ
from sklearn.datasets import load_iris

data = load_iris()

# アヤメのデータを集計できるようにpandasのDataFrameに変換
iris_data = pd.DataFrame(data.data, columns=data.feature_names)
print(iris_data.head())

f:id:taniguchi_toshiokun:20200523184413p:plain

 

scikit-learnには"Toy datasets"というデータ分析を学習する用のデータセットが一通り揃っています。

load_boston(ボストンの住宅の価格帯情報),load_boston(ワインの情報)なども用意されていますが、今回は分類のデータセットとしてよく使われるload_iris(アヤメのデータ)を使いたいと思います。
各項目の意味は下記の通りです。

sepal length (cm) がく片の長さ
sepal width (cm) がく片の幅
petal length (cm) 花弁の長さ
petal width (cm) 花弁の幅
各項目の値の標準化

各項目の値はそれぞれ標準偏差が異なるので、利用回数の標準化してからクラスター分析を行います。
標準化にはscikit-learnにあるStandardScalerを利用することができます。

# データセット標準化機能のインポート
from sklearn.preprocessing import StandardScaler
sc = StandardScaler()

#標準化したアヤメのデータ
iris_data_sc = sc.fit_transform(iris_data)

K-means法でクラスター分析

クラスター分析の代表的な手法の一つであるK-means法という方法を使用して、がく片と花弁の特徴からアヤメを5つのクラスターに分けます。

# クラスター分析をするためのKMeansのインポート
from sklearn.cluster import KMeans

kmeans = KMeans(n_clusters=5, random_state=0)
clusters = kmeans.fit(iris_data_sc)

クラスターを分けたら、そのクラスターを元データに項目として割り当てます。

iris_data["cluster"] = clusters.labels_

クラスターの特徴の洗い出し

クラスターごとのアヤメの数を出します

iris_data.groupby("cluster").count()

クラスターごとの各特徴の平均を出します

iris_data.groupby("cluster").mean()

集計結果はこちら。

f:id:taniguchi_toshiokun:20200523184416p:plain

クラスターごとの集計からわかることを整理

クラスターの数値上の特徴を捉えることはできますが、実際にクラスターに所属しているアヤメが「何の品種か」「どういった特性を持っているのか」といったことは分かりません。
クラスター分析は自動的に分類されるため、各クラスターがどのような特徴を指しているのかはあくまで「質的」に判断する必要があります。

クラスターの分類を可視化する

今回分類したクラスターは4つの変数を使用しているので、二次元上で分布を確認したい場合には、次元削除という方法を使う必要があります。
次元削除の代表的な手法である主成分分析を使って、2つの変数でデータを表現できるようにしてみましょう。

#主成分分析
from sklearn.decomposition import PCA
X = iris_data_sc
pca = PCA(n_components=2)
pca.fit(X)
x_pca = pca.transform(X)
pca_df = pd.DataFrame(x_pca)

#グラフで色付けするため
pca_df["cluster"] = iris_data["cluster"]

# グラフ描画のロジック
import matplotlib.pyplot as plt
%matplotlib inline
for i in iris_data["cluster"].unique():
  tmp = pca_df.loc[pca_df["cluster"]==i]
  plt.scatter(tmp[0], tmp[1])


# 凡例の表示
plt.legend()

実行結果は下記の通りです。

 

f:id:taniguchi_toshiokun:20200523185629p:plain

二次元上に各分析単位の分布がプロットできたことによって、「クラスター0とクラスター3」「クラスター1とクラスター2とクラスター4」という単位でグルーピングができることがわかりました。
実際のデータ分析では、このクラスター分析をベースにして

を調査することで、さらに母集団(アヤメのデータ)の特徴を詳細に分析することになります。

最後に

今回の記事ではクラスター分析の触りの部分のご紹介をしました。

思っていたよりも簡単に機械学習を使ってデータをクラスターに分けることができることが分かったのではないでしょうか。

今回はサンプルデータを使ったクラスター分析のご紹介でしたが、実務に応用できる例としては下記のようなケースが挙げられると考えられます。

  • プロダクトマネージャーがユーザーの各機能の利用回数から、今まで見えてこなかったユーザー像を可視化する
  • 営業企画が営業メンバーの成績を様々な項目に分解してクラスター分析をすることで、「優秀な営業マン」の特徴を洗い出し、新人の営業マンの研修プログラム開発に役立てる
  • カスタマーサクセスマネージャーがクライアントのヘルススコアを機械学習をかけることで、「継続するクライアント」はどのようなヘルススコアが高いのかを知り、カスタマーサクセスチームのKPI設定の参考にする

クラスター分析のメリットは、「経験」や「勘」ではなく、利用回数などのデータを使うことで、統計的な根拠を持って分析単位を分類することができるようになることです。しかもご紹介したとおり、機械学習自体は触りだけであればそこまで難しいものではありません。

まずは機械学習で何ができるのか体感するため、scikit-learnにあるデータセットを用いて、jupyter notebookで遊んでみてはいかがでしょうか?

Androidのテストケース名は全て日本語が分かりやすいのでは?

f:id:zigenin:20200526143815p:plain:w128

はじめに

こんにちは。 Andpadのモバイルアプリの開発を担当しているzigeninです。 2019年の11月頃から、モバイルアプリにテストコードを少しずつ追加しています。

Androidでは、どのようなテストケースの名だと読みやすいのか、自分の中では答えが出ました。それは、テストケース名を全て日本語で記述してしまうことです。日本語だと微妙なニュアンスを短く正確に表現できるからです。

テストケース名の具体例

たとえば、以下のkotlinメソッドがあったとします*1

/*
 * Todoリストの項目を渡すと、作業中のタスクの割合と完了したタスクの割合を返す。
 */
internal fun getActiveAndCompletedStats(tasks: List<Task>?): StatsResult {
    return if (tasks == null || tasks.isEmpty()) {
        StatsResult(0f, 0f)
    } else {
        ...
    }
}

このメソッドの引数tasksにnullを渡した場合、(0f, 0f)を返すのが期待動作です。 ここでは、そのテストコードのテストケース名として、3つのスタイルを示します。

  1. 筆者が最近まで書いていたスタイル
    • "testGetActiveAndCompletedStatsReturnsZerosWhenTasksIsNull"
  2. Google CodeLab流儀
    • "getActiveAndCompletedStats_error_returnsZeros"
  3. 全て日本語のスタイル
    • "Todoリストがnullのとき、作業中と完了したタスクの割合は両方0とする"

最初のスタイルは、昔ながらのtest<Method名>というスタイルを意識したものです。今どき、先頭にtestを付けるのはナンセンスらしいです。また、切れ目が良く分からないので可読性が低いです*2

2番目のスタイルは、Google CodeLabの流儀です。1番目に比べると、単語の切れ目が分かりやすく、可読性が高いです。

最後のスタイルは、テスト対象メソッドも含めて全て日本語で書いたものです。他のスタイルよりもテスト内容が分かりやすいと感じています。これを英語で記述しようとしたら、"bothActiveRatioAndCompleteRatio_are_zero_if_tasks_is_null"のようになると思います。この英語の記述だとひと目見ても意味が分からないのと、そもそも英語的に正しいのか謎な点が問題です*3

まとめ

環境が許せば*4、テストケース名は全て日本語で記述するのが分かりやすいと思っています。 最後に、本記事の主張は、まだ個人的な考えの段階です。

補足

JUnit5について

AndpadのAndroidアプリではJUnit4を使っていますが、JUnit5からメソッド名とは別に表示名(@DisplayName)を付けられるようになりました。JUnit5を使っているなら、メソッド名は英語にしておいて、表示名に日本語を付けたほうが良さそうです。

iOSについて

XCTestの仕様上、iOSではテストケース名の先頭に"test"を付ける必要があります。 "test"を先頭に付ければ、日本語も普通に記述できます。 メソッド名に日本語入れるのは嫌だが、日本語の説明文がどこかしらには表示されて欲しいなら、XCTContext.runActivityを使えば実現できます。

*1:元ネタはGoogle CodeLabです

*2:今にしてみれば、camelCaseにこだわらないで、"_"を入れれば、大分ましになったのですが

*3:筆者の英語力の問題な気がしますが

*4:日本語ネィティブの方が多い環境