GAEでPUSH通知時の"TypeError: must be _socket.socket, not socket"の対応

GAEでiosのPUSH通知(APNS)を実装しようとしたら、"TypeError: must be _socket.socket, not socket"のエラーがでたので、全体的な流れと対応をまとめる。
なお、このエラーは開発環境でのみ出る。

まずは、Google App EngineからiOSアプリへPush通知が送れるようになりました - laisoを参考に、下記2つを行った。
1、PyAPNsをインストール
2、下のソースの記述

from apns import APNs, Payload


apns = APNs(use_sandbox=True, cert_file='cert.pem', key_file='pkey.pem') 
token_hex = '1c222886bcce84bd9fb34c274ea3d466be970b62c7d72582d6fc302f9b072503' 
# tokeh_hexはiOSアプリで取得するデバイストークン
    
class APNsHandler(webapp.RequestHandler):
    def get(self):
        payload = Payload(alert="Hello World!", sound="default", badge=1)

        apns.gateway_server.send_notification(token_hex, payload)
        self.response.write(u"PUSH通知送信完了")

app = webapp2.WSGIApplication([
    ('/notify', APNsHandler)
], debug=True)

3、app.yamlに下記を追加
libraries:
- name: ssl
version: latest

で、開発環境のGAEを起動して、
http://localhost:8080/notify
にアクセスすると、
"TypeError: must be _socket.socket, not socket"
が発生する。

解決策は、ここに載っている。

やることは2つ。
1、"_ssl" and "_socket"を /path-to-gae-sdk/google/appengine/tools/devappserver2/python/sandbox.py の中の _WHITE_LIST_C_MODULESに追加。
2、/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/socket.py を、 /path-to-gae-sdk/google/appengine/dis27/socket.py にコピー(上書き)。

一番下のUITextFieldをキーボードで隠れないようにする

LINEのように、コメントを書き込む一番下のUITextFieldを、キーボード表示で隠れないようにする、簡単なやり方のメモ。

UIScrollViewの上にUITextFieldを配置してスクロールさせるサンプルはネットに散見されるけど、UIToolbarを使ったもっとシンプルなやり方がある。

ポイントは、
1、UIToolbarを使いその上にUITextFieldを配置する。
2、view.transformでキーボード分ずらす。

ソースコードは下記。

var myToolbar: UIToolbar!
var myTextField: UITextField!
override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated)

        // ツールバーの追加
        self.view.backgroundColor = UIColor.cyanColor()
        myToolbar = UIToolbar(frame: CGRectMake(0, self.view.frame.height - 44, self.view.frame.width, 44.0))
        myToolbar.barStyle = UIBarStyle.BlackTranslucent
        myToolbar.tintColor = UIColor.whiteColor()
        myToolbar.backgroundColor = UIColor.blackColor()
        self.view.addSubview(myToolbar)
        
        // UITextFieldを作成する.
        myTextField = UITextField(frame: CGRectMake(0,0 ,self.view.frame.width, 44))
        
        // 表示する文字を代入する.
        myTextField.placeholder = "コメントを書き込んでください"
        myTextField.contentHorizontalAlignment = UIControlContentHorizontalAlignment.Center
        
        // 枠を表示する.
        myTextField.borderStyle = UITextBorderStyle.RoundedRect

        // Delegateを設定する.
        myTextField.delegate = self
        
        
        // ツールバーに追加する.
        myToolbar.addSubview(self.myTextField)

        // 通知設定
        let notificationCenter = NSNotificationCenter.defaultCenter()
        notificationCenter.addObserver(self, selector: "handleKeyboardWillShowNotification:", name: UIKeyboardWillShowNotification, object: nil)
        notificationCenter.addObserver(self, selector: "handleKeyboardWillHideNotification:", name: UIKeyboardWillHideNotification, object: nil)
}

    // UITextFieldをクリックした時。キーボードが出てくるので、その分だけずらす
func handleKeyboardWillShowNotification(notification: NSNotification) {
        
        let userInfo = notification.userInfo!
        let keyboardScreenEndFrame = (userInfo[UIKeyboardFrameEndUserInfoKey] as! NSValue).CGRectValue()
        
        let transform = CGAffineTransformMakeTranslation(0, -keyboardScreenEndFrame.size.height);
        self.view.transform = transform
}
    
    // UITextFieldでエンターを押した時。キーボードが消えるので、その分だけずらす
func handleKeyboardWillHideNotification(notification: NSNotification) {
        self.view.transform = CGAffineTransformIdentity
}


// UITextFieldでリターンを押した時にキーボードを閉じる
func textFieldShouldReturn(textField: UITextField) -> Bool {
        textField.resignFirstResponder()
        
        return true
}

swiftでRealmをインストールして少し使った

CoreDataは少し複雑だし、iOSだけだし、もっと早いDBがあるということで、「Realm」を今後使うことにした。

インストール方法

もちろん手動ではなくて、cocoapodでRealmをインストールする。

1、Podfileを作成して以下を記述する。

platform :ios, "8.0"
use_frameworks!
pod 'Realm'

2、pod installの実行

pod install

3、RLMSupport.swiftをダウンロードして、プロジェクト内に配置する。

4、println(RLMRealm.defaultRealmPath())をソース内で実行して問題なければ、一応OK。

ソースコード

データ保存用のクラス作成。RDBのテーブルのようなもの。KVSでいうエンティティ。

class Book : RLMObject {
    dynamic var isbn = ""
    dynamic var name = ""
    dynamic var price = 0
}

保存して取り出す実験。

let realm = RLMRealm.defaultRealm()
        
// Bookオブジェクト生成.
let book = Book()
book.isbn = "222"
book.name = "book name sample"
book.price = 100


// 保存
realm.transactionWithBlock() {
	realm.addObject(book)
}

for realmBook in Book.allObjects() {
	println("book name:\((realmBook as! Book).name)")
}

保存、取り出しがログで確認できた。

rest_gaeでのユーザ削除とその関連データの削除

GAEでユーザ削除と、それに関連するデータ削除のケースを考える。
前提として、rest_gae+webapp2を使っている。

今回は「関連するデータ」を「投稿データ」とする。
ソースコードで表すと、下の例のように投稿データを表すUserPostクラスが、ownerとしてUserクラス(ユーザ)を持つ。
このUserPostは複数GAEのデータストア内に存在可能なので、結果として「ユーザ」と「投稿」の1対多の関係となる。

class UserPost(ndb.Model):
    owner = ndb.KeyProperty(kind='User') #投稿したUser
    description = ndb.StringProperty() #書き込み
    
    class RESTMeta:
        user_owner_property = 'owner'


【理想】
外部キーをカスケード設定して、ユーザ削除と同時に投稿データも自動削除されること

【現実】
上の理想は実現できない。。。RDBではないから。

【考えた対応策#1】
ユーザ削除後に呼ばれるコールバック関数を設定して、そのコールバック関数内で投稿データの削除を行う。そのためには、投稿データのparentをユーザにしておく必要がある。
<この対応策>:☓
<その理由>:他のモデルクラスの場合はコールバック関数が設定できるのに、Userモデルクラスに対してはコールバック関数が設定できなかった。

【考えた対応策#2】
利用者がユーザ削除ボタンを押した時(スマホアプリを想定)、アプリ側で
1,投稿データ削除
2,ユーザ削除
の順にAPI呼び出しを制御する。
<この対応策>:◯
<その理由>:ベストと言えないかもしれないが、他に解決策が見えなかったのでこれを採用する

GAE(python)のRESTfulなAPIのフレームワークのrest_gae

budowski/rest_gae · GitHubというGoogle App Engine上で動くpythonのRESTfulなWeb APIフレームワークを使っているが、とても便利。

日本語での参考サイトがないので、メモとしてまとめる。

 

[概要]

Web APIなんて結局、jsonリクエストを受け取って、パラメータチェックして、レスポンスをjsonで返すだけのもの。

そのほとんどの機能をrest_gaeは提供する。インタフェースはREST。

 

1. ユーザ管理API

ユーザ作成、変更、削除などをのAPIがある。

 

1-1. 独自ユーザクラスの定義

from rest_gae.users import User
class MyUser(User):
    """Our own user class"""
    prop1 = ndb.StringProperty(required=True)
    prop2 = ndb.StringProperty()

独自のユーザ情報(この場合は、prop1とprop2)が必要なければ、デフォルトで提供されているUserクラスを使えば良い。

Userクラスが持っているプロパティは、

・email

・ユーザ名

・パスワード(ハッシュ化)

・adminユーザかどうか

・email認証済みかどうか

・作成日時と変更日時

※ドキュメントには書いてなかったので、ソースコードと実際に追加されたDataStoreから解析した!

 

そして、これを動かすには下記を記述する。

from rest_gae import * # This imports RESTHandler and the PERMISSION_ constants
from rest_gae.users import UserRESTHandler

# Make sure we initialize our WSGIApplication with this config (used for initializing webapp2_extras.sessions)
config = {}
config['webapp2_extras.sessions'] = {
    'secret_key': 'my-super-secret-key',
}

app = webapp2.WSGIApplication([
    UserRESTHandler(
        '/api/users', # The base URL for the user management endpoints
        user_model='models.MyUser', # Use our own custom User class
        user_details_permission=PERMISSION_LOGGED_IN_USER,
        verify_email_address=True,
        verification_email={
            'sender': 'John Doe <john@doe.com>',
            'subject': 'Verify your email',
            'body_text': 'Hello {{ user.full_name }}, click here: {{ verification_url }}',
            'body_html': 'Hello {{ user.full_name }}, click <a href="{{ verification_url }}">here</a>'
        },
        verification_successful_url='/verified-user',
        verification_failed_url='/verification-failed',
        reset_password_url='/reset-password',
        reset_password_email={
            'sender': 'John Doe <john@doe.com>',
            'subject': 'Reset your password',
            'body_text': 'Hello {{ user.name }}, click here: {{ verification_url }}',
            'body_html': 'Hello {{ user.name }}, click <a href="{{ verification_url }}">here</a>'
        },
        send_email_callback=my_send_email,
        allow_login_for_non_verified_email=False,
        user_policy_callback=lambda user, data: if len(data['password']) < 8: raise ValueError('Password too short')
   )], config=config)

 

UserRESTHandlerの中を見ていく。

'/api/users', # The base URL for the user management endpoints

URLを定義する。この場合、

http://ホスト名/api/users

でユーザ管理APIにアクセスすることになる。

 

user_model='models.MyUser', # Use our own custom User class

ユーザモデルの定義。先ほど作ったMyUserを使う。

 

user_details_permission=PERMISSION_LOGGED_IN_USER,

ユーザ情報を取得できるユーザの権限を設定している。ここでは、PERMISSION_LOGGED_IN_USERという(そのアカウントで)ログインしたユーザ以上に権限がある。

ユーザの権限には、下記がある。下に行くに従い、権限が強くなる。

・PERMISSION_ANYONE

・PERMISSION_LOGGED_IN_USER

・PERMISSION_OWNER_USER

・PERMISSION_ADMIN

 

その他の部分は、メール認証に関する機能で今回は無視する。

 

 1-2. ユーザ管理APIの呼び出し

【実行サンプルあり】

A) POST /users : ユーザ情報の登録。誰でも可。
B) POST /users/login : ログイン。誰でも可。

C) GET /users/エンティティキー : あるユーザ情報の取得。前述の通りログインユーザ以上で可。

 

【実行サンプルなし】

GET /users : 全ユーザ情報の取得。adminユーザのみ可。
PUT /users/エンティティキー : ユーザ情報変更。パスワードも変更できる。ログインユーザ以上で可。
DELETE /users/エンティティキー : ユーザ情報削除。ログインユーザ以上で可。 

 

【実行サンプルあり】のAPI実際に呼び出してみる。

 A) POST /users : ユーザ情報の登録。誰でも可。

f:id:tsuboi_sj:20150416064615p:plain

email, user_name, passwordがUserクラスの必須のパラメータ。MyUserクラスでprop1を必須で定義したので、prop1も必須。

レスポンスでエンティティキーがidという名で返ってくる。

 

DataStore Viewerで中身をチェックすると、

f:id:tsuboi_sj:20150416064714p:plain

ちゃんと値が入っている。

 

B) POST /users/login : ログイン。誰でも可。

f:id:tsuboi_sj:20150416064938p:plain

 れクエストはuser_nameとパスワードが必須。

レスポンスではuser_idが返される。これはMyUserエンティティのIDに一致する。つまり、ログインではMyUserエンティティキーは返却されない。

 

これがC) GET /users/エンティティキーの呼び出しで問題になる。エンティティキーがログインで取得できないので、下の2つのどちらかで対応する必要がある。

ア)アカウント作成時にローカルに保存(スマホアプリのみ可)

イ)エンティティキーを取得するAPIを別途用意する。

 

)の場合のサーバ側での処理は、

1.IDをクライアントから受け取る

2.ndb.Key(MyUser, IDの値).urlsafe()でエンティティキーを取得(※これがわからず苦労した)。

(例)ndb.Key(MyUser,4677872220372992).urlsafe()

3.クライアントに返却

ア)、イ)以外にもっと良いやり方があるかもしれないが、わからなかったのでこれで進めている。(誰か知っていたら教えて下さい。 )

 

※2015/4/18 追記

※ /users/me で自分のアカウントにアクセスできる(と分かった)ので、エンティティキーの取得は不要だった。

 

C) GET /users/エンティティキー : あるユーザ情報の取得。前述の通りログインユーザ以上で可。

f:id:tsuboi_sj:20150416065539p:plain

 取得成功。

 

Modelを追加してAPIを呼び出しもだいたい同じ感じ。次回書く。

 

[Mac] eclipse+pydevで作ったプロジェクトをGoogleAppEngineLauncherに登録する

 

 eclipse+pydevとGoogleAppEngineLauncherとの連携にちょっと手間取ったので、メモとして残しておく。

事前条件

eclipse、pydev、GoogleAppEngineLauncherのインストールが必要。他のサイトを参考にインストールした。

やること

1.最初にeclipsegoogle app engineのプロジェクト作成

2. 次にソースを少しいじる

3. 最後にGoogleAppEngineLauncherに登録

1.eclipsegoogle app engineのプロジェクト作成

eclipseのメニューバーからファイル>新規>プロジェクトを選択 する。ウイザードが表示されるので、「PyDev Google App エンジン・プロジェクト」を選択する。 

 f:id:tsuboi_sj:20150330131409p:plain

 

 「次へ」をクリックすると、Pydevプロジェクトの設定画面が表示される。

プロジェクト名に適当な値を入力し(ここでは、test01とした)、インタープリターを「python」にする。また、「Add project directory to the PYTHONPATH」を選択する。 

f:id:tsuboi_sj:20150330135942p:plain

 

Google App エンジンのディレクトリを指定するように言われるので、下記を入力した。

/Applications/GoogleAppEngineLauncher.app/Contents/Resources/GoogleAppEngine-default.bundle/Contents/Resources/google_appengine 

すると、ライブラリ一覧が出てくるので、何も考えずに全て選択状態のままでOKをクリックする。

f:id:tsuboi_sj:20150330132219p:plain

 

下の画面が出て来るので、次へをクリックする。

f:id:tsuboi_sj:20150330132348p:plain

 

アプリケーションIDとテンプレート選択画面が出てくるので、ここではそれぞれ「ts-01」と「Hello Webapp World」を入力して完了をクリックして、プロジェクト作成完了!

※ アプリケーションIDとはgoogle app engineのアプリIDのこと

f:id:tsuboi_sj:20150330132910p:plain

 

test01の下にhelloworld.pyがあれば作業成功。

f:id:tsuboi_sj:20150330140203p:plain

 

2. 次にソースをいじる

helloworld.pyを開いて、「Hello, webapp World!」となっている部分を、「こんにちは〜」に変更する。

f:id:tsuboi_sj:20150330133424p:plain

 

3. 最後にGoogleAppEngineLauncherに登録

GoogleAppEngineLauncherを起動して、左下の+をクリックする。

入力画面が出てくるので、Application IDは何も入力せず、Application Directoryに/Users/sky_env/eclipse-prj/workspace/test01を入力した。

f:id:tsuboi_sj:20150330133759p:plain

 

IDがts-01のアプリが追加されていているのが確認できる。Runでアプリを起動してBrowserボタンで実行する。

f:id:tsuboi_sj:20150330140348p:plain

 

「こんにちは〜」と表示されているのが確認できた(文字化けしていたので、ブラウザでUTF8にエンコードしたけど)。

f:id:tsuboi_sj:20150330134154p:plain