iOS アプリの位置情報から AWS Lambda で GeoHash を作成するサーバーレスアプリケーション

こんな構成のやつを作ります。

arch.jpg

動機

位置情報を扱う場合、素の緯度・経度のままでは扱いにくいことがあります。例えば、

  • 特定の範囲に存在するデバイス一覧を抽出したい
  • ある店舗の周辺にあるデバイスを検知したい

といったユースケースです。手間と精度の相談になりますが、いくつか方法があります。

  • 頑張って計算する
  • MySQL の geometry型を使う
  • Elasticsearch の Geolocation を使う
  • GeoHash を計算し、ハッシュで抽出する

このうち、精度はやや落ちるものの、特定のデーターベースに依存せす、文字列で表現でき、サーバーレスと相性の良さそうなGeoHashを計算してみます。GeoHashが計算できれば、保存されたGeoHashと一致する緯度経度が抽出できそうです。以下の手順で進めます。

  1. サンプル iOS アプリケーションを用意する
  2. アプリで位置情報が取得できるようにする
  3. アプリで AWSの認証トークンを取得できるようにする
  4. Kinesis Streams とつなぎ、位置情報を送信する
  5. GeoHash を計算する Lambda Function

サンプル iOS アプリケーションを用意する

AWS が提供してくれているサンプルアプリケーションを使います。

こちらのブログでも利用したアプリです。ForkしてPUSH通知の周りを修正した分をGitHubにあげていますのでそれも利用できます。

cloneして、pod install後、Xcode で開いてください。

アプリで位置情報が取得できるようにする

LocationManager の実装

こちらのブログを参考に、位置情報を取得する LocationManager を実装していきます。

import UIKit
import CoreLocation

class LocationManager: NSObject {

    var locationManager: CLLocationManager?
    var analyticsService: AnalyticsService? // Amazon Pinpoint の endpointId=デバイス識別ID をキーとして利用するため

    init(analyticsService: AnalyticsService) {
        let lm = CLLocationManager()
        lm.desiredAccuracy = kCLLocationAccuracyBestForNavigation
        lm.distanceFilter = 100
        lm.pausesLocationUpdatesAutomatically = true
        lm.allowsBackgroundLocationUpdates = true // バックグラウンドでの位置情報取得を有効にする
        lm.activityType = .fitness
        self.locationManager = lm

        self.analyticsService = analyticsService
    }

    func monitoring() {
        if !CLLocationManager.significantLocationChangeMonitoringAvailable() {
            return
        }
        locationManager!.delegate = self
        locationManager!.startMonitoringSignificantLocationChanges()
        locationManager!.startUpdatingLocation()
    }


    private func stream(location: CLLocationCoordinate2D) {

        // Amazon Pinpoint の endpointId を取得する
        // Amazon Pinpoint を利用しない場合は、デバイストークンなどで代用してください
        guard let endpointId = analyticsService?.getEndpointId() else {
            return
        }

        // シミュレーターで動作確認するためデバイストークンは取得できない前提で実装します
        let deviceToken = analyticsService?.getDeviceToken() ?? ""

        let location = Location(
                date: NSDate().timeIntervalSince1970 * 1000,
                latitude: String(location.latitude),
                longitude: String(location.longitude),
                endpointId: endpointId,
                deviceToken: deviceToken
        )

        do {
            let jsonData = try JSONEncoder().encode(location)
            var jsonDataString = String(data: jsonData, encoding: .utf8)
            print("location: \(jsonDataString)") // いったん表示するだけにとどめます
        } catch {
            print("dispatch location error \(error)")
        }
    }
}

struct Location: Codable {
    let date: Double
    let latitude: String
    let longitude: String

    let endpointId: String
    let deviceToken: String

    private enum CodingKeys: String, CodingKey {
        case date
        case latitude
        case longitude
        case endpointId
        case deviceToken
    }
}


// LocationManager のデリゲート実装
extension LocationManager: CLLocationManagerDelegate {

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

        if let current = locations.last {
            self.stream(location: current.coordinate)
        }
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("\(classForCoder) " + #function + "error: \(error)")
    }

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .authorizedAlways:
            break
        case .notDetermined:
            manager.requestAlwaysAuthorization()
        case .denied:
            print("denied")
        case .restricted:
            print("restricted")
        case .authorizedWhenInUse:
            break
        }
    }
}

これを、AppDelegate で有効にします。


import UIKit
import UserNotifications

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate {

    var window: UIWindow?
    var analyticsService: AnalyticsService?
    var locationManager: LocationManager?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        // 前略 ...
        
        // Initialize the analytics service
        // analyticsService = LocalAnalyticsService()
        analyticsService = AWSAnalyticsService()

        // Location Service
        locationManager = LocationManager.init(analyticsService: analyticsService!)
        locationManager?.monitoring()

        return true
    }

Info.plist を編集して ユーザーに許可を求める文言を設定

以下の設定を追加します。Property List 表示だとそれぞれ

  • Privacy - Location When In Use Usage Description
  • Privacy - Location Always and When In Use Usage Description

が対応します。

	<key>NSLocationWhenInUseUsageDescription</key>
	<string>アプリ利用中に位置情報を利用します</string>
	<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
	<string>位置情報を利用します</string>

バックグラウンドの位置情報更新を有効にする

MyNoes > Capabilities > Background Modes > check Location updates

ios_backgroundmode.png

動作確認

シミュレータで起動し、位置情報が出力されることを確認します。

ios_location-output.png

あとはこれを、print ではなく Kinesis Streams に流していくことになりますね。iOS アプリが AWS のサービスを使う場合、一時認証情報が必要になります。そのためには、アプリと Cognito Identity Pool を接続する必要があります。

アプリで AWSの認証トークンを取得できるようにする

ここからは、Mobile Hub で作成したAWSのバックエンドが必須になります。Mobile Hub を利用してアプリと連携する方法については、以下のブログを参照ください。

Cognito Identity Pool の ID を控える

Mobile Hub で Cognito Identity Pool を作成します。

AWS マネジメントコンソール > Cognito Identity Pool > Edit identity pool ここの identity pool ID を使います。

aws_idenitypoolid.png

iOS アプリで、この identity pool ID を指定して Credential Provider、つまり一時認証情報を提供してくれる人を定義します。

アプリの AppDelegate で AWSCognitoCredentialsProvider を登録する

platform :ios, '9.0'

target 'MyNotes' do
  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
  use_frameworks!

  # Pods for MyNotes
  # Analytics dependency
  pod 'AWSPinpoint'
  pod 'AWSCognitoIdentityProvider' //追加

end

pod install して AWSCognitoIdentityProvider が利用できるようにします。

 
import UIKit
import UserNotifications
import AWSCognitoIdentityProvider

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate {

    var window: UIWindow?
    var dataService: DataService?
    var analyticsService: AnalyticsService?
    var locationManager: LocationManager?
    var credentialsProvider: AWSCognitoCredentialsProvider?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        // 前略...

        // Initialize the credential provider.
        credentialsProvider = AWSCognitoCredentialsProvider(
                regionType: .APNortheast1,
                identityPoolId: "ap-northeast-1:xxxxxxxxxxxxxxxxxxxx" // 先程控えた Identity pool ID
        )
        // AWS Servicve configuration.
        let serviceConfiguration = AWSServiceConfiguration(region: .APNortheast1, credentialsProvider: credentialsProvider)
        AWSServiceManager.default().defaultServiceConfiguration = serviceConfiguration
        

        // 中略...

        return true
    }

これでOKです。AWSServiceManagerAWSCognitoCredentialsProvider を登録しておくことで、あとはSDKが自動で一時認証情報を駆使してAWSサービスとやりとりしてくれます。

モバイルアプリが Kinesis Streams と接続することを許可する

一時認証情報を利用する準備が整いましたが、今回利用する Kinesis Streams と接続するためにはもう1ステップ必要です。AWSCognitoCredentialsProvider で利用できる一時認証情報が2種類あり、

  • 未認証のクレデンシャル
  • 認証済みのクレデンシャル

があります。この仕組によって、「ログイン前は利用できないけど、ログイン後は利用できるようになるよ」といったことが実現できるわけです。今回のサンプルでは、認証済みのクレデンシャル は利用しません。アプリは必ず未認証のクレデンシャルを利用することになるため、未認証のクレデンシャルに紐づくIAMロールのポリシーで、Kinesis Streams を利用することを許可します。 ちなみにこのIAMロールは、Mobile Hub 経由で Cognito Identity Pool を設定すると自動で生成されるものです。

aws_unauth-iam-role.png

本当は利用する Kinesis Streams に絞ってPUTを許可するべきなのですが、簡単のためにフルアクセス権限を付与します。

Kinesis Streams とつなぎ、位置情報を送信する

ここまでできていれば、あとは先程 print していた位置情報を、SDK を使って Kinesis Streams に送信するだけです。Kinesis Streams は後で作成します。

アプリ側 LocationManager 修正

作成した Kinesis Streams にデータを送信するよう修正します。

    private func stream(location: CLLocationCoordinate2D) {
        guard let endpointId = analyticsService?.getEndpointId() else {
            return
        }
        let deviceToken = analyticsService?.getDeviceToken() ?? ""
        let location = Location(
                date: NSDate().timeIntervalSince1970 * 1000,
                latitude: String(location.latitude),
                longitude: String(location.longitude),
                endpointId: endpointId,
                deviceToken: deviceToken
        )
        do {
            let jsonData = try JSONEncoder().encode(location)
            let recorder = AWSKinesisRecorder.default()
            recorder.saveRecord(jsonData, streamName: "itg-note-device-location-stream")
                    .continueOnSuccessWith { task -> Any? in
                        print("dispatch location: \(String(describing: jsonDataString))")
                        return recorder.submitAllRecords()
                    }
                    .continueWith { task -> Any? in
                        if let error = task.error {
                            print("dispatch location error: \(error)")
                        }
                        return nil
                    }
        } catch {
            print("dispatch location error \(error)")
        }
    }

GeoHash を計算する Lambda Function

ライブラリを利用すれば GeoHash の計算はそれほど難しくありません。今回は TypeScript で実装しました。Kinesis Streams から受け取ったデータから GeoHash を計算するクラスを用意します。

import { DateTime } from 'luxon';
import * as GeoHash from 'ngeohash';

export class DeviceLocation {

    public location: IDeviceLocation;

    constructor(location: IDeviceLocation) {
        this.location = location;
    }

    public geoHash9(): string {
        // 精度9(高い)のGeoHashを算出
        return GeoHash.encode(this.location.latitude, this.location.longitude, 9);
    }

    public geoHash5(): string {
        // 精度5(低い)のGeoHashを算出 DynamoDB のGSIに設定して絞り込む目的で使う
        return GeoHash.encode(this.location.latitude, this.location.longitude, 5);
    }

    public deviceToken(): string {
        const t = this.location.deviceToken;
        return t ? t : 'empty';
    }
}

export interface IDeviceLocation {
    endpointId: string;
    latitude: string;
    longitude: string;
    dispatchAt: DateTime;
    deviceToken?: string;
}

これを使って、DynamoDB に保存します。

export class DeviceDynamodbTable {

    public static updateLocation(deviceLocation: DeviceLocation): Promise<void> {
        const params: UpdateItemInput = {
            TableName: NoteDeviceTableName,
            Key: {endpointId: {S: deviceLocation.location.endpointId}},
            UpdateExpression: [
                'set latitude = :latitude',
                'longitude = :longitude',
                'geoHash5 = :geoHash5',
                'geoHash9 = :geoHash9',
                'dispatchAt = :dispatchAt',
                'deviceToken = :deviceToken',
                'updatedAt = :updatedAt',
            ].join(', '),
            ExpressionAttributeValues: {
                ':latitude': {S: deviceLocation.location.latitude},
                ':longitude': {S: deviceLocation.location.longitude},
                ':geoHash5': {S: deviceLocation.geoHash5()},
                ':geoHash9': {S: deviceLocation.geoHash9()},
                ':dispatchAt': {N: deviceLocation.location.dispatchAt.toMillis().toString()},
                ':deviceToken': {S: deviceLocation.deviceToken()},
                ':updatedAt': {N: DateUtil.jstNow().toMillis().toString()},
            },
        };

        return DynamoDB.updateItem(params).promise()
            .then(() => {
            });
    }

手前味噌で申し訳ないですが、Typescript の Lambda Function をデプロイするテンプレートを作成し、今回はそこからデプロイしています。位置情報を更新する今回のコードも含まれていますので、よかったら参考にしてみてください。

デプロイしたいAWSアカウントにスイッチロールの上、以下のようにしてデプロイします。

aws s3 mb s3://cm-itg-note-lambda-deploy # CloudFormationが利用するデプロイ用バケットの作成
make push-params env=itg ns=cm # Systems Manager のパラメータストアにパラメータを送信します
make deploy-note env=itg ns=cm # パラメータストアの値を使って CloudFormation Deploy します

Kinesis Stream を作成する

Kinesis Streams と Lambda Function を接続するため、Kinesis Streams の CloudFormation テンプレートを修正し、再度デプロイします。

AWSTemplateFormatVersion: '2010-09-09'
Resources:

  NoteDeviceLocationStream:
    Type: AWS::Kinesis::Stream
    Properties:
      Name: !Sub ${Env}-note-device-location-stream
      RetentionPeriodHours: 24
      ShardCount: 1
  NoteLambdaDeviceLocationEventSourceMapping:
    Type: AWS::Lambda::EventSourceMapping
    Properties:
      BatchSize: 100
      Enabled: true
      EventSourceArn: !GetAtt NoteDeviceLocationStream.Arn
      FunctionName: !Sub ${Env}-note-transfer-location
      StartingPosition: LATEST
make infra-kinesis_streams env=itg ns=cm

DynamoDB を作成する

Kinesis Streams と同じように CloudForamation テンプレートを作成し、デプロイします。

AWSTemplateFormatVersion: '2010-09-09'
Resources:
  NoteDeviceTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Ref NoteDeviceTableName
      AttributeDefinitions:
      - AttributeName: endpointId
        AttributeType: S
      - AttributeName: geoHash5
        AttributeType: S
      - AttributeName: dispatchAt
        AttributeType: N
      KeySchema:
      - AttributeName: endpointId
        KeyType: HASH
      ProvisionedThroughput:
        ReadCapacityUnits: !Ref LocationTypeRcu
        WriteCapacityUnits: !Ref LocationTypeWcu
      GlobalSecondaryIndexes:
      - IndexName: geoHash5-index
        KeySchema:
        - AttributeName: geoHash5
          KeyType: HASH
        - AttributeName: dispatchAt
          KeyType: RANGE
        Projection:
          ProjectionType: ALL
        ProvisionedThroughput:
          ReadCapacityUnits: !Ref LocationTypeRcu
          WriteCapacityUnits: !Ref LocationTypeWcu
make infra-dynamodb_tables env=itg ns=cm

実装完了です。

確認作業

アプリをシミュレータで起動します。まず、位置情報がPUTされ、Kinesis Streams の Monitoring から確認できればアプリからの送信はOKです。

aws_kinesis-data.png

次に、DynamoDBを確認します。

aws_dynamodb-result.png

計算されたGeoHashとともに、DynamoDBへ保存されていることが確認できました。これを使って、精度5の GeoHash により global secondary index で絞り込んだ後、さらに精度9の GeoHash を使って filter をかけることで、GeoHashで表現される、特定の範囲内に存在するendpointIdを抽出する といったことができます。

まとめ

iOSアプリと Kinesis Streams をつないで、位置情報からGeoHashを算出して保存するサーバーレスアプリケーションを作成しました。保存されたGeoHashをもとに、「特定の範囲内にあるデバイスに対してPUSH通知を送る」といった芸当が可能です。別の記事で、Amazon Pinpoint を利用してこれをやってみようと思います。

AWS側はデータさえ送られてくれば計算・保存はそれほど難しくありませんでしたが、私がモバイルアプリに明るくないこともあり位置情報を取得して送信する部分で苦労しました。特に、AWSの未認証クレデンシャルを使うための処理に気づかず、Kinesis Streams へデータが送られず困っていまいした。この記事は備忘録として自分用に書いたところが大きいですが、誰かの参考になれば幸いです。

参考