MetaGatewayに見る次世代Webサービス

第4回プラグイン実装でわかるMetaGatewayのコンセプト

Web APIに柔軟に対応するために

MetaGatewayは、他のブログエディタではサポートしていないさまざまなサービスなどを、すべて同じように操作することができます。具体的には「対応サービスとサポートする機能」を参照してください。

さらにこれらのサービスだけでなく、Google Mapや各種アフィリエイト、その他さまざまなAPIを利用して、外部サイトとのマッシュアップを行っています。数だけでいえば、ある意味究極のマッシュアップサービスかもしれません。

MetaGatewayでは新しいサービス等が増えても、非常に簡単に機能追加が可能になるように注意して設計されています。APIや認証などさまざまなプラグインを配置すれば対応できるような形です。

たとえば、APIのプラグインであれば、Twitterならtwitter.py, mixiならmixi.pyというコードの中に、サービスに依存するコードがすべて集約されています。問題の切り分けがしやすく、メンテナンスコストが低い構成というのを常に意識しています。

実際はファイルを置いただけで機能を追加できるようにでもできますが、ファイルのリスティングなどのパフォーマンスをちょっとでも稼ぐために、プラグインモジュールの__init__.pyにリスト1のようなデータを定義しているので、そこは合わせて修正する必要はあります。

リスト1
SERVICE_SUPPORTS = [
    {
        'key':'twitter',
        'api':API_TYPE_TWITTER,
        'auth':AUTH_TYPE_BASIC,
        'type':SERVICE_TYPE_NO_REQUIRE,
    },
    {
        'key':'hatena_haiku',
        'api':API_TYPE_HATENA_HAIKU,
        'auth':AUTH_TYPE_BASIC,
        'type':SERVICE_TYPE_NO_REQUIRE,
    },
    ...
]

MetaGatewayのプラグイン実装

まずはMetaGatewayのコードを実際に出しながら、どういう構造になっているのかを話していこうと思います。まず具体的に、それらのプラグインがどのように実装されているのかを簡単に説明します。例として、マイクロブログに対応するためのプラグイン部分をとりあげます。マイクロブログの代表的なものに、Twitter、 はてなハイク、Jaiku、cotobako、Haru.fm、もごもご、piyo、Timelog、Wassr等があります。

まず、すべてのAPIプラグインはApiPlugin抽象クラスリスト2からの派生クラスになります。これらのメソッドをオーバーライドすることにより、挙動を制御することができます。インターフェースに関しては、XMLRPCのインターフェース(blogger、metaweblog、 movabletype、 typepad)とatompubを抽象化したあとに、各種APIを参考にすべてを表現するために足りないものを追加したものになります。

リスト2
class ApiPlugin():
    DEFAULT_CHANNEL_ID = '1'

    def __init__(self, end_point, auth):
        self.end_point = end_point
        self.auth = auth
        self.error_msg = None

    def isAuthed(self):
        return False
    def getUserInfo(self, params):
        return False
    def getUserChannels(self, params):
        return False
    def getPost(self, params):
        assert( params.has_key('post_id') )

        post_id = params[ 'post_id']
        documents = self.getRecentPosts(params)
        for document in documents:
            if( document['post_id'] == post_id ):
                return document

        raise ApplicationException()

    def getRecentPosts(self, params):
        return []
    def createPost(self, params):
        raise ApplicationException()
    def isCreatePost(self):
        return True
    def deletePost(self, params):
        raise ApplicationException()
    def editPost(self, params):
        raise ApplicationException()

    def createUploadFile(self, params):
        raise ApplicationException()
    def getUploadFiles(self, params):
        raise ApplicationException()
    def deleteUploadFile(self, params):
        raise ApplicationException()

    def getCategories(self, params):
        return []
    def getPostCategories(self, params):
        return []
    def setPostCategories(self, params):
        return True
    def getTrackbackPings(self, params):
        return []
    def canTrackback(self, params={}):
        return False
    def rebuildSite(self, params):
        return True

    def formatTitle(self, title = ''):
        return title
    def formatBody(self, body = ''):
        return body

    boundary = None
    def makeBoundary(self):
        if( self.boundary == None ):
            self.boundary = '----------------------------' + str(int(time.time()))
        return self.boundary

    MULTIPART_CONTENT_TYPE = 'multipart/form-data'
    def makeMultipartHeader(self):
        content_type = ("%s; boundary=%s" % (self.MULTIPART_CONTENT_TYPE, self.makeBoundary()))
        return {'Content-Type':content_type}

    def makeMultipartBody(self, params, exinfo={}):
        boundary = self.makeBoundary()
        orderd = sorted(params.keys())
        s = ''
        for k in orderd:
            s += ( '--' + boundary + "\r\n" )
            if( exinfo.has_key(k) ):
                s += ('Content-Disposition: form-data; name=\"%s\"; filename="%s"\r\n' % (k, exinfo[k]['filename']) )
                s += ('Content-Type: %s' % (exinfo[k]['content-type']) + "\r\n")
                s += ('Content-Length: %s' % len(params[k]) + "\r\n\r\n")
            else:
                s += ('Content-Disposition: form-data; name=\"%s\"\r\n\r\n' % (k) )
            s += (params[k] + "\r\n")
        s += ( '--' + boundary + '--\r\n\r\n' )
        return s

リスト2のコードは、だいたい先に挙げたインターフェースと1対1にマップされているのですが、いくつかわかりにくいものもあるので説明しておきます。

isAuthedは、getUserChannels時に認証できない場合、実際に記事を取得してみて認証を行うためにあります(ただ、実際には書き込んでみないと合っているかどうが一切かわからないサイトも多く、⁠開発者泣かせ」も結構あります⁠⁠。

isCreatePostは、本当にデータが書き込まれたかをチェックするためにあります(Twitterなどでは、連続で同じステータスを書き込むと成功であるにもかかわらず、1つにまとめられてしまい書き込まれないため⁠⁠。

rebuildSiteは、MovableTypeのようにmt.publishPostしないと再構築されないサイトが数多くあるので、そのためにあります。formatTitle, formatBodyはMicroformatsなどが必要なサイトの場合、そこを変換するためにあります。さらに、Google App Engineのfetchでは、postのContent-Typeが常にapplication/x-www-form-urlencodedのになってしまうので、multipart/form-dataできるようにメソッドを追加しています。

プラグイン作成の実際

実際のTwitterプラグインはリスト3のようになります。これに関しても、Twitter互換のAPIをもつサイトが多いので、書き換えられるようにしているわけです。そのため、Twitterから派生しているmogomogoは、リスト4のように簡単に記述することができます。また「はてなハイク」なども同様にTwitter互換のAPIですので、非常に簡単に実装することができるわけです。

リスト3
class ApiPlugin_twitter(ApiPlugin):
    DATE_FORMAT = '%a %b %d %H:%M:%S +0000 %Y'
    MAX_POST_LEN = 140
    RETURN_ID_NODE = 'id'

    _capable = CHANNEL_CAPABLE_ALL & ~CHANNEL_CAPABLE_EDIT \
                                   & ~CHANNEL_CAPABLE_HTML \
                                   & ~CHANNEL_CAPABLE_UPLOAD \
                                   & ~CHANNEL_CAPABLE_SEARCH 

    def _getTitle(self, text, keyword):
        return text
    def _getText(self, text, keyword):
        return text
    def _getPubDate(self, text):
        return datetime.datetime.strptime(text, self.DATE_FORMAT)
    def _getFeedUrl(self, params = {}):
        return self.end_point + 'statuses/user_timeline/' + self.auth.username + '.xml'
    def _getPostUrl(self, params = {}):
        return self.end_point + 'statuses/update.xml'
    def _getDeleteUrl(self, params = {}):
        id = params['id']
        return self.end_point + ( 'statuses/destroy/%(id)s.xml' % { 'id': id } )

    def getUserChannels(self, params = {}):
        channels = [
            { 'channel_id' : self.DEFAULT_CHANNEL_ID, 
              'name' :self.auth.username,
              'url' : self.end_point + self.auth.username,
              'capable': self._capable, },
        ]
        return channels

    def getRecentPosts(self, params = {}):
        headers = self.auth.getHeaders()
        uri = self._getFeedUrl()
        result = urlfetch.fetch(uri, None, urlfetch.GET, headers, False)
        if( result.status_code >= 400 ):
            raise ApplicationException()

        root = cElementTree.fromstring(result.content)
        entries = []
        tag = './status'
        for status in root.findall(tag):
            pub_date = self._getPubDate(status.find('created_at').text)
            id = status.find('id').text
            text = status.find('text').text
            keyword = status.find('keyword')
            if( keyword != None ):
                keyword = keyword.text
            entry = {
                'post_id': id,
                'link': self.end_point + self.auth.username + '/status/' + id,
                'title': self._getTitle(text, keyword),
                'text': self._getText(text, keyword),
                'pub_date' : pub_date,
            }
            entries.append(entry)
        return entries

    def createPost(self, params = {}):
        headers = self.auth.getHeaders()
        uri = self._getPostUrl()
        text = params['text']

        check_text = text.replace('\n', '')
        l = len(check_text)
        if(len(check_text) = 400 ):
            raise ApplicationException()
        root = cElementTree.fromstring(result.content)
        id = root.find(self.RETURN_ID_NODE).text
        return {
            'complete':True, 
            'post_id':id,
            'link': self.end_point + self.auth.username + '/status/' + id,
            'title': text,
            'original_text': text,
            'pub_date': params['pub_date'],
        }

    def isCreatePost(self, params = {}):
        # TODO database compare
        return True

    def deletePost(self, params = {}):
        headers = self.auth.getHeaders()

        id = params['post_id']
        uri = self._getDeleteUrl( {'id':id} )
        result = urlfetch.fetch(uri, None, urlfetch.POST, headers, False)
        if( result.status_code >= 400 ):
            raise ApplicationException()
        root = cElementTree.fromstring(result.content)
        deleted_id = root.find(self.RETURN_ID_NODE).text
        if( id == deleted_id ):
            return True

        raise ApplicationException()
リスト4
class ApiPlugin_mogomogo(twitter.ApiPlugin_twitter):
    DATE_FORMAT = '%a %b %d %H:%M:%S +0900 %Y'
    HOME_URL = 'http://mogo2.jp/home'
    RETURN_ID_NODE = './status/id'

    _capable = CHANNEL_CAPABLE_ALL & ~CHANNEL_CAPABLE_EDIT \
                                   & ~CHANNEL_CAPABLE_DELETE \
                                   & ~CHANNEL_CAPABLE_HTML \
                                   & ~CHANNEL_CAPABLE_UPLOAD \
                                   & ~CHANNEL_CAPABLE_SEARCH 

    def _getFeedUrl(self, params = {}):
        return self.end_point + 'statuses/user_timeline.xml'
    def _getPostUrl(self, params = {}):
        return self.end_point + 'statuses/update.xml'
    def _getPubDate(self, text):
        dt = datetime.datetime.strptime(text, self.DATE_FORMAT)
        offset = 60*60*9
        of = datetime.timedelta(seconds=offset)
        return dt - of
    def getUserChannels(self, params = {}):
        channels = [
                { 'channel_id' : self.DEFAULT_CHANNEL_ID, 
                  'name' : self.auth.username,
                  'url' : self.HOME_URL,
                  'capable': self._capable,
            }
        ]
        return channels

    def isCreatePost(self, params = {}):
        return True

    def deletePost(self, params = {}):
        raise ApplicationException()

日付だけを見てもマイクロブログごとに好きなフォーマットを使っているので、残念ながら一貫性がほとんどありません。

表1 Twitter系マイクロブログの日付フォーマット
twitter'%a %b %d %H:%M:%S +0000 %Y'
mogomogo'%a %b %d %H:%M:%S +0900 %Y'
nowa'%Y-%m-%d %H:%M:%S'
timelog'%Y/%m/%d %H:%M:%S'
はてなハイク'%Y-%m-%dT%H:%M:%SZ'

これらも含めて各マイクロブログごとに微妙な差異があるため、各専用クライアントはそれらを吸収する必要があり、それぞれ対応しなければなりません。なぜ再発明したがるのかがいまひとつ理解できませんが、真似るところは真似る、特化する所は特化する。メリハリが大事なのではないかなと思います。

ちなみに、私が開発しているしゃべるでは完全Twitter互換なAPIを提供しています。そのためHOSTSにtwitter.comと社内サーバのマッピングを記述すれば、どのような専用クライアントを使っても動作します。HOSTSの書き換えを行いたくない場合は、P3のような優れたクライアントを使えば動作します。

ユーザの「入り口」を広げたい

最近のWebアプリケーションは、Webインターフェースだけに特化したものでなくなってきています。スマートフォン、モバイル、ブラウザのExtension、専用クライアントなど、どれもシームレスに連携できるのが当たり前になりつつあります。DropBoxなどのようにExplorerの拡張になっているものもあります。ユーザの入り口を限りなく広めていく必要があるのです。MetaGatewayでもそれらの入り口を広げるためにAPIを実装しています。そのため、どのブログクライアントでも同様に操作することが可能になるのです。

よく言われるのですが、なぜにAtomPubでなくて、MetaWeblogなのか? 理由は明白です。MetaWeblogの方が仕様が明確だからです。機能も豊富ですし、ほとんどのブログクライアントで実装されています。もちろんGoogleのAPIのようなAtomPubの拡張はいいと思いますし、AtomPubの方が技術的にはエッジが利いているかもしれません。ただ、私にとっては最も重要なことではありません。技術云々よりも、まずたくさんの人に喜んでもらうにはどうするのが一番いいのか? それが大切だと考えています。

おすすめ記事

記事・ニュース一覧