Upload
toru-kawamura
View
2.252
Download
1
Embed Size (px)
DESCRIPTION
RubyKaigi 2014での発表に、当日カットしたスライドを加えて「RESTful Web APIs 読書会」で話したものです。 http://www.circleaf.com/events/155 オリジナルバージョン http://www.slideshare.net/tkawa1/rubykaigi2014-hypermedia-the-missing-element
Citation preview
HYPERMEDIA: THE MISSING ELEMENT
to Building Adaptable Web APIs in Rails
Toru Kawamura @tkawa
!RubyKaigi 2014
RESTful Web APIs 読書会 #19 2014.10.09
ハイパーメディア: RailsでWeb APIをつくるには、これが足りない
@tkawaToru Kawamura
• フリーランス Ruby/Rails プログラマ
• Technology Assistance Partner at SonicGarden Inc.
• REST厨 (RESTafarian) inspired by Yohei Yamamoto (@yohei)
• Sendagaya.rb 共同主催
• “RESTful Web APIs” 読書会主催
Web API
http://pixabay.com/en/spider-web-net-grid-silk-drops-13516/
「クモの巣」
https://www.flickr.com/photos/tamaki/260594564/
「クモの糸」
• プライベート
• 内部から使われる
• SPAや専用のクライアントが使う
• だいたい予想できるコントロールできる
• パブリック
• 外部から使われる
• 汎用的な目的の クライアントが使う
• 予想しづらいコントロールしづらい
http://www.slideshare.net/yohei/webapi-36871915– 「WebAPIのこれまでとこれから」by @yohei
http://pixabay.com/en/spider-web-net-grid-silk-drops-13516/
今回は「クモの巣」メインの話です
Change
変化は避けられない !
Web APIは変化に適応しなければならない
2種類の Change
バージョンが変わる バージョンが変わらない
互換性がない 互換性がある
クライアントを壊す クライアントを壊さない
2種類の Change
バージョンが変わる バージョンが変わらない
互換性がない 互換性がある
クライアントを壊す クライアントを壊さない
壊す Change 壊さない Change
壊すChangeはよくない
• ひどいユーザ体験を生む
• クライアント開発者にコードの書き直し・再デプロイを強いる
• もし だったら…
バージョンが変わる バージョンが変わらない
互換性がない 互換性がある
クライアントを壊す クライアントを壊さない
壊す Change 壊さない Change
なぜ起こる?
人間が読める説明書から作られるクライアントがたくさんある
GET /v1/statuses?id=#{id} GET /v1/statuses?id=#{id}
GET /v2/statuses/#{id} GET /v1/statuses?id=#{id}×コードの書き直しが必要
機械が読める説明書から作られるクライアントもある
{ "apiVersion": "1.0.0", "basePath": "http://petstore.swagger.wordnik.com/api", "resourcePath": "/store", "produces": [ "application/json" ], "apis": [ { "path": "/store/order/{orderId}", "operations": [ { "method": "GET", "summary": "Find purchase order by ID", "notes": "For valid response try integer IDs with value <= 5. Anything above 5 or nonintegers will generate API errors", "type": "Order", "nickname": "getOrderById", "authorizations": {}, "parameters": [
GET /v1/statuses?id=#{id} GET /v1/statuses?id=#{id}
{ "apiVersion": "2.0.0", "basePath": "http://petstore.swagger.wordnik.com/api", "resourcePath": "/store", "produces": [ "application/json" ], "apis": [ { "path": "/store/order/{orderId}", "operations": [ { "method": "GET", "summary": "Find purchase order by ID", "notes": "For valid response try integer IDs with value <= 5. Anything above 5 or nonintegers will generate API errors", "type": "Order", "nickname": "getOrderById", "authorizations": {}, "parameters": [
GET /v2/statuses/#{id} GET /v1/statuses?id=#{id}×コードの再生成が必要
{ uber: { version: "1.0", data: [{ url: "http://www.ishuran.dev/notes/1", name: "Article", data: [ { name: "articleBody", value: "First note's text" }, { name: "datePublished", value: null }, { name: "dateCreated", value: "2014-09-11T12:00:31+09:00" }, { name: "dateModified", value: "2014-09-11T12:00:31+09:00" }, { name: "isPartOf", rel: "collection", url: "/notes"
{ uber: { version: "1.0", data: [{ url: "http://www.ishuran.dev/notes/1", name: "Article", data: [ { name: "articleBody", value: "First note's text" }, { name: "datePublished", value: null }, { name: "dateCreated", value: "2014-09-11T12:00:31+09:00" }, { name: "dateModified", value: "2014-09-11T12:00:31+09:00" }, { name: "isPartOf", rel: "collection", url: "/notes"
• APIの変更がクライアントに反映されればよい
• APIの説明を分割して各レスポンスに埋め込むのが良い方法
• APIについての多大な仮定は密結合を生む
「密結合」が原因
バージョンが変わる バージョンが変わらない
互換性がない 互換性がある
クライアントを壊す クライアントを壊さない
壊す Change 壊さない Change
密結合による 疎結合による
例で見る疎結合: FizzBuzzaaS• by Stephen Mizell
http://fizzbuzzaas.herokuapp.com/http://smizell.com/weblog/2014/solving-fizzbuzz-with-hypermedia
• サーバは与えられた100までの数のFizzBuzzを計算できる
• サーバは次のFizzBuzzが何になるか知っている
• クライアントは1から最後まで順番にすべてのFizzBuzzが欲しいhttp://sef.kloninger.com/posts/
201205fizzbuzz-for-managers.html
密結合なクライアント
• すべてのURLとパラメータがハードコードされている
• カウントアップのような、サーバロジックと同じことをクライアントでも行っている
"/v2/fizzbuzz/#{i}"
(1..1000)
(1..100).each do |i| answer = HTTP.get("/v1/fizzbuzz?number=#{i}") puts answer end
疎結合なクライアント
• ハードコードされたURLなし
• URLや条件を変えてもクライアントは壊れない
root = HTTP.get_root answer = root.link('first').follow puts answer while answer.link('next').present? answer = answer.link('next').follow puts answer end next リンクが重要
• 実際にリンクをたどる代わりに、
埋め込みリソースを使うことで
リクエスト数を減らすことが可能
http://techlife.cookpad.com/entry/2014/09/08/093000
「APIコール」のメタファーは危険
• クライアントがあらかじめURLとパラメータを用意してAPIを呼ぶ、というRPCのようなパラダイムから離れよう
• クライアントが次にすることは、レスポンスの中のリンクから選ぶこと
== ハイパーメディア
これは想像上のものではなくすでにHTMLにある
HTMLのWeb• WebアプリやWebサイトはずっと変わり続けているが、ブラウザは壊れていない
• HTMLのWebでは、なぜブラウザは壊れない?HTMLのリンク
http://www.youtypeitwepostit.com/messages
HTMLの中のデータ
• HTMLが表現する意味は
「ヒューマンリーダブルなドキュメント」
• 段落、リスト、表、セクション、…
• これが人間にゆるく伝わればよい
HTMLの中のワークフロー• Webアプリはワークフロー(の提案)を含む
• ワークフローは一連の画面遷移で表現される
— リンク と フォーム
”RESTful Web APIs” p.11 Figure 1-7
ハイパーメディアはワークフローを示す
• 各画面は次に何ができるかの「メニュー」としてリンクやフォームを含む
• ブラウザはその「メニュー」の中から選んで次へ進む
• これがハイパーメディア で、FizzBuzzaaSもやっていたこと
3
4
もしHTMLにリンクがなかったら?
• 各WebアプリごとにURLやパラメータをハードコードした専用クライアントを作りたくなる
• クライアントとサーバのコードが密結合して、変更できないWebアプリになってしまう
• これが今のWeb APIがやっていること
メッセージWebアプリ利用の手順書
1. アドレスバーに /messages と入力して GET
2. アドレスは /messages のまま、 title と body のパラメータに文字列をセットして POST
3. message-id を受け取って、アドレスバーに /messages/{message-id} と入力して GET
代わりにワークフロー手順書がある?
クローラにはもう1つヒントが
• クローラはリンクをたどる(submitできるフォームもある)
• クローラはHTMLドキュメントの中にあるデータとその「意味」を理解している
• どうやって?https://support.google.com/webmasters/answer/99170
Microdata
• 構造化データをHTMLドキュメントに埋め込むしくみ
• データを変えることなくドキュメントの構造を変えられる
• データをURLに結びつけることで、大まかな「データの意味」も表す(これもリンクの一種)
Microdata<div itemscope itemtype="http://schema.org/Person"> My name is <span itemprop="name">Bob Smith</span> but people call me <span itemprop="nickname">Smithy</span>. Here is my home page: <a href="http://www.example.com" itemprop="url">www.example.com</a> I live in Albuquerque, NM and work as an <span itemprop="title">engineer</span> at <span itemprop="affiliation">ACME Corp</span>. </div>
• 構造化データをHTMLドキュメントに埋め込むしくみ
• データを変えることなくドキュメントの構造を変えられる
• データをURLに結びつけることで、大まかな「データの意味」も表す(これもリンクの一種)
Microdata<div itemscope itemtype="http://schema.org/Person"> My name is <span itemprop="name">Bob Smith</span> but people call me <span itemprop="nickname">Smithy</span>. Here is my home page: <a href="http://www.example.com" itemprop="url">www.example.com</a> I live in Albuquerque, NM and work as an <span itemprop="title">engineer</span> at <span itemprop="affiliation">ACME Corp</span>. </div>
schema.org 標準語彙(ボキャブラリー)by Bing, Google, Yahoo! and Yandex
http://getschema.org/index.php/Main_Page
schema.org
• フィールド名・カラム名は変わるが、標準名は変わらない
• 変えたときに壊れないように、変わらない基盤としての標準と結びつける
• schema.org はコーポレートスタンダードの1つといえる
Bing, Google, Yahoo! and Yandex
• 現在700種類以上のデータタイプを定義
変化に適応するために必要なもの
• APIには構造化データが必要
• 柔軟なワークフローにはリンクとフォームが必要
data link form
HTML - ✓ ✓
HTML+Microdata ✓✓ ✓ ✓
✓✓: 「データの意味」を含む
HTMLでWeb APIを作ることもできる
• “Microdata DOM API” でHTMLからデータを抽出できるhttp://www.w3.org/TR/microdata/#using-the-microdata-dom-api
• JavaScriptの実装: https://github.com/termi/Microdata-JS
• MicrodataからJSONに変換する仕様もいくつかある
• HTMLはリンクとフォームを持っているのが大きなアドバンテージ
var user = document.getItems('http://schema.org/Person')[0]; var name = user.properties['name'][0].itemValue; alert('Hello ' + name + '!');
でもきっとJSON Web APIが欲しいはず
• リンクとフォームを埋めればいい
(できればデータの意味も)
data link form
HTML+Microdata ✓✓ ✓ ✓
JSON ✓ - -
✓✓: 「データの意味」を含む
JSONでリンク・フォームを表す• リンクやフォームを表現できるJSONベースのフォーマットを使う
• 他にもSiren, Collection+JSON, Mason, Verbose など…
data link form
JSON ✓ - -
JSON +Link header ✓ ✓ -
HAL ✓ ✓ -
JSON-LD ✓✓ ✓ -
JSON-LD+Hydra ✓✓ ✓ ✓
UBER ✓ ✓ ✓✓✓: 「データの意味」を含む
JSONでデータの意味を表す:「プロファイル」
• ALPSプロファイル
• MicrodataをHTML以外のどんなフォーマットにも適用可能にする
• JSON-LDコンテキスト
• ドキュメントとコンテキストの両方を同じ1つのフォーマットで扱える
A Solution
Hypermicrodata gem
• サーバサイドでHTMLをJSONに変換
• Microdataだけではなく
リンクとフォームもHTMLから抽出
• ベースのALPSプロファイルを用意して、データの意味も表しやすい形でJSONベースのフォーマットを生成
https://github.com/tkawa/hypermicrodata
Example in HAL (application/hal+json){ "image": "/assets/bob.png", "name": "Bob Smith", "isPartOf": "/people", "_links": { "self": { "href": "http://www.example.com/people/1" }, "type": { "href": "http://schema.org/Person" }, "collection": { "href": "/people" }, "profile": { "href": "/assets/person.alps" } } }
class PeopleController < ApplicationController before_action :set_message, only: %i(show edit update destroy) include Hypermicrodata::Rails::HtmlBasedJsonRenderer ... end
.person{itemscope: true, itemtype: 'http://schema.org/Person', itemid: person_url(@person), data: {main_item: true}} .media .media-image.pull-left = image_tag @person.picture_path, alt: '', itemprop: 'image' .media-body %h1.media-heading %span{itemprop: 'name'}= @person.name = link_to 'collection', people_path, rel: 'collection'
Hypermicrodata gemを使ったRailsによる設計手順
1. リソース設計
2. 状態遷移図を描く
3. データの名前を対応するURLに結びつける
4. HTMLテンプレート(Haml, Slimなど)を書いて、Microdata
でマークアップする
(その後、必要ならschema.org定義にないプロファイルと説明を書く)
Example: Note API
1. リソース設計
カラム名 説明 タイプtext noteの内容のテキスト text
published_at noteの公開時間 datetime
(id, created_at, updated_at) (自動生成)
$ rails g model Note text:text published_at:datetime
model: Notecontroller : NotesController
routing: resources :notes
2. 状態遷移図を描く
Collection Memberitem
collection
create*†
update*, delete*
* 安全でない † 冪等でない
Railsの Collection & Member リソースパターンから始める (API ver.)
Collection of Note
Note (text, published_at,
created_at, updated_at, id)
item
collection
create*†
update*, delete*,publish*
next, prev
Home
notes home
* 安全でない † 冪等でない
3. データの名前を対応するURLに結びつける
Collection of Note http://schema.org/ItemList
Note http://schema.org/Article
text http://schema.org/articleBody
published_at http://schema.org/datePublished
created_at http://schema.org/dateCreated
updated_at http://schema.org/dateModified
id (各noteは個別のURLを持つので不要)
Home http://schema.org/SiteNavigationElement
item IANA ‘item’ & http://schema.org/hasPart
collection IANA ‘collection’ & http://schema.org/isPartOf
notes -
create Activity Streams ‘create'
update Activity Streams ‘update’
delete Activity Streams ‘delete’
publish Activity Streams ‘post’
IANA registered Link Relation: http://www.iana.org/assignments/link-relations/Activity Streams Verbs: http://activitystrea.ms/registry/verbs/
4. HTMLテンプレートとMicrodataを書く
/app/views/notes/index.html.haml
GET /notes HTTP/1.1 Host: www.example.com Accept: application/vnd.amundsen-uber+json
%div{itemscope: true, itemtype: 'http://schema.org/ItemList', itemid: notes_url, data: {main_item: true}} - @notes.each do |note| = link_to note.text.truncate(20), note, rel: 'item', itemprop: 'hasPart' = form_for Note.new do |f| = f.text_field :text = f.submit rel: 'create'
{ "uber": { "version": "1.0", "data": [{ "url": "http://www.example.com/notes", "name": "ItemList", "data": [ { "name": "hasPart", "rel": "item", "url": "/notes/1" }, { "name": "hasPart", "rel": "item", "url": "/notes/2" }, { "rel": "create", "url": "/notes", "action": "append", "model": "note%5Btext%5D={text}" }, { "rel": "profile", "url": "/assets/note.alps"} ] }] } }
Collection of Note
Link
Form
%div{itemscope: true, itemtype: 'http://schema.org/Article', itemid: note_url(@note), data: {main_item: true}} %span{itemprop: 'articleBody'}= @note.text %span{itemprop: 'datePublished'}= @note.published_at %span{itemprop: 'dateCreated'}= @note.created_at %span{itemprop: 'dateModified'}= @note.updated_at = form_for @note, method: :put do |f| = f.text_field :text = f.submit rel: 'update' = button_to 'Destroy', @note, method: :delete, rel: 'delete' = button_to 'Publish', publish_note_path(@note), rel: 'publish' unless @note.published? = link_to 'Next note', note_path(@note.next), rel: 'next' if @note.next = link_to 'Prev note', note_path(@note.prev), rel: 'prev' if @note.prev = link_to 'Collection of Note', notes_path, rel: 'collection', itemprop: 'isPartOf'
/app/views/notes/show.html.haml
GET /notes/1 HTTP/1.1 Host: www.example.com Accept: application/vnd.amundsen-uber+json
Note
{ "uber": { "version": "1.0", "data": [{ "url": "http://www.example.com/notes/1", "name": "Article", "data": [ { "name": "articleBody", "value": "First note's text" }, { "name": "datePublished", "value": null }, { "name": "dateCreated", "value": "2014-09-11T12:00:31+09:00" }, { "name": "dateModified", "value": "2014-09-11T12:00:31+09:00" }, { "name": "isPartOf", "rel": "collection", "url": "/notes" }, { "rel": "update", "url": "/notes/1", "action": "replace", "model": "note%5Btext%5D={text}" }, { "rel": "delete", "url": "/notes/1", "action": "remove" }, { "rel": "publish", "url": "/notes/1/publish", "action": "append" }, { "rel": "next", "url": "/notes/2" }, { "rel": "profile", "url": "/assets/note.alps" } ] }] } }
%div{itemscope: true, itemtype: 'http://schema.org/Article', itemid: note_url(@note), data: {main_item: true}} %span{itemprop: 'articleBody'}= @note.text %span{itemprop: 'datePublished'}= @note.published_at %span{itemprop: 'dateCreated'}= @note.created_at %span{itemprop: 'dateModified'}= @note.updated_at = form_for @note, method: :put do |f| = f.text_field :text = f.submit rel: 'update' = button_to 'Destroy', @note, method: :delete, rel: 'delete' = button_to 'Publish', publish_note_path(@note), rel: 'publish' unless @note.published? = link_to 'Next note', note_path(@note.next), rel: 'next' if @note.next = link_to 'Prev note', note_path(@note.prev), rel: 'prev' if @note.prev = link_to 'Collection of Note', notes_path, rel: 'collection', itemprop: 'isPartOf'
Note
{ "uber": { "version": "1.0", "data": [{ "url": "http://www.example.com/notes/1", "name": "Article", "data": [ { "name": "articleBody", "value": "First note's text" }, { "name": "datePublished", "value": null }, { "name": "dateCreated", "value": "2014-09-11T12:00:31+09:00" }, { "name": "dateModified", "value": "2014-09-11T12:00:31+09:00" }, { "name": "isPartOf", "rel": "collection", "url": "/notes" }, { "rel": "update", "url": "/notes/1", "action": "replace", "model": "note%5Btext%5D={text}" }, { "rel": "delete", "url": "/notes/1", "action": "remove" }, { "rel": "publish", "url": "/notes/1/publish", "action": "append" }, { "rel": "next", "url": "/notes/2" }, { "rel": "profile", "url": "/assets/note.alps" } ] }] } }
= button_to 'Publish', publish_note_path(@note), rel: 'publish' unless @note.published? = link_to 'Next note', note_path(@note.next), rel: 'next' if @note.next = link_to 'Prev note', note_path(@note.prev), rel: 'prev' if @note.prev
条件の表現
{ "rel": "publish", "url": "/notes/1/publish", "action": "append" }, { "rel": "next", "url": "/notes/2" },
publishできるが prevには行けない
この設計手順の3つのメリット
メリット 1: DRY• リンクとフォームをHTMLテンプレートに一度書けば、JSON生成時にも再利用できる
• Microdataマークアップもそのまま再利用できる
• Bonus: HTMLのMicrodataはSEO効果を上げる
(JSONにも可能性あり)
メリット 2: リンクとフォームを意識できる
• JSON Web APIを作るときには、状態遷移の重要性を見落としやすい
• APIをHTMLのWebアプリのように表現することで、状態遷移に着目して適切にリンクとフォームを実装できる
メリット 3: 制約
• 「HTMLドキュメントをMicrodataでマークアップして、それを一定のルールでフォーマットされたJSONに変換する」という制約
• この制約はよりよい設計のガイド
• “Constraints are liberating” 「制約は自由をもたらす」
もしJSONだけを書くときは 注意すること
• リンク・フォームを意識するために:
• 状態遷移図を描きましょう
• APIを疎結合に保つために:
• model.to_json の代わりに Jbuilder/RABL のようなビューテンプレートやリプレゼンターを使いましょう
• リンクとフォームを持ったJSONベースのフォーマットを使いましょう
• さらに schema.org のような標準名を使うとベター
– 「Webを支える技術」@yohei
“WebアプリとWeb APIを分けて考えない”
結論: Web APIはHTML Webアプリと同じように設計しよう
• Web APIは特別なものではなく、ただ表現フォーマットが違うだけ
• 状態遷移図を描いて状態遷移を意識することで、リンクやフォームを忘れずにすむ
最後に• 残念ながら、JSONフォーマット、クライアント実装やライブラリにはデファクトスタンダードがない
• RESTの制約・原則を意識するともっとうまくできる
• ハイパーメディアはRESTの最も重要な要素の1つで
変化に適応できるWeb APIへの重要なステップ
よりよい、変化に適応できるWeb APIを作りましょう Thank you for your attention.
• L. Richardson & M. Amundsen “RESTful Web APIs” (O’Reilly)
• 山本陽平 “Webを支える技術” (技術評論社)
• Designing for Reuse: Creating APIs for the Future http://www.oscon.com/oscon2014/public/schedule/detail/34922
• API Design Workshop 配布資料 http://events.layer7tech.com/tokyo-wrk
• https://speakerdeck.com/zdne/robust-mobile-clients-v2
• http://www.slideshare.net/yohei/webapi-36871915
• http://smizell.com/weblog/2014/solving-fizzbuzz-with-hypermedia
• 山口 徹 “Web API デザインの鉄則” WEB+DB PRESS Vol.82
References