冥冥乃志

ソフトウェア開発会社でチームマネージャをしているエンジニアの雑記。アウトプットは少なめです。

follow us in feedly

Unfiltered + HerokuでTwitter ToDoサービスを作る(Local環境でMondoDBで接続してみる編)

Twitter ToDoサービス(TwoDo)を作る連載4回目。
前回できるようになったOAuthの認証情報を永続化、CookieにID(どういう形にするかは合わせて設計)を保存することで次のアクセスからTwitterへの認証なしにいけるようにしてみようかと思います。

MongoDBのインストール

永続化にはMongoDBを使います。
なんでと言われても困ります。RDBMSでも良かったんですけど、無料枠案外少ないんですよね。Add-onの無料枠で一番要領が大きかったのがMongoDBのAdd-onだったからです。
それに、今回永続化しなければならないデータのモデルがそこまで大きくないことと、作りながら改良していくのが予想されるため、スキーマレスの方が扱いやすいだろうなあ、というのもありました。
というわけで、まずはローカル環境にMongoDBを使います。

$ curl http://downloads.mongodb.org/osx/mongodb-osx-x86_64-2.0.2.tgz > mongo.tgz
$ tar xzf mongo.tgz

インストールはこちらを参照→Mac OS X で MongoDB を動かす - 涅ir槃na
MongoDBのサーバ起動はmongodを叩けばOKです。どうやらMongoDBはナイーブな子らしいので、シャットダウン方法はここを参考にしっかりと学んでおきましょう→MongoDB : Starting and Stopping Mongo
mongodとmongoをいちいちパス指定で叩くのは面倒なため、パスが通ったところにシンボリックリンクを作成します。

ざっくりと使ってみましたが、スキーマレスなので、同じコレクションに対して突っ込めるのは非常に魅力的です。小さいデータモデルを育てながら作るときには非常に便利ですね。
TwoDoのようなアプリケーションには向いているような気がします。
そのかわり、実際のデータの構造に対しては、結構な倫理が求められるのではないかと思います。
それから一意になるデータに対して意識しておかないと、MongoDBが自動でふるIDは正直使いづらいです。

Casbahのインストール

ScalaからMongoDBにアクセスするためのライブラリです。
インストールには依存、非依存がありますよっと。今までのことを考えると(unfiltered-scalateの件)、依存ビルド怖いですね。というわけで依存性を追加してみます(ぇ。
参考にしたのはこちら→1. Getting Started — Casbah (MongoDB + Scala Toolkit Documentation v2.0.1 documentation。「1.1.5. Setting up SBT」を参考にしましょう。
build.sbtのlibraryDependenciesに以下を追加します。

   "com.mongodb.casbah" %% "casbah" % "2.0.1"

はい、ビルドが失敗します。
build.sbtで指定しているScalaのバージョンは2.9.1です。それに対して、Casbahライブラリのビルドバージョンは2.8.0と2.8.1が現状の最新っぽいです(さっきのサイトより)。つまり、エラーメッセージはscala-toolsリポジトリのCasbahには2.9.1でビルドするものはないよ、ということらしいです。というわけで、バージョンをちゃんと指定してあげます。

   "com.mongodb.casbah" % "casbah_2.8.1" % "2.0.1"

無事、ビルドが通るようになりました。
ま、unfiltered-scalateの件もありますので、こんなもんじゃ安心できません。ちゃんとherokuにpushしてheroku側でビルドしてみないと。という訳で、いつものgit push heroku masterです。

       [warn] 	module not found: com.mongodb.casbah#casbah_2.8.1;2.0.1
       [warn] ==== local: tried
       [warn]   /tmp/build_3dqjee7azkvvu/.sbt_home/.ivy2/local/com.mongodb.casbah/casbah_2.8.1/2.0.1/ivys/ivy.xml
       [warn]   -- artifact com.mongodb.casbah#casbah_2.8.1;2.0.1!casbah_2.8.1.jar:
       [warn]   /tmp/build_3dqjee7azkvvu/.sbt_home/.ivy2/local/com.mongodb.casbah/casbah_2.8.1/2.0.1/jars/casbah_2.8.1.jar
       [warn] ==== twitter4j.org Repository: tried
       [warn]   http://twitter4j.org/maven2/com/mongodb/casbah/casbah_2.8.1/2.0.1/casbah_2.8.1-2.0.1.pom
       [warn]   -- artifact com.mongodb.casbah#casbah_2.8.1;2.0.1!casbah_2.8.1.jar:
       [warn]   http://twitter4j.org/maven2/com/mongodb/casbah/casbah_2.8.1/2.0.1/casbah_2.8.1-2.0.1.jar
       [warn] ==== heroku-sbt-typesafe: tried
       [warn]   -- artifact com.mongodb.casbah#casbah_2.8.1;2.0.1!casbah_2.8.1.jar:
       [warn]   http://s3pository.heroku.com/ivy-typesafe-releases/com.mongodb.casbah/casbah_2.8.1/2.0.1/jars/casbah_2.8.1.jar
       [warn] ==== heroku-central: tried
       [warn]   http://s3pository.heroku.com/maven-central/com/mongodb/casbah/casbah_2.8.1/2.0.1/casbah_2.8.1-2.0.1.pom
       [warn]   -- artifact com.mongodb.casbah#casbah_2.8.1;2.0.1!casbah_2.8.1.jar:
       [warn]   http://s3pository.heroku.com/maven-central/com/mongodb/casbah/casbah_2.8.1/2.0.1/casbah_2.8.1-2.0.1.jar
       [warn] ==== heroku-scala-tools-releases: tried
       [warn]   http://s3pository.heroku.com/maven-scala-tools-releases/com/mongodb/casbah/casbah_2.8.1/2.0.1/casbah_2.8.1-2.0.1.pom
       [warn]   -- artifact com.mongodb.casbah#casbah_2.8.1;2.0.1!casbah_2.8.1.jar:
       [warn]   http://s3pository.heroku.com/maven-scala-tools-releases/com/mongodb/casbah/casbah_2.8.1/2.0.1/casbah_2.8.1-2.0.1.jar
       [warn] ==== heroku-scala-tools-snapshots: tried
       [warn]   http://s3pository.heroku.com/maven-scala-tools-snapshots/com/mongodb/casbah/casbah_2.8.1/2.0.1/casbah_2.8.1-2.0.1.pom
       [warn]   -- artifact com.mongodb.casbah#casbah_2.8.1;2.0.1!casbah_2.8.1.jar:
       [warn]   http://s3pository.heroku.com/maven-scala-tools-snapshots/com/mongodb/casbah/casbah_2.8.1/2.0.1/casbah_2.8.1-2.0.1.jar
       [warn] 	::::::::::::::::::::::::::::::::::::::::::::::
       [warn] 	::          UNRESOLVED DEPENDENCIES         ::
       [warn] 	::::::::::::::::::::::::::::::::::::::::::::::
       [warn] 	:: com.mongodb.casbah#casbah_2.8.1;2.0.1: not found
       [warn] 	::::::::::::::::::::::::::::::::::::::::::::::
       [error] {file:/tmp/build_3dqjee7azkvvu/}default-a14189/*:update: sbt.ResolveException: unresolved dependency: com.mongodb.casbah#casbah_2.8.1;2.0.1: not found
       [error] Total time: 36 s, completed Feb 26, 2012 8:46:39 AM

まさか本当にこけるとは……。クラウドサービスは色々と難しいですねえ。
なんかちょっとわかったような気がしますよ。warnメッセージに注目してみましょう。どこのリポジトリを見て、Casbahがないと言っているのかがわかります。というか、scala-toolsはheroku内のリポジトリを参照するようになってるみたいですね。おそらくscala-toolsの全てのツールリポジトリに入っている訳ではないのでしょう。ということでなんとかしてあげなければなりません。まあ、本家のリポジトリを参照対象に追加してあげるだけなのですが。build.scalaに以下を追加します。

resolvers += "scala-tools for Casbah build on heroku release" at "http://scala-tools.org/repo-releases/"

resolvers += "scala-tools for Casbah build on heroku snapshot" at "http://scala-tools.org/repo-snapshots/"

リポジトリ名はなんかバッティングしないように適当に長ったらしくつけただけなんで、任意です。これでherokuにpushするとちゃんとビルドが通ります。heroku側でconsoleからimportしてみても、ちゃんとcom.mongodbがサジェストされるはずです*1

使い方はこちらを参考に→ScalaからMongoDBへアクセスする - Casbah編 - I Will Survive

ちなみに、最初にCasbahのビルド依存性を追加した後、Scala 2.9.1対応のバージョンが出たようです*2
build.sbtを以下のように変更します。

	"com.mongodb.casbah" %% "casbah" % "2.1.5-1"

後は今までと同じようにビルドしてやってください。

Salatのインストール

Casbahだけだとドキュメントの項目名やらを文字列で指定しなければなりません。ToDoアプリくらいの規模ならそれでも良いのかもしれませんが、なるべくならデータはカターンゼンに扱いたいものですね。という訳で、Scala-MongoDB間のORマッパー*3であるSalatを使ってみることにします。
参考にしたのはこちら→novus/salat · GitHub
いつものように、build.sbtのlibraryIndependenciesに以下を追加します。

	"com.novus" %% "salat-core" % "0.0.8-SNAPSHOT"

SNAPSHOTってのがちょっと気になりますが、細かいことは気にしません。
続いて、リポジトリをresolversに追加します。

resolvers += "repo.novus for salat build release" at "http://repo.novus.com/releases/"

resolvers += "repo.novus for salat build snapshot" at "http://repo.novus.com/snapshots/"

後はいつもの通り、ビルドするのとIDEA用の設定ファイルを吐き直してあげます。今度は、heroku側にpushしても問題なくビルドできますし、heroku側でconsole実行してみてもちゃんとサジェストされます。

基本的な使い方はこちらを参考に→ScalaからMongoDBへアクセスする - Salat編 - I Will Survive

OAuth認証情報の永続化

CasbahとSalatを使って、認証情報を永続化するための仕組みを作ります。永続化しなければならない情報は以下の通りです。

  • Twitter上で一意なユーザID
  • TwoDoでユーザ名として表示するTwitter上の表示名
  • ユーザのアクセストークン
  • ユーザのトークンシークレット

TwoDoとして必要なものは他にもありますが、まずはOAuth認証に必要なものを実装します。

case class SimpleTwoDoUserData(
                                userId: Long,
                                screenName: String,
                                accsToken: String,
                                tokenSecret: String
                                ) {
  override def equals(other: Any) = other match {
    case that: SimpleTwoDoUserData => that.userId == this.userId && that.accsToken == this.accsToken && that.tokenSecret == this.tokenSecret
    case _ => false
  }
}

実際には、上記のクラスにscreenName、accsToken、tokenSecretを変更したインスタンスを返すメソッドを実装しています。
これをベースにした、CRUD操作を実装します。

object SimpleTwoDoDatabase {
  val conn = MongoConnection()
  val db = conn("simple_twodo")
  val usersDataCollection = db("users_data")

  private val userIdKey = "userId"
  private val duplicateErrMsg = "insertUserData error:user data duplicate. userId = %d."

  val g = grater[SimpleTwoDoUserData]

  def insertUserData(userData: SimpleTwoDoUserData) {
    getUserData(userData.userId) match {
      case Some(dbUserData) if userData == dbUserData => throw new IllegalArgumentException(duplicateErrMsg.format(userData.userId))
      case _ => usersDataCollection += g.asDBObject(userData)
    }
  }

  def updateUserData(userData: SimpleTwoDoUserData) {
    removeUserData(userData.userId)
    insertUserData(userData)
  }

  def removeUserData(userId: Long) {
    usersDataCollection.remove(MongoDBObject(userIdKey -> userId))
  }
  
  def getUserData(userId: Long) = {
    usersDataCollection.findOne(MongoDBObject(userIdKey -> userId)) match {
      case Some(dbData) => Some(g.asObject(dbData))
      case None => None
    }
  }
}

コネクションはまだローカル環境のみです。ローカル環境とステージング環境で設定を変えたいのですが、その辺りは次回に譲ることにします。
updateは、割り切ってDELETE INSERTです。それ以外は案外普通のコードですね。

2回目以降のアクセスで認証を省略する

OAuth認証が完了して永続化した情報のうち、TwitterIDを認証済を表すCookieに保存します。
このCookieの情報を使って認証済かどうかを確認し、必要に応じて認証配下や認証ページにリダイレクトすることにします。
以下のフィルタを作成します。

  • 認証状態を確認するためのフィルタ
  • 認証配下で動くアプリケーション本体
  • OAuth認証

まずは、認証状態を確認するフィルタです。

object UserAuth extends unfiltered.kit.Prepend {
  def intent = Cycle.Intent[Any, Any] {
    case Cookies(cookies) if (cookies.get("TwoDoUserId").isDefined) => {
      Pass
    }
    case _ => {
      Redirect("/authwithtwitter")
    }
  }
}

Cookieが設定されていて、なおかつその中にTwoDoUserIdというキーに値がある場合のみ認証していると見なします。ない場合は、OAuth認証のためのページにリダイレクトします。

次に認証配下で動くアプリケーション本体です。なお、まだGUI部分については何にも書いていないので、Twitterからツイートを取得するところやintentメソッドが返している部分は仮です。

class TwoDoApplicationServer extends unfiltered.filter.Plan {
  def intent = UserAuth {
    // to do list main page
    case req@GET(Path(Seg("twodolist" :: Nil)) & Cookies(cookies)) => {
      cookies("TwoDoUserId") match {
        case Some(Cookie(_, userIdStr, _, _, _, _)) => {
          SimpleTwoDoDatabase.getUserData(userIdStr.toLong) match {
            case Some(userData) => {
              val twodotweets = getToDoTweets(userData)

              var tweetStr = new StringBuilder
              tweetStr.append("tweet length=").append(twodotweets.length).append("<br/><br/>")
              twodotweets.foreach(tweet => tweetStr.append(tweet.getText).append("<br/><br/>"))

              Ok ~> HtmlContent ~> ResponseString(tweetStr.toString())
            }
            case None => authErr(MessageProperties.getProperty("err.authuser.notfound"))
          }
        }
        case _ => authErr(MessageProperties.getProperty("err.authentication"))
      }
    }
    case GET(_) => NotFound ~> ResponseString(MessageProperties.getProperty("err.requestapi.notfound"))
  }
}

authErrは個別に実装している箇所ですが、本筋と関係ないので省略します。認証エラーを返すと思ってください。
以下の部分で認証フィルタをかけています。先述の認証チェックがOKだった場合のみ素通りすることになります。

	def intent = UserAuth { // UserAuthが先に実行されて、認証OKならその後が実行される

残りの部分に関しては、CookieからユーザIDを取得し、それらを元にTODOツイートを取得しています。matchとcaseによるネストが多くてなんかエレガントじゃないのは相変わらずですが……。

認証の処理は前回のときからほとんど変わっていませんが、別のフィルタクラスを作成します。
コールバック時にアクセストークンから必要な情報をMongoDBに永続化してCookieに保存しています。

で、これをmainメソッドのサーバ実行部分に追加します。ここでのフィルタ追加の順番に気をつけてください。
最初は以下のようにしていました。

    unfiltered.jetty.Http(port).context("/public").filter(new TwoDoApplicationServer).filter(new AuthenticationServer).run

これだと、エラーになります。転送がループするって言われます。
まあ、理由は単純で、フィルタが先に設定した方から確認されるということです。上記のコードで、非認証の状態でアクセスすると以下のような挙動をします。

  1. 認証配下のフィルタで非認証として、TwitterOAuthのURLにリダイレクト
  2. リダイレクトなので、もっぺんサーバにアクセスされる
  3. 認証配下のフィルタで(以下略)

見事なまでのループです。貞子が出そうです。ってか、これ途中で止めてくれるんですね。ブラウザ?サーバ?どっちで止めてるんでしょう。
で、フィルタの順序を入れ替えました。

    unfiltered.jetty.Http(port).context("/public").filter(new AuthenticationServer).filter(new TwoDoApplicationServer).run

これだと、以下のような挙動になるのでループしません(非認証の状態で認証配下のURLにアクセスした場合)。

  1. TwitterOAuth認証のフィルタは素通り
  2. 認証配下のフィルタで非認証として、TwitterOAuthのURLにリダイレクト
  3. TwitterOAuthのURLで認証が処理される
  4. コールバック後に非認証のURL宛にリダイレクト

とまあ、フィルタに関してはこれでOKだったんですが、今度は認証しようとすると、"Access token already available."って怒られるようになりました。
これに関しては、

のやり取りにあるように、Twitter4Jのオブジェクトを使い回していたのが原因のようです。認証の度にインスタンスを生成しなおすように変更すると上手く行きました。

というわけで、認証情報の永続化まで上手く行くようになりました。次回は、コールバックURLなどの設定値がハードコーディングになっていて格好悪い箇所を直すのと、それにともなってローカル環境とステージング環境の設定値の切り替えあたりをやります。で、ここまで整えば、heroku Add-onの出番となります。

*1:ただし、これがherokuで使う上で正しい対応かどうかわかりません。おそらく、herokuからscala-toolsリポジトリへのアクセスを減らす目的でheroku内にリポジトリを作成して優先させていると思うのですが、これでbuild.sbtの設定が優先されてしまうと、その前提を崩してしまってまずいんじゃないかと。

*2:色々と面倒くさかったです。gen-ideaで読み込まれて実行時エラーになったり。。。

*3:ドキュメント指向データベースで「リレーション」という言葉を使うのは少し違和感がありますが