2016.09.02
iOS Walletを作ってみよう-後編(Wallet管理サーバーを作ってみました)
こんにちは。GMOインターネットの次世代システムのJ.Lです。
iOS Walletを作ってみよう-前編に続けて、今回はWebサービスを利用して「Wallet」を配信、及び更新を行う方法について説明します。(前編からかなり時間が経ってしまって申し訳ございません。)
ただの説明だとつまらないと思いますので、以下のシナリオに沿って説明します。
ちなみに、利用するAPIは以下のページにて詳しく説明されていますので、参考にしてください。
シナリオ
- ある店舗にて新規会員登録を行います。
- 会員を登録するとその会員宛にWallet登録用のURLが届きます。
- 会員はそのURLから自身のデバイスにWalletをインストールします。
- 店舗の管理者が会員にポイントを付与すると会員のWalletのポイントが自動で更新されます。
- 会員がWalletを削除すると店舗の管理者はその状態を管理画面から確認できます。
始める前に
Wallet配信がメインですので、会員管理などに関しての説明はありません。そして、Walletを利用して会員のポイントを管理する方法に関しての説明です。その他のWalletの利用については以下のページを参照してください。
そして、iOSの仕様上全てのリクエストは「https」である必要があります。
構成
全体構成は以下の次の図の通りです。
- DB
- 各種情報を閲覧および保存
- Storage
- 作成したWalletのバイナリを保持
- Web Server
- デバイスからのリクエストを処理
- APNs
- デバイスへプッシュ通知を行う
- デバイス
- Walletをインストールし、ユーザがWalletを情報を閲覧できる
DBテーブル構成
- 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が届いた際には以下の作業を行うべきです。
- serialNumberから会員情報登録時に作成したSerial番号を検索し、紐付いているWallet情報を検索します。
- 検索したWallet情報から会員情報を取得できます。
- deviceLibraryIdentiferですでにデバイスが登録されているか確認します。登録されている場合は、200を返します。
- 上で登録されてない場合は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情報の更新日を必ず保存してください。情報を更新した後はデバイスに更新を知らせるために、プッシュ通知を行います。この際にデバイス情報に保持していたプッシュトークンを利用します。プッシュ通知の具体的な方法は以下もしくはサンプルを参照してください。
重要なポイントだけ説明しますと、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が届いた際には以下の内容を行うべきです。
- この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が届いた際には以下の内容を行うべきです。
- 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が届いた際には以下の内容を行うべきです。
- serialNumberからWallet情報を探し、そのWalletに紐付いているデバイス情報を取得します。取得したデバイス情報のdevice_identifierとdeviceLibraryIdentifierが一致する場合は、該当のレコードを削除します。
- 重要なポイントは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が正式に日本でサービス開始するともっと利用する場面が増えるのでと思いますので、この辺の技術もチェックしておきましょう。
次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧からご応募をお願いします。
グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。
Follow @GMO_RD