Djangoのテストを使いやすくする

2016-02-14 23:23:00

Djangoでテストを実行するには、以下のコマンドを実行するだけです。

$ python manage.py test

これでmanage.pyの下にあるtest*.pyのファイルに入っているTestCaseを継承したクラスがテストに追加され、実行されます。

$ python manage.py test
Creating test database for alias 'default'...
Creating test database for alias 'shard0'...
Creating test database for alias 'shard1'...
......................................................................................
----------------------------------------------------------------------
Ran 86 tests in 51.495s

OK
Destroying test database for alias 'default'...
Destroying test database for alias 'shard0'...
Destroying test database for alias 'shard1'...

詳しくはDjangoのドキュメントを参照して下さい。

自動的にテストが走るようにする

Djangoのテストは便利なのですが、開発をしていると、毎回テストを手で実行するのが面倒になってきます。 なのでファイルが書き換わったら自動的にテストが走るようにしたいところです。

こんなのきっと誰か開発してるだろうとPyPIを探したところ、tdaemonというパッケージが見つかりました。

これは以下のように書いておくと、そのフォルダ以下にあるどれかのファイルの変更があった際に自動的にテストを実行してくれます。

$ tdaemon -t django

これを別ウインドウでずっと実行したままにしておくことで、とりあえず自動的にテストが走るようになりました。

しかしこのままだと、Vimでファイルを開くと、無駄にテストが走るという状況になります。 これはVimが生成する.swpファイルをtdaemonが検知して、そのせいでテストが走るためです。

コードを見てみると、IGNORE_EXTENSIONSに拡張子を追加すれば.swpファイルの検知を無視してくれそうです。

ということで以下のようなコードを書きました。

import os
import sys

import tdaemon


def main():
    tdaemon.IGNORE_EXTENSIONS += ('swp',)
    tdaemon.main([sys.argv[0], '-t', 'django'])

if __name__ == '__main__':
    main()

これで無駄にテストが走ることが無くなりました。

しかしまだ問題があります。 tdaemonはなぜか、標準エラーは即座に出力するのに、標準出力は全てのテストが終わってから一気に出力します。 これはコードを見れば実際そのように書かれていて、大変残念な感じです(gitにある、リリースされていない最新バージョンなら直っている)。

かなりイケてないので、これも適当にモンキーパッチを当てて直します。


def run_tests(self):
    print(datetime.datetime.now())
    result = subprocess.call(self.cmd, shell=True)
    print('SUCCESS' if result == 0 else 'FAILED')

def main():
    tdaemon.Watcher.run_tests = run_tests
    ...

これでようやく見やすい出力になりました。

Djangoのテストを高速化する

最初のテスト結果を見れば分かりますが、1回のテストに51秒掛かっています。

ファイルを保存して、テストが通るかどうかを確認するために51秒も待つのは、さすがに辛いものがあります。

開発者は通常、開発しようとしている機能のテストを通そうとしているはずです。 そのため、通常ははその機能のテストだけをやってくれていれば良くて、それ以外の機能のテストは、開発しようとしている機能のテストが終わって、最後にやってくれればいいのです。

しかし、手動で開発中の機能を指定するのは面倒なので、「前回失敗したテストを優先的にテストし、次回もそのテストが失敗したらその時点で中断する」という形にしました。 つまり失敗するようなテストは開発中の機能に違いないので、それらのテストが通るまでは、ひたすらそのテストを回し続けよう、という方針です。

テストの動きを変えるのは結構手間かなと思っていたのですが、Djangoのテストランナーを見ながら拡張すれば思ったより簡単にできました。以下のようになります。

class FastRunner(object):
    def __init__(self, **kwargs):
        self.kwargs = kwargs

    def load_failtests(self):
        try:
            with open('.failtests') as f:
                return set(f.read().split(','))
        except Exception:
            return None

    def save_failtests(self, result):
        lines = []
        for testcase, traceback in result.errors:
            lines.append(testcase.id())
        for testcase, traceback in result.failures:
            lines.append(testcase.id())
        with open('.failtests', 'w') as f:
            f.write(','.join(lines))

    def teardown_with_keepdb(self, verbosity, old_config):
        old_names, mirrors = old_config
        for connection, old_name, destroy in old_names:
            if destroy:
                connection.creation.destroy_test_db(old_name, verbosity, True)

    def run_tests(self, test_labels, extra_tests=None, **kwargs):
        from django.test.runner import DiscoverRunner
        from django.test.runner import setup_databases

        # 全体テストじゃなければいつも通りの処理を行なう
        if test_labels:
            dr = DiscoverRunner(**self.kwargs)
            return dr.run_tests(test_labels, extra_tests, **kwargs)

        # 直前のテスト結果が成功でもいつも通りの処理を行なう
        failtests = self.load_failtests()
        if failtests is None:
            dr = DiscoverRunner(**self.kwargs)
            dr.setup_test_environment()
            suite = dr.build_suite(test_labels, extra_tests)
            old_config = dr.setup_databases()
            result = dr.run_suite(suite)
            if result.wasSuccessful():
                # テストが成功だったら普通に後処理
                dr.teardown_databases(old_config)
                dr.teardown_test_environment()
            else:
                # テストが失敗だったら失敗したテストを保存してDBをキープしておく
                self.save_failtests(result)
                self.teardown_with_keepdb(dr.verbosity, old_config)
                dr.teardown_test_environment()
            return dr.suite_result(suite, result)

        else:
            # 直前のテスト結果が失敗なら、キープしておいたDBを使って失敗したテストをもう一度実行
            #   - そこで失敗したら失敗したテストを保存してDBをキープして終了
            #   - 成功したら残りのテストを実行する
            #     - そこで失敗したら同様に失敗したテストを保存してDBをキープして終了
            #     - 成功したらDBを解体処理して終了
            dr = DiscoverRunner(**self.kwargs)
            dr.setup_test_environment()
            first_suite = dr.test_suite()
            second_suite = dr.test_suite()
            suite = dr.build_suite(test_labels, extra_tests)
            for testcase in suite:
                if testcase.id() in failtests:
                    first_suite.addTest(testcase)
                else:
                    second_suite.addTest(testcase)

            old_config = setup_databases(dr.verbosity, dr.interactive, True, dr.debug_sql)

            result = dr.run_suite(first_suite)
            if not result.wasSuccessful():
                self.save_failtests(result)
                self.teardown_with_keepdb(dr.verbosity, old_config)
                dr.teardown_test_environment()
                return dr.suite_result(first_suite, result)

            result = dr.run_suite(second_suite)
            if not result.wasSuccessful():
                self.save_failtests(result)
                self.teardown_with_keepdb(dr.verbosity, old_config)
                dr.teardown_test_environment()
                return dr.suite_result(second_suite, result)

            os.remove('.failtests')
            dr.teardown_databases(old_config)
            dr.teardown_test_environment()
            return dr.suite_result(second_suite, result)

あとはこのランナーをテストで使うDjangoの設定ファイルのTEST_RUNNERに設定すればOKです。

テストに失敗している間はDBを作り直したりもしないので、かなり高速(1秒未満)にテストできます。 ただし失敗している間にDBの構造が変わった場合には、.failtestsファイルを削除して再度全体テストを回す必要があるので注意しましょう。

あと嬉しい誤算として、テストに失敗しているとgit status時に.failtestsファイルが見えるため、このファイルがある間はコミットしてはいけないなというのがすぐに分かります。gitのコミットメッセージを書く時にはuntracked filesが見えるので、(ゴミファイルを常に置くようなことをしていなければ)結構気が付きます。

これでかなり快適にDjangoアプリケーションのテストができるようになりました。 テストを書くのはほんと面倒です。しかし変更の度にクライアントを使って手でやるよりははるかにマシだし、バグ発生数もかなり減るので、Djangoアプリケーションを書く場合には書ける部分は頑張ってテストを書きましょう。

spine/Json.hを使うべきでない4つの理由

2014-03-15 21:43:00

Cocos2d-x で JSON フォーマットを扱うためのライブラリを探すと、大抵最初に見つかるのは spine/Json.h です。

Cocos2d-x に組み込まれているため、何かのライブラリを追加で入れる必要はありません。 使っている人も多いため、ちょっと検索すれば使い方も分かります。

そんな便利な spine/Json.h。 しかし、spine/Json.h は決して使うべきではありません。 その理由は、以下の4つです。

  • リンクリストかつ線形探索なのでクソ遅い
  • 型の間違いを無視する
  • インターフェースが変わりやすい
  • キーの大文字小文字を無視する

この4つについて詳細に説明します。

なお、ここではコミットID 625b9501f320a08e6d3aff2ee2ad4e04c6872cc0 時点の spine/Json.hspine/Json.cpp について書いています。

リンクリストかつ線形探索なのでクソ遅い

この辺を見れば分かりますが、オブジェクトから要素を取ってくるために、リンクリストを線形探索しています。

これは配列の探索も同様です。n 番目の要素を取ってくるために、リンクリストを先頭から順番に辿っていく実装になっています。

これだけでもう、使うべきでないというのは確定です。 もちろん線形探索で問題のないケースというのも存在します。 しかし似たような選択肢が大量にある中、わざわざ遅い実装を使う理由なんてほぼありません。

なお、Cocos2d-x内には、spine/Json.h以外にも、最近ならrapidjson、古いのならJsonCppが入っているようです。 追加でライブラリを入れるのが面倒なのであれば、これらを利用するのがいいでしょう。

型の間違いを無視する

この辺を見れば分かります。 Json_getItemJson型の値を取り出し、それを整数として解釈しています。

これ自体は普通の処理です。 問題は、型に対するエラー処理が何も無いことです。

例えば、取り出した値の型が文字列だった場合には、何のエラーも出さず、単に 0 を返します。 null の場合も同様、何のエラーも出さずに、単に 0 を返します。 しかもこれは、引数のdefaultValueとは無関係に 0 を返します。 defaultValueを返すのは、オブジェクト型のデータからnameの要素が見つからなかった場合だけです。

Json_getStringの場合も同様に、エラー処理が何もありません。 値が文字列以外だった場合、nullptrが返されます。

Segmentation Faultで落ちないだけマシだと言えますが、最低でもassertぐらいは入れておくべきです。 可能なら例外を投げるなどの処理を入れた方がいいでしょう。

なお、spine/Json.hで型をちゃんと見た上で値を取り出すなら、以下のようになります。

Json* p;
if (p->type != Json_Object)
    throw "オブジェクト型じゃない";
Json* q = Json_getItem(p, "hoge");
if (q == nullptr)
    throw "hogeが見つからなかった";
if (q->type != Json_Number)
    throw "hogeの値が数値型じゃない";

// 無事目的の値を取得
int result = q->valueInt;

インターフェースが変わりやすい

以前はJson_getItemAtJson_getSizeという関数がありましたが、今はもう消されています

つまり配列から要素を取得したり、サイズを取得するには、このような実装を書いてあげる必要があるということです。 また、Json_getItemAtを使っていた人は、新しいCocos2d-xのバージョンに乗り換えた際に、この部分を修正する必要があります。

spine/Json.hはSpineのためのJSONライブラリなので、Spine側で使わない関数は消されるし、もしかしたらspine/Json.hが丸ごと消える可能性があります。 そのようなライブラリを使い続けるというのは、後のコストを考えるとかなり危険だと言えます。

キーの大文字小文字を無視する

比較の処理を見れば分かるように、tolower小文字に変換した上でキーを比較しています。

tolowerの第2引数を省略した場合、グローバルなロケールが使われます。 そして、ロケールによってtolowerの返す値は異なります。 ロケールによって結果が異なる例はcppreference.comのtolowerなどを見るといいでしょう。

このようなグローバルな値に依存するということはつまり、最初の検索で成功したのに、次の検索では失敗する可能性があるということです。 どこかのライブラリがふとした拍子にstd::setlocaleを呼び出しただけで、意図しない結果になることがあるのです。

このような比較をしているspine/Json.hは使うべきではないでしょう。

最後に

spine/Json.h を使うべきではない理由を4つ説明しました。 しかし実際のところ、これは spine/Json.h が悪いわけではありません。

そもそも spine/Json.h は、Spine で吐き出された JSON を読むために作られたライブラリです。 その JSON は、要素数が少ないため線形探索でも問題ありません。 自動で生成されたものを Spine の内部で読むだけだから、Spine を使うユーザにとっては型の間違いなんて関係ありません。 同様の理由で、大文字小文字を無視されても問題ありません。 配列のデータを取ってくる処理も必要ありません。

つまり spine/Json.h は、目的に対して十分な機能を有しています。 しかし、決して汎用的なJSONライブラリではありません。

spine/Json.hは利用せず、他のJSONライブラリを使うようにしましょう。

Haskellでのimportの使い方

2014-02-10 18:56:00

修飾名を付ける

基本的に、Haskell でモジュールを import する際には、ちゃんと修飾名を付けるべきです。

これは名前の衝突を防ぐためです。

例えば以下のコードはコンパイルエラーになります。 putStrLnData.TextData.ByteString の両方に存在しているからです。

import Data.Text
import Data.ByteString

main = putStrLn ""

これが衝突しなかったら自由に import していいのかというと、これもやめておいた方がいいでしょう。

というのも、依存パッケージをバージョンアップした時に関数が増えた場合、その関数が他のパッケージの名前を重複する場合があるからです。

module Module.Oreore (oreore) where
oreore = undefined
module Module.Moremore (moremore) where
moremore = undefined

というモジュールがあり、以下の様に利用していたとします。

import Module.Oreore
import Module.Moremore

main = oreore

この後、Module.Moremore モジュールが以下の様に更新されてしまうと…。

module Module.Moremore (moremore, oreore) where
moremore = undefined
oreore = undefined
import Module.Oreore
import Module.Moremore

main = oreore -- コンパイルエラー!

oreore という名前が重複してしまい、コンパイルエラーになります。

この現象のひどいところは、元のソースコードを一切行っていないにも関わらず発生する可能性があることです。 依存するパッケージが、何気ないつもりで関数を一個追加しただけで、コンパイルが通らなくなる可能性があるのです。

このような悲劇を防止するためにも、修飾名を付けるか、あるいは関数名を明示的に指定するべきです。

import qualified Module.Oreore as O
import qualified Module.Moremore as M

main = O.oreore

-- あるいは以下の様にする
import Module.Oreore (oreore)
import Module.Moremore (moremore)

main = oreore

修飾名のルール

自分が作っている Yesod のプロジェクト(例えば melpon.org)では、可能な限り修飾名を統一しています。 これは、ファイルによって異なる修飾名にされると、非常に読みにくいからです。

import qualified Data.Text as T
import qualified Control.Monad as M
import qualified Data.Text as Text
import qualified Data.Map as M

Data.Text の修飾名が T だったり Text だったりと、統一されていません。 また、M という修飾名が Data.Map だったり Control.Monad だったりと統一されていません。

ファイルによって修飾名を変えられたコードを読むのは非常に大変です。 これはプロジェクト全体で統一してやるべきです。

自分の場合は、以下の様な ImportRule.hs を用意し、それを目的のファイルにコピペしています。

{-# OPTIONS_GHC -w #-}
module ImportRule () where

import qualified Control.Monad                          as Monad
import qualified Control.Monad.Logger                   as MonadLogger
import qualified Control.Applicative                    as Applicative
import qualified Control.Exception                      as Exc
import qualified Control.Concurrent                     as Concurrent
import qualified Control.Concurrent.Chan                as Chan

import qualified Data.Aeson                             as Aeson
import qualified Data.Aeson.Types                       as AesonTypes
import qualified Data.Bits                              as Bits
import qualified Data.ByteString                        as BS
import qualified Data.ByteString.Char8                  as BSC
import qualified Data.ByteString.Lazy                   as BSL
import qualified Data.Char                              as Char
import qualified Data.Conduit                           as Conduit
import qualified Data.Conduit.Binary                    as ConduitB
import qualified Data.Conduit.List                      as ConduitL
import qualified Data.Default                           as Default
import qualified Data.Function                          as Func
import qualified Data.IORef                             as IORef
import qualified Data.List                              as List
import qualified Data.Maybe                             as Maybe
import qualified Data.Text                              as T
import qualified Data.Text.IO                           as TIO
import qualified Data.Text.Encoding                     as TE
import qualified Data.Time                              as Time
import qualified Data.Word                              as Word
import qualified Data.Yaml                              as Yaml

import qualified Language.Haskell.TH                    as TH
import qualified Language.Haskell.TH.Syntax             as THS

import qualified Network.HTTP.Conduit                   as HConduit
import qualified Network.Wai.Logger                     as WaiLogger
import qualified Network.Wai.Middleware.RequestLogger   as RequestLogger
import qualified Network.Wai.Handler.Warp               as Warp

import qualified System.Directory                       as Directory
import qualified System.Exit                            as Exit
import qualified System.IO                              as I
import qualified System.Locale                          as Locale
import qualified System.Log.FastLogger                  as FastLogger
import qualified System.Mem                             as Mem
import qualified System.Environment                     as Environment

import qualified Text.Hamlet                            as Hamlet
import qualified Text.Hamlet.RT                         as HamletRT
import qualified Text.Jasmine                           as Jasmine
import qualified Text.Shakespeare.Text                  as Text

import qualified Yesod                                  as Y
import qualified Yesod.AtomFeed                         as YAtomFeed
import qualified Yesod.Core.Types                       as YCoreTypes
import qualified Yesod.Default.Config                   as YDConfig
import qualified Yesod.Default.Handlers                 as YDHandlers
import qualified Yesod.Default.Main                     as YDMain
import qualified Yesod.Default.Util                     as YDUtil
import qualified Yesod.Feed                             as YFeed
import qualified Yesod.RssFeed                          as YRssFeed
import qualified Yesod.Static                           as YStatic

これは別に .hs ファイルにする必要は無く、単なるテキストファイルに書いておいても構いません。 .hs にしているのは、vim でシンタックスハイライトした表示にするのが楽だからというだけです。

ひとまず、こうすることで、プロジェクト全体で統一した名前になり、コードが読みやすくなりました。

例えば以下のコードは Yesod が自動生成したコードです。

import Yesod
import Yesod.Default.Config
import Yesod.Core.Types
import Network.Wai.Middleware.RequestLogger
    ( mkRequestLogger, outputFormat, OutputFormat (..), IPAddrSource (..), destination
    )
import qualified Network.Wai.Middleware.RequestLogger as RequestLogger
import Data.Default

import Foundation
import Settings
import Settings.Development

makeApplication :: AppConfig DefaultEnv Extra -> IO Application
makeApplication conf = do
    foundation <- makeFoundation conf

    -- Initialize the logging middleware
    logWare <- mkRequestLogger def
        { outputFormat =
            if development
                then Detailed True
                else Apache FromSocket
        , destination = RequestLogger.Logger $ loggerSet $ appLogger foundation
        }

    -- Create the WAI application and apply middlewares
    app <- toWaiAppPlain foundation
    return $ logWare app

これが、ルールに従って書くと、以下の様になります。

import qualified Yesod                                  as Y
import qualified Yesod.Default.Config                   as YDConfig
import qualified Yesod.Core.Types                       as YCoreTypes
import qualified Network.Wai.Middleware.RequestLogger   as RequestLogger
import qualified Data.Default                           as Default

import Foundation (App(appLogger))
import Settings (Extra)
import Settings.Development (development)

makeApplication :: YDConfig.AppConfig YDConfig.DefaultEnv Extra -> IO Y.Application
makeApplication conf = do
    foundation <- makeFoundation conf

    -- Initialize the logging middleware
    logWare <- RequestLogger.mkRequestLogger Default.def
        { RequestLogger.outputFormat =
            if development
                then RequestLogger.Detailed True
                else RequestLogger.Apache RequestLogger.FromSocket
        , RequestLogger.destination = RequestLogger.Logger $ YCoreTypes.loggerSet $ appLogger foundation
        }

    -- Create the WAI application and apply middlewares
    app <- Y.toWaiAppPlain foundation
    return $ logWare app

自分としては、これで大分読みやすくなったかなと思います。

デメリットは、新しいモジュールを追加する場合、ImportRule.hs に記述してから目的のファイルに記述することになり、手間が増えることです。 正直、これぐらい Haskell の機能として用意して欲しいところですが…。 無いものは仕方がないので、ひとまずこの方法でやっています。

iOSで消費型プロダクトのアプリ内課金を実装する際の注意点

2014-02-02 04:42:00

TODO:コード例を載せる

Consumable なプロダクトの In-App Purchase は、クライアント側でのアプリ内課金と、サーバ側でのアイテムの付与が1対1になっている必要があります。 課金をしていないのにアイテムの付与をする、あるいは課金をしたのにアイテムを付与していない、という状況は許されません。 今回は、正しくアイテムを付与するためにはどうすればいいのかについて書きます。

正しい付与のやり方

本当に重要な部分は、これだけです。

iOS でアプリ内課金による決済が完了すると、レシートが渡されます。 そのレシートの中にはトランザクションIDが入っていて、それぞれの課金でユニークなIDになっています。 そのため、1個のトランザクションIDで、1回の付与を行えばいいことが分かります。

クライアントは、何とかして、トランザクションIDを含んだレシートをサーバに届ける必要があります。 ネットワークに繋がらない環境であっても、通信中に強制終了されても、アプリを終了されても、頑張って届ける必要があります。 これを満たすためには、最低でも、決済が完了したした時点でレシート情報をアプリ内に保存しておく必要があります。

また、サーバ側から明確に成功か失敗が返るまで、ひたすらリトライをし続ける必要もあります。 アプリを終了して再起動した場合でも、リトライを行う必要があります。 ただし、アプリを消された場合にはどうしようも無いので、これに関してはサポート行きになるでしょう。

サーバは、渡されたレシートの検証を Apple に問い合わせて、付与を行います。 付与を行う際には、必ずトランザクションIDをDBに保存し、そのトランザクションIDが既に付与済みであるかどうかを確認する必要があります。 また、付与済みかどうかを確認し、実際に付与を行い、付与済みフラグを設定するという動作をアトミックに行う必要があります。

まとめると、クライアントは以下の処理を行う必要があります。

  • 課金が完了したら、すぐにレシートをアプリ内のストレージに保存する
    • 複数のレシートが存在するアプリの場合は、キューに入れるような実装にすること
  • サーバにレシートを送信する
  • (※)送信した結果が明確に成功か失敗かを返すまでリトライを行う
    • 成功か失敗が返ってきたらストレージのレシートを削除する
  • 起動時にストレージ内にレシートが残っている場合、(※)のフローへ進む
    • アプリの仕様によっては、起動時でなくても問題ないかもしれない

サーバーは、以下の処理を行う必要があります。

  • クライアントからレシートが送られてきたら、Apple に有効かどうかを問い合わせる
    • 正しく有効だという結果が返されなかった場合、クライアントに失敗かリトライかを返す
  • トランザクション開始、かつレコード取得時には FOR UPDATE を行う。
    • DBに、送られてきたレシートにあるトランザクションIDが含まれたレコードが既に存在してるかを調べる
    • 存在していなければ、そのトランザクションIDを含むレコードを追加する
    • そのレコードが付与済みかを調べる
      • 付与済みだったらここで処理終わり
    • 付与済みでなければ、対象となるレコードに値を加算して保存する
    • 付与済みフラグを立てて保存する
    • コミット

ありがちな間違い

消費型プロダクトのアプリ内課金で、やってしまいがちな間違いについて列挙してみました。 これらの方法を採らないように注意しましょう。

レシートをアプリ内に保存しない

前述の通り、アプリをユーザが終了させた場合でも、レシートをサーバに届ける必要があります。 そのため、レシートをアプリ内に保存せず送信するのは非常に危険です。 必ず保存してから送信を行いましょう。

また、起動時には送信していないレシート情報があるかどうかを確認し、そのようなレシートがあったら送信するフローへ遷移しましょう。

リトライを行わない

リトライは必須です。 クライアントがネットワークに繋がらず、サーバに届けられない可能性があるからです。 サーバにレシートを送信していない状態でリトライをやめてしまうと、そのレシートに対する付与が行なわれず、付与の機会を無くしてしまいます。

レシート確認の失敗をハンドリングしない

サーバ側では、レシートが有効かどうかを Apple に問い合わせます。 このときの失敗をハンドリングしてやらないと、不正なレシートであっても課金をしてしまう場合があります。

レシート確認の際に失敗するケースとしては、以下のことが考えられます。

  • Apple のサーバに繋がらない
  • Apple が返してきた結果が壊れていた(HTTP にすらなっていない)
  • Apple が返してきた結果が 200 系以外だった
  • Apple が返してきたデータが有効な JSON になっていない
  • Apple が返してきたデータが有効な JSON になっているけど、必要な項目が入っていない
  • Apple が返してきた JSON のエラーコードが 0 以外だった

これらを考慮し、ちゃんとハンドリングしましょう。

余談になりますが、Apple が返すエラーコードで気をつけなければならないのは、21005 と 21007 です。

21005 は「レシート検証のサーバが落ちている」なので、リトライすれば成功する可能性があります。 そのため、21005 が返された場合には、クライアントにはリトライを促す結果を返した方がいいでしょう。

21007 は「そのレシートはサンドボックスのものだ」という結果なので、サンドボックス側に問い合わせ直す処理が必要になります。

リクエストが来るたびに付与を行う

クライアント側で何度もリトライを行うので、同じレシートは重複する可能性があります。 あるいは、悪意のあるユーザがわざと何度もレシートを送信してくる可能性もあります。

リクエストが来るたびに付与を行うと、上記のような場合に多重に付与してしまうことになります。 必ずトランザクションIDごとに1回の課金を行うようにしましょう。

メッセージキューを使って DB を使わずに付与を行う

何らかのメッセージパッシングの仕組みを使って付与を行う、これ自体は特に問題ではありません。 しかし、「メッセージキューを使ってるから DB を使わずに付与を行なっても構わない」というのは、ほとんどの場合は間違いです。

メッセージキューというのは、ほとんどの実装でメッセージの重複が発生する可能性があります。 例えば Amazon SQS は、メッセージがワーカーに取得された後、ワーカーが ACK を返さず一定時間が経過したら、そのメッセージを復活させます。 そのため、ワーカーが正常に処理して ACK を返したとしても、それが既にタイムアウトになり、他のワーカーが同じメッセージを処理している可能性があります。

RabbitMQ の場合、メッセージの状態を管理する DB を持っているため、重複が発生する確率は少ないでしょう。 しかしゼロではありません。 XA トランザクションはできないため、メッセージの管理と、それに紐付いた状態の管理を同時に変更できないのです。 そのため重複が発生する可能性は依然としてあります。

ActiveMQ の場合、うまく設定すれば重複を発生しないようにできるでしょう。 これは内部でメッセージ状態を管理する DB を持っていて、かつ XA トランザクションの設定ができるからです。

どのメッセージキューを使うにせよ、DB を使わない実装というのは考えられません。 必ず状態を DB で管理するようにしましょう。

そもそも、課金アイテムの付与でメッセージキューを使う理由はありません。 メッセージキューは非同期に結果を返すときに利用するものです。 しかし、ユーザが課金を行うのは、すぐにそのアイテムが欲しいからです。 そのため、例え非同期に動き、UI 上で自由に行動できるようになっていたとしても、ユーザはそのまま待ち続けるでしょう。

付与済みフラグの書き換えと実際の数の増加がアトミックになっていない

トランザクションIDごとにレコードを作り、付与しているかどうかのフラグを管理しているとします。 また、それとは別のテーブル、あるいは別のDBで、ユーザごとにレコードを作り、実際に付与されたアイテムの数を管理しているとします。 このとき、以下の処理をアトミックに行う必要があります。

  1. 付与済みかどうかを確認する
  2. 付与済みでなければ実際に付与を行う
  3. 付与済みフラグを設定する

2つのテーブルが同じ DB にあるなら、単にトランザクションと行ロックを使って処理するだけです。 2つのテーブルが別の DB にあるなら、XA トランザクションなどを利用し、複数の DB に跨って整合性を担保しましょう。

RSS を配信しました

2014-01-30 23:45:00

このブログに RSS を付けました。

このブログは Haskell の Web フレームワークである Yesod 上で動かしています。 それ自体の話は今後書くとして、今回は RSS の設置について書きます。

Yesod 上で設置できる RSS は yesod-newsfeed というのがあるようです。

yesod-newsfeed は Atom と RSS のどちらも対応しています。 Atom だけ配信するなら Yesod.AtomFeed を、RSS だけ配信するなら Yesod.RssFeed を、両方配信するなら Yesod.Feed を使います。

フィードを配信するのは簡単です。Feed 型のデータを作って、それをそれぞれの方法で配信するだけです。

import qualified Yesod                                  as Y
import qualified Yesod.Feed                             as YFeed

import Foundatin (Route, App)

data Blog = ... -- 適当なブログの定義があるとする

recentBlogs :: [Blog]
recentBlogs = undefined -- 最新のブログ何件か取得する

toFeed :: [Blog] -> IO (YFeed.Feed (Route App))
toFeed blogs = undefined -- 適当に Feed を作る

getFeedR :: Handler Y.TypedContent
getFeedR = do
    feed <- Y.liftIO $ toFeed recentBlogs
    YFeed.newsFeed feed -- Atom と RSS の両方に対応した Feed を返す

newsFeed は、Accept ヘッダーを見て、application/atom+xml なら Atom のフィードを、application/rss+xml なら RSS のフィードを返します。

実際のところ、一番面倒なのは Feed を作る部分です。 これは以下のようになっています。

blogToHtml :: Blog -> Y.Html
blogToHtml = undefined

toEntry :: Blog -> IO (YFeed.FeedEntry (Route App))
toEntry blog = do
    html <- blogToHtml blog
    return YFeed.FeedEntry
        { YFeed.feedEntryLink    = UrlR $ blogURL blog
        , YFeed.feedEntryUpdated = blogDateTime blog
        , YFeed.feedEntryTitle   = blogTitle blog
        , YFeed.feedEntryContent = html
        }

toFeed :: [Blog] -> IO (YFeed.Feed (Route App))
toFeed blogs = do
    entries <- mapM toEntry blogs
    return YFeed.Feed
        { YFeed.feedAuthor      = "melpon"
        , YFeed.feedTitle       = "Blog :: Licensed by Meatware"
        , YFeed.feedDescription = "blog by melpon"
        , YFeed.feedLanguage    = "ja"
        , YFeed.feedLinkSelf    = FeedR
        , YFeed.feedLinkHome    = RootR
        , YFeed.feedUpdated     = YFeed.feedEntryUpdated $ head entries
        , YFeed.feedEntries     = entries
        }

結構埋める項目があって面倒です。 ただ、結構当たり前のデータを入れてるだけなので、難しい訳ではないと思います。

これでフィードを配信できるようになったので、あとはブログページに RSS を用意するだけです。

hamlet ファイルに直接書いてもいいですが、ここは Haskell のコードで書きましょう。

Y.toWidgetHead [hamlet|<link rel="alternate" type="application/rss+xml" title="RSS 2.0" href=@{FeedR}>|]

これを Blog ページへのリクエストを処理するハンドラに書けば完成です。 読者がこの Blog ページをフィードリーダーに登録すれば、フィードリーダーが RSS を見つけてくれます。