OTOBANK Engineering Blog

オトバンクはコンテンツが大好きなエンジニアを募集しています!

PHPStan、phpstan-doctrine を 0.12 へと アップデートした

はじめに

弊社のサーバーサイド でのメインプロジェクトでは、過去のブログエントリにもあるように、PHP ならびにORMとしてDoctrine を導入しています。

そして PHPStan をQAでの主な静的解析として利用しており、コードレビュー時の負担を減らすため機械が指摘できることは極力機械で行えるように随時設定の見直し・チェック項目の追加などを行っています。

先月にはプロジェクトでの導入している PHPStan ならびにそのエクステンションのひとつである phpstan-doctrine のバージョンを ^0.12 にやっとのことでアップデートをしました。 その際の対応点と、バージョンをあげたことによる恩恵などについて記したいと思います。

PHPStan 0.12 ならびに phpstan-doctrine とは

PHPStanはよりfeatureを盛り込んだ0.12.0 が 去年12月に リリースされていました。

とりわけ ジェネリクスのサポートは Doctrineのコレクションクラスを多用しているプロジェクトではより型を検査することに活躍できますね。 0.12 でのほかの feature としては以下が挙げられます。

“class-string” 擬似型 でのクラス文字列名のチェック

これは例えば

<?php
/**
 * @param class-string $className
 */
function foo(string $className): void { }

foo('DateTime');
foo(Bar::class); // Class Bar not found.

という風に、主にライブラリでクラス名を指定するようなケースでのミス防止につながります。

phpstan/phpdoc-parserが0.4 にあがったことによる array-shapes (Object-like arrays) へのチェック

※ 正直なところとしては psalmですでにサポートされている array-shapes記法が PHPStan:0.11 ではエラー(Unexpected token)となってムムムと思ってました。

「PHPでの型」と 「Doctrineでのカラム型」の不一致の判定

上述の OndrejMirtesのブログエントリ内にある 「Doctrine extension: compare property type against @ORM\Column definition」の記述の通り、Doctrineで定義されている型と@var アノテーションでのプロパティ型に不一致がある場合警告してくれるようになりました。これは ^0.12 に上げることで最も大きい恩恵と言えるでしょう。

これは主に、nullable=true とカラムを定義していたにも関わらず、@var アノテーションにnullableなユニオン型チェックを定義していない検出に役立ちました。

また、下記のようなdeciaml定義をしたにも関わらず int を記述しているようなケースについても

    /**
     * @var int
     * @ORM\Column(name="latitude", type="decimal")
     */
    private $latitude;

type mapping mismatch\\: database can contain string but property expects int と警告で気づけるようになりました。

また、

<?php
class User
{
    /**
     * @var CreditCard
     *
     * @ORM\OneToOne(targetEntity="CreditCard", mappedBy="user", cascade={"persist","remove"})
     */
    private $creditCard;

のような nullがありうる OneToOneなどのアソシエーションへも

 ------ ----------------------------------------------------------------------------------------------------------
  Line   User.php                                                                                                 
 ------ ----------------------------------------------------------------------------------------------------------
  123    Property User::$creditCard type mapping mismatch: database can contain
         CreditCard|null but property expects CreditCard.
 ------ ----------------------------------------------------------------------------------------------------------

と警告がでるようになりました。

^0.12 に上げるためにしたこと、併せて行ったこと

otobank/phpstan-doctrine-criteria をアップデート

PHPStan 0.12 への変更に追従しているかを確証するために各ユニットテストの追加と 依存する phpstanならびにphpstan-doctrineのバージョンを 0.12 系に上げました。 https://github.com/otobank/phpstan-doctrine-criteria/pull/1

不足していたプロパティ指定の追加。

phpstan-doctrineを 0.12 に上げた際に、主に対応量が多かったのは、下記のような OneToManyなプロパティについて、

<?php
class Audiobook
{

    /**
     * @var \Doctrine\Common\Collections\Collection
     *
     * @ORM\OneToMany(targetEntity="Artwork", mappedBy="audiobook", cascade={"persist","remove"}, orphanRemoval=true)
     */
    private $artworks;
 ------ -----------------------------------------------------------------------------------------------------------
  Line   Audiobook.php                                                                                             
 ------ -----------------------------------------------------------------------------------------------------------
  217    Property Audiobook::$artworks type mapping mismatch: property
         can contain Doctrine\Common\Collections\Collection but database expects
         Doctrine\Common\Collections\Collection&iterable<Artwork>.
 ------ -----------------------------------------------------------------------------------------------------------

と警告が出る点でした。 これはもちろんそのプロパティを参照するさいに(foreachで回す場合など)はどのエンテイティを取得しているか必要となるため、これは警告通り iterable<エンテイティクラス>なユニオン指定を地道に追加していきました。

<?php
class Audiobook
{

    /**
     * @var \Doctrine\Common\Collections\Collection&iterable<Artwork>
     *
     * @ORM\OneToMany(targetEntity="Artwork", mappedBy="audiobook", cascade={"persist","remove"}, orphanRemoval=true)
     */
    private $artworks;

プロパティタイプヒントを必須に

上述の"「PHPでの型」と 「Doctrineでのカラム型」の不一致の判定"は、各プロパティの型が検出できなければいけません。PHPStan でのlevel 6 では(主にアノテーションでの)タイプヒントに関しての各ルールが追加され、この中の MissingPropertyTypehintRule によってタイプヒント指定されているかを検出できます。

また、phpstan.neonでのパラメータで inferPrivatePropertyTypeFromConstructorをtrueにすればコンストラクタでの型指定からプロパティの型補足する方法もあります。 しかしながら、現在のコードベースでのタイプヒント全般については、継続的に都度記述を行って改善している段階であります。

そのため、プロパティタイプでの型宣言を必須とするチェックには、PHP_CodeSniffer 用の追加ルール slevomat/coding-standard での SlevomatCodingStandard.TypeHints.PropertyTypeHint Sniff を用いて コーディング規約チェックとして検出するようにしました。

まとめ

この記事では、PHPStan 0.12 を導入した点でも特に、phpstan-doctrine での利点/対応点についてふれました。 ここ最近の PHP静的解析シーンでの注目は、強力な擬似型のサポートであるわけですが、今回メインプロジェクトにて PHPStanを0.12に上げたことにより、その効果によりバグの減少やテストの強化につながる土台を整えらえて安堵しています。

React NativeアプリでSiri Media Intentを実装した話

初めまして、アプリ開発を担当している エモト と申します。手元にSHIROBAKOのムビチケがあるのに、昨今の事情で映画館に行けず、モヤモヤしております。昨年秋に入社して、いつかはブログを書こうと思って気付いたら今日となりました。よろしくお願いします。

さて昨年末、弊社のiOSアプリは Siri Media Intents に対応しました。

「Hey Siri, {作品名}をオーディオブックで再生して」

と呼びかけると、Siri経由でアプリのライブラリ内にあるオーディオブックが再生されます。ぜひお試しください。

弊アプリは、React Native(以降、RN)でアプリ開発を行っています。クロスプラットフォーム開発のRNでSiriが実装できるの!?と思う方もいるかと思います。RNでのSiri開発を少し話したいと思います。

なお、以降文中のネイティブ開発は、Xcodeを使ったSwiftやObjective-Cでのアプリ開発を指します。

Siri Media Intents と React Native

一般的なネイティブ開発での Siri Media Intents の実装は、Siriが命令を受けると、Intent側の handle(intent:completion:) からアプリ側のメソッド application(_:handle:completionHandler:) が呼ばれます。そのメソッド内でアプリ側の処理(今回はオーディオブックの再生)を行い、Siriへ完了コールバックを送ります。ここで、弊社アプリはRNを採用しているため、RN側で処理を行う必要があります。

RNには、SwiftやObjective-Cで書かれたネイティブモジュールを用いて、ネイティブ 側とやりとりできる機能があります。それを利用して、Siriから受けた情報をRNに送り、RNで再生制御を行いました。

f:id:mitsuharu_e:20200401134736p:plain

最も苦労した点の1つは、RN側の再生処理の結果を application(_:handle:completionHandler:) の中で受け取らなといけないことです。ネイティブからRNへにイベントを送る処理は情報を送るだけで、再生完了コールバックのデリゲートやクロージャを設定することはできません。あまりスマートではないですが、RN側で処理が完了したらネイティブ側に完了フラグを通知する、ネイティブ側はそのフラグが立つまで待つというアプローチを取りました。

// React-Nativeからの完了を受け取る
var isSucceededFromRN: Bool?

@objc( SiriCtrl )
class SiriCtrl: RCTEventEmitter {
}

extension SiriCtrl {

  // React-NativeからNative methodとして、Intentの完了メソッドを呼ぶ
  @objc(completeSiriHandler:)
  func completeSiriHandler(isSucceeded: Bool) {
    DispatchQueue.global().async {
      isSucceededFromRN = isSucceeded
    }
  }
  
   /**
   AppDelegate の application(_:handle:completionHandler:) 
   の中で用いるメソッド
   */
  func application(_ application: UIApplication,
                   handle intent: INIntent,
                   completionHandler: @escaping (INIntentResponse) -> Void) {
    if #available(iOS 13.0, *) {
      guard
        let playMediaIntent = intent as? INPlayMediaIntent,
        let mediaItem = playMediaIntent.mediaItems?.first,
        let title = mediaItem.title
        else {
          completionHandler(INPlayMediaIntentResponse(code: .failure,
                                                      userActivity: nil))
          return
      }

      // 完了判定フラグを初期化する
      isSucceededFromRN = nil

      // React Nativeに再生イベントを投げる
      self.sendMediaInfoToReactNative(title: title)

      // RNから完了判定が帰ってくるまで待つ
      self.waitAsyncCallback({
        isSucceededFromRN == nil
      }) {
        var code: INPlayMediaIntentResponseCode = .failureRestrictedContent
        if let result = isSucceededFromRN, result == true {
          code = .success
        }
        completionHandler(INPlayMediaIntentResponse(code: code,
                                                    userActivity: nil))
      }
    }
  }
}

extension NSObject {

  /**
   処理完了を待ってから、後続処理を行う
   */
  func waitAsyncCallback(_ waitContinuation: @escaping (() -> Bool),
                         completion: @escaping (() -> Void)) {
    var wait = waitContinuation()
    let semaphore = DispatchSemaphore(value: 0)
    DispatchQueue.global().async {
      while wait {
        DispatchQueue.main.async {
          wait = waitContinuation()
          semaphore.signal()
        }
        semaphore.wait()
        Thread.sleep(forTimeInterval: 0.01)
      }
      DispatchQueue.main.async {
        completion()
      }
    }
  }
}

非同期処理の待ち処理は、こちらの記事 【Swift 3】処理の完了を待ってから後続処理を行う - Qiita を参考にしました。ありがとうございます。

まとめ

React Nativeと聞くと、SwiftやKotlinなどのネイティブコードは不要のイメージがあるかもしれません(私も最初はそうでした)。しかしながら、今回のような固有機能の実装時には適宜ネイティブコードを使用しています。

最後に、オトバンクではエンジニアを募集中です。もし、日頃はSwiftやKotlinを書かれてる方でも、オーディオブックや、React Native開発に少しでも興味があれば、是非どうぞ。

お待ちしております。

Sign In with Apple サーバ実装時に悩んだポイント〜名前・emailが取得できない編〜

こんにちは。 コロナウイルス対策で引きこもり属性が高まっているサーバエンジニアyukimuraです。

突然ですが、アプリのソーシャルログインといえば何が思い浮かびますか?


Facebook?


Google?


Apple?





あれ、Appleのサインインボタンってあんまり見たことないな〜と思った方はいないでしょうか?


それもその筈、Appleのソーシャルログインは、2019年6月3日にAppleが発表した新機能なんです。

このAppleによるソーシャルログイン、「Sign In with Apple」ですが、既にApple以外のソーシャルログインを実装しているIOSアプリは、2020年6月30日までに実装する必要があります。(2020/3/30現在)
※ 改定の可能性があるため、最新の情報はAppleのガイドラインを確認してくださいね。

ですので今後、Sign In with Appleを実装したアプリが増えていくことと思います!

弊社のアプリaudiobook.jpもただいまSign In with Appleを絶賛実装中です😁
audiobook.jpにはIOSアプリ・androidアプリ・webアプリがありますので、それぞれに実装を行なっています。 私はサーバサイドエンジニアとして認証api・webアプリのサインイン機能の実装に携わっております。

これから実装する方もいらっしゃるのではと思いますので、私が実装時に悩んだ内容を残しておこうと思います!

名前・メールアドレスが取得できず困る

まず、webアプリケーションにsiwaを実装する場合、認証用のURLを用意する必要があります。 (Sign In with Apple JSを利用する場合はパラメータをセットするだけで自動的にURLが生成されますが、利用しない想定です。)

URLに必要なクエリパラメータについては公式ページに記載があります。 https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms

さて、URLを組み立ててAppleの認証画面で認証すると、認証したユーザの名前とメールアドレスが取得できるはず・・。

ということで以下のようなURLを作成して試してみました。

https://appleid.apple.com/auth/authorize

クエリパラメータ
  client_id = xxx (アプリ毎に異なるid)
  redirect_uri = xxx
  response_type = code id_token
  response_mode = form_post

認証の結果、以下認可codeとIDトークンを取得できました。

code: xxxxxxx
id_token:yyyyyy

取得したIDトークンをデコードすると・・(値はサンプルです)

{
  "iss": "https://appleid.apple.com",
  "aud": "xxx",
  "exp": 1234567890,
  "iat": 1234567890,
  "sub": "xxx",
  "c_hash": "123456",
  "auth_time": 1234567890,
  "nonce_supported": true
}

email・名前は含まれていませんね。 よくURLを見てみると、scopeを設定していなかったためscopeを指定して再試行します・・

https://appleid.apple.com/auth/authorize

クエリパラメータ
  client_id = xxx (アプリ毎に異なるid)
  redirect_uri = xxx
  response_type = code id_token
  response_mode = form_post
  scope = email name

しかしレスポンスされるデータ構造には変化がなく、email・名前が取得できません・・!

原因

Appleとの初回認証時にscopeを設定しておく必要がありました。 2回目以降の認証だと、scopeを設定しても効果はありません。

初回認証時にscopeを設定している場合は以下のような結果が取得できます。

code: xxxxxxx
id_token:yyyyyy
user: {
  "name":{"firstName":"太郎","lastName":"音羽"},
   "email":"otohatarou@example.com"
}

userというフィールドが追加され、名前とemailが取得できました!

取得したIDトークンをデコードすると・・(値はサンプルです)

{
  "iss": "https://appleid.apple.com",
  "aud": "xxx",
  "exp": 1234567890,
  "iat": 1234567890,
  "sub": "xxx",
  "c_hash": "123456",
  "email": "otohatarou@example.com",
  "email_verified": "true",
  "auth_time": 1234567890,
  "nonce_supported": true
}

IDトークンにもemailが含まれています。 なお、Appleで初回認証時にメールの共有・非公開を選択できますが、共有を選択した場合にはemail_verifiedtrue、非公開を選択した場合にはfalseになります。

注意点として、このscopeを設定していた場合でも2回目以降の認証の場合userフィールドは取得できません・・。 2回目以降でもemailはIDトークンに含まれているため、2回目以降で取得可能なのはemailのみとなります。

ユーザが初回認証後操作をキャンセルして再認証した場合、名前は取得できなくなりますので、名前を取得できないことも考慮して実装を行う必要があります。
Facebookのようにユーザ情報を取得するUserInfo Endpointがあれば良いのですが、今のところ残念ながらそのようなapiは用意されていないんですよね・・。

再度初回認証を試したい場合

AppleIDのアカウントページから自分のアプリの紐付け情報を削除すれば、再度初回認証の確認ができます。 https://appleid.apple.com/#!&page=signin

ログイン後、以下手順で紐付け情報を削除できます。

> 「セキュリティ」を選択
> 「Apple IDを使用しているAppとWebサイトの管理… 」を選択
> ご自分のアプリを選択
> 「Apple Idの使用を停止」をクリック

終わりに

ソーシャルログインの実装に慣れている方であればすぐに原因が分かる内容かもしれませんが、認証系の実装は初めてだったため原因が分からず苦労しました・・!

この内容がお役に立てたようでしたら嬉しいです😊