2016.09.02

iOS Walletを作ってみよう-後編(Wallet管理サーバーを作ってみました)

Pocket

こんにちは。GMOインターネットの次世代システムのJ.Lです。

iOS Walletを作ってみよう-前編に続けて、今回はWebサービスを利用して「Wallet」を配信、及び更新を行う方法について説明します。(前編からかなり時間が経ってしまって申し訳ございません。)

ただの説明だとつまらないと思いますので、以下のシナリオに沿って説明します。

ちなみに、利用するAPIは以下のページにて詳しく説明されていますので、参考にしてください。

PassKit Web Service Reference

シナリオ

  1. ある店舗にて新規会員登録を行います。
  2. 会員を登録するとその会員宛にWallet登録用のURLが届きます。
  3. 会員はそのURLから自身のデバイスにWalletをインストールします。
  4. 店舗の管理者が会員にポイントを付与すると会員のWalletのポイントが自動で更新されます。
  5. 会員がWalletを削除すると店舗の管理者はその状態を管理画面から確認できます。

始める前に

Wallet配信がメインですので、会員管理などに関しての説明はありません。そして、Walletを利用して会員のポイントを管理する方法に関しての説明です。その他のWalletの利用については以下のページを参照してください。

Wallet Developer Guide

そして、iOSの仕様上全てのリクエストは「https」である必要があります。

構成

全体構成は以下の次の図の通りです。

構成
  • DB
    • 各種情報を閲覧および保存
  • Storage
    • 作成したWalletのバイナリを保持
  • Web Server
    • デバイスからのリクエストを処理
  • APNs
    • デバイスへプッシュ通知を行う
  • デバイス
    • Walletをインストールし、ユーザがWalletを情報を閲覧できる

DBテーブル構成

tables
  • WalletとDeviceは1:nの関係です。
  • member_idは会員テーブルと紐付けるフォーリンキーの想定です。

会員情報登録とWallet配信

仮に店舗の管理者が用意しているWebの登録フォームから会員が登録を行うと想定します。その際に会員に対してWalletを発行するためにはまず、ユニークなSerial番号を発行し、Wallet情報としてDBに保存します。そして、Serial番号をベースにWalletのバイナリも作成します。バイナリの作成についてはiOS Walletを作ってみよう-前編を参考してください。

登録された会員に対してWalletバイナリがダウンロードできるURLを配信をします。配信はWebページリンクもしくはメールのリンクが一般的です。

会員は届いた配信用のURL(下のSampleの場合は「https://host/wallet/:member_id」)を開きます。するとWebサーバーはリンクに該当するWalletバイナリをResponseに乗せて返します。その際にResponse HeaderのContent-Typeは必ず「application/vnd.apple.pkpass」である必要があります。Walletバイナリの中身に問題なければデバイスは勝手にWalletインストール画面が表示されます。

以下サンプルです。(RubyのSinatraを利用してます)
  configure do
    mime_type :pkpass, 'application/vnd.apple.pkpass'
    register Sinatra::Reloader
  end

  before do
    @db = Database.new()
    @logger = Logger.new('./log/wallet.log')
    @pass_type = '<Apple Developerに登録したPass Identifier>'
    @auth_token = '...' # memberごとに発行する必要がある
  end

  get '/wallet/:member_id' do
    @logger.info "Walletバイナリ取得 ===============>"
    @logger.info "  Member Id: #{params[:member_id]}"

    member_id = params[:member_id]
    serial_number = ''
    path = ''
    result = @db.findWalletByMemberId(member_id)  # DBからmember_idにひもづくWalletデータを取得
    if 0 < result.count
      # すでにバイナリが存在する場合
      tmp = result.first
      serial_number = tmp['serial_number']
      path = "./wallets/#{serial_number}.pkpass"
    else
      serial_number = `uuidgen`.chomp.upcase
            # Walletデータ追加
      @db.addWallet(params[:member_id], serial_number, @pass_type, @auth_token)
      # Walletバイナリ生成
      make_wallet = MakeWallet.new(member_id, serial_number, @pass_type, @auth_token, 0)
      path = make_wallet.make
      @logger.info "  ======== Wallet作成:#{path}"
    end
    send_file(path, :type => :pkpass)
  end

Walletインストール

ここからが本番です。会員がWalletをインストールすると早速デバイス登録依頼APIがデバイスからWebサーバーへ届きます。デバイス登録依頼APIのURIは以下の通りです。

Method:POST

URI:webServiceURL/version/devices/deviceLibraryIdentifier/registrations/passTypeIdentifier/serialNumber

(イタリックフォントの文字はデバイスから値が設定される部分です)

そしてBodyにはデバイスへプッシュを送る際に必要なTokenが設定されています。このAPIが届いた際には以下の作業を行うべきです。
  1. serialNumberから会員情報登録時に作成したSerial番号を検索し、紐付いているWallet情報を検索します。
  2. 検索したWallet情報から会員情報を取得できます。
  3. deviceLibraryIdentiferですでにデバイスが登録されているか確認します。登録されている場合は、200を返します。
  4. 上で登録されてない場合はBodyからプッシュトークを取得し、deviceLibraryIdentifierと一緒に保存します。そして、上で検索したWallet情報と1:n紐付けるべきです。理由は会員一人が複数のデバイスを持つことも十分にありうるためです。
  post '/v1/devices/:device_id/registrations/:pass_type/:serial_number' do
    @logger.info "新しいデバイスの登録依頼 ===========>"
    @logger.info "  Device Id :#{params[:device_id]}"
    @logger.info "  Pass Type :#{params[:pass_type]}"
    @logger.info "  Serial Num:#{params[:serial_number]}"
    @logger.info "  Auth Token:#{authentication_token}"

    json_body = JSON.parse request.body.read
    push_token = json_body['pushToken']

    status registerDevice(params[:device_id], params[:pass_type], push_token, params[:serial_number], authentication_token)
  end

  def registerDevice(device_id, pass_type, push_token, serial_number, auth_token)
    return 401 if not auth_token || auth_token.length < 1
    res = @db.findWallet(serial_number)
    return 401 if res.count < 1 # 未登録のWallet
    wallet = res.first
    return 401 if auth_token != wallet['auth_token']
    res = @db.findDeviceByWalletId(wallet['id'])
    if res.count < 1
      # 新規登録
      @logger.info " ==> デバイス新規登録 <=="
      @db.registerDevice(wallet['id'], device_id, push_token)
      return 201
    end
    return 200
  end

Wallet更新

店舗の管理者がポイントなどを更新し、Walletを更新したい場合があると思います。その際にはまず、会員情報を更新後、紐付いているWallet情報を更新します。この際に後からWalletの更新日時を知らせる必要があるため、Wallet情報の更新日を必ず保存してください。情報を更新した後はデバイスに更新を知らせるために、プッシュ通知を行います。この際にデバイス情報に保持していたプッシュトークンを利用します。プッシュ通知の具体的な方法は以下もしくはサンプルを参照してください。

Wallet Developer Guide

重要なポイントだけ説明しますと、APNsへ接続する際のクライアント証明書はWalletバイナリへ署名する際に利用する証明書と同じです。そして、プッシュ通知のメッセージ本文は空のDictionaryで設定してください。他はアプリへの通知する時に設定する値と変わらないですので、そちらを確認してください。

以下は会員のポイントを更新すると該当のWalletを更新するサンプルです。
post '/member/:member_id/point/:point' do
    @logger.info "会員ポイント更新 ===============>"
    @logger.info "  Member Id:#{params[:member_id]}"
    @logger.info "  Point    :#{params[:point]}"

    # member_idに紐付いているWalletを取得
    res = @db.findWalletByMemberId(params[:member_id])
    if 0 < res.count
      wallet = res.first
      if wallet['point'] == params[:point]
        status 200
      else
        # ポイントを更新する
        @db.updateMemberPoint(params[:member_id], params[:point])
        # Walletバイナリも更新する
        make_wallet = MakeWallet.new(params[:member_id], wallet['serial_number'], @pass_type, @auth_token, params[:point])
        path = make_wallet.make
        @logger.info "  ======== Wallet再作成:#{path}"
        # 該当するWalletに紐付いているデバイスにプッシュ処理を行う
        res = @db.findDeviceByWalletId(wallet['id'])
        res.each do |device|
          # の修正バージョン
          ApnsNotification.sendNotification(device['push_token'])
        end
      end
    end
  end
APNsプッシュのサンプルは「https://github.com/jpoz/APNS」を少し改造しています。
$ cat notification.rb
module ApnsNotification
  require 'openssl'

  class Notification
    attr_accessor :token, :priority
    attr_accessor :message_identifier, :expiration_date

    def initialize(token)
      self.token = token
      self.message_identifier ||= OpenSSL::Random.random_bytes(4)
      self.priority = 10
      self.expiration_date = 0
    end

    def packagedNotification
      pt = self.packagedToken
      pm = self.packagedMessage
      pi = self.message_identifier
      pe = (self.expiration_date || 0).to_i
      pr = (self.priority || 10).to_i

      data = ''
      data << [1, pt.bytesize, pt].pack("CnA*")
      data << [2, pm.bytesize, pm].pack("CnA*")
      data << [3, pi.bytesize, pi].pack("CnA*")
      data << [4, 4, pe].pack("CnN")
      data << [5, 1, pr].pack("CnC")
    end

    def packagedToken
      [token].pack('H*')
    end

    def packagedMessage
      {}.to_json
    end
  end
end
$ cat apns.rb
module ApnsNotification
 require 'socket'
 require 'openssl'
 require 'json'
 require './lib/notification'

 @host = "gateway.push.apple.com"
 @port = 2195
 @cert = <プッシュ証明書のパス>
 @pkey = <プッシュ証明書の秘密鍵のパス>
 @pass = <鍵のパスワード>

 class << self
   attr_accessor :host, :cert, :pkey, :port, :pass
 end

 def self.sendNotification(token)
   sock, ssl = self.openConnection

   packed_notification = self.packagedNotification(token)
   ssl.write(packed_notification)
   ssl.close
   sock.close
 end

 def self.openConnection
   raise "The path to your pem file is not set. (APNS.pem = /path/to/cert.pem)" unless self.cert
   raise "The path to your pem file does not exist!" unless File.exist?(self.cert)

   context = OpenSSL::SSL::SSLContext.new
   context.cert = OpenSSL::X509::Certificate.new(File.read(self.cert))
   context.key = OpenSSL::PKey::RSA.new(File.read(self.pkey), self.pass)

   sock = TCPSocket.new(self.host, self.port)
   ssl = OpenSSL::SSL::SSLSocket.new(sock,context)
   ssl.connect

   return sock, ssl
 end

 def self.packagedNotification(token)
   notification = ApnsNotification::Notification.new(token)
   pn = notification.packagedNotification
   bytes = ''
   bytes << ([2, pn.bytesize].pack('CN') + pn)
 end
end
サーバーからプッシュ通知を行うとデバイスからWallet更新可否確認のAPIリクエストが届きます。

Method:GET

URI:webServiceURL/version/devices/deviceLibraryIdentifier/registrations/passTypeIdentifier?passesUpdatedSince=tag

このAPIが届いた際には以下の内容を行うべきです。
  1. このAPIは該当の端末にてインストールされている全てのWallet(Serial番号ベース)に対する更新可否を送るべきです。ですので、deviceLibraryIdentifierからデバイスを検索し、そのデバイスと紐付いている全てのWalletを検索し、それぞれの更新時刻を返します。(JSON BODY)
  get '/v1/devices/:device_id/registrations/:pass_type?' do
    @logger.info "Wallet更新可否情報依頼 ===========>"
    @logger.info "  Device Id   :#{params[:device_id]}"
    @logger.info "  Pass Type   :#{params[:pass_type]}"
    @logger.info "  Update since:#{params[:passesUpdatedSince]}"

    # デバイスに紐づくWallet一覧を取得(複数のWalletを運用する場合でも対応できるように)
    res = @db.findWalletByDeviceId(params[:device_id], params[:passesUpdatedSince])
    if 0 < res.count
      update_time = lambda{Time.now}.call
      res_data = {:updated_time => update_time}
      res_data[:serialNumbers] = res.map {|wallet| wallet['serial_number']}
      res_data.to_json
    else
      status 204
    end
  end
もし、上のURIの返信にて送った時刻から更新する必要があると判断されると以下のWallet更新APIリクエストが届きます。

Method:GET

webServiceURL/version/passes/passTypeIdentifier/serialNumber

このAPIが届いた際には以下の内容を行うべきです。
  1. serialNumberから紐付いているWallet情報を検索します。Wallet情報から該当のWalletバイナリをResponseで返します。(インストール時と同じくContent-Typeなど設定する必要はあります。)
  get '/v1/passes/:pass_type/:serial_number' do
    @logger.info "Walletデータ更新依頼 =========>"
    @logger.info "  Pass Type :#{params[:pass_type]}"
    @logger.info "  Serial Num:#{params[:serial_number]}"
    @logger.info "  Auth Token:#{authentication_token}"

    # Serial番号からWallet取得
    res = @db.findWallet(params[:serial_number])
    if res.count < 1
      @logger.error "  該当のWalletデータがありません"
      status 401
    else
      wallet = res.first
      if wallet['pass_type_identifier'] != params[:pass_type] || wallet['auth_token'] != authentication_token
        @logger.error "  情報が正しくありません。"
        status 401
      else
        # Walletバイナリを返す
        filename = "./wallets/#{params[:serial_number]}.pkpass"
        @logger.info "  Walletデータ更新成功:#{filename}"
        send_file(filename, :type => :pkpass)
      end
    end
  end

デバイス登録解除

会員が自身のデバイスからインストールしていたWalletを消すとデバイス登録解除APIリクエストが届きます。

Method:DELETE

webServiceURL/version/devices/deviceLibraryIdentifier/registrations/passTypeIdentifier/serialNumber

このAPIが届いた際には以下の内容を行うべきです。
  1. serialNumberからWallet情報を探し、そのWalletに紐付いているデバイス情報を取得します。取得したデバイス情報のdevice_identifierとdeviceLibraryIdentifierが一致する場合は、該当のレコードを削除します。
  2. 重要なポイントはWallet情報削除しないことです。なぜならば、ユーザーは複数の端末でWalletを入れている場合もありますので、Wallet情報を削除するタイミングは紐付いている会員情報を削除する時になります。
  delete '/v1/devices/:device_id/registrations/:pass_type/:serial_number' do
    @logger.info "デバイス登録解除依頼 ===========>"
    @logger.info "  Device Id :#{params[:device_id]}"
    @logger.info "  Pass Type :#{params[:pass_type]}"
    @logger.info "  Serial Num:#{params[:serial_number]}"
    @logger.info "  Auth Token:#{authentication_token}"

    res = @db.findWallet(params[:serial_number])
    if res.count < 1
      @logger.error "  Walletデータなし!"
      status 401
    else
      wallet = res.first
      if wallet['pass_type_identifier'] != params[:pass_type] or wallet['auth_token'] != authentication_token
        @logger.error "  データが正しくありません。"
        status 401
      else
        res = @db.findDeviceByWalletId(wallet['id'])
        if res.count < 1
          @logger.error "  Deviceデータなし!"
          status 401
        else
          res.each do |device|
            @db.deleteDevice(device['id']) if device['device_identifier'] == params[:device_id]
          end
        end
      end
    end
  end

ログ取得

デバイス側にエラーが発生した場合は以下のリクエストでエラーログをサーバーに送信します。サーバーはログを受診しデバイス状態を確認することが可能です。

Method:POST

webServiceURL/version/log

リクエストボディはJSON形式になっています。詳細は以下のサンプルを確認してください。


  post '/v1/log' do
    json_body = JSON.parse request.body.read
    json_body['logs'].each do |log|
      @logger.error log
    end
  end

まとめ

ここまで、WebサービスにてWalletの管理方法について説明しました。小規模のお店などはWalletを利用してポイント管理するのもありかと思います(Android端末でもWalletがインストールできるアプリもあります)。そして、今後ApplePayが正式に日本でサービス開始するともっと利用する場面が増えるのでと思いますので、この辺の技術もチェックしておきましょう。

 

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧からご応募をお願いします。