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


