mopcon2014 - 使用 sinatra 結合 ruby on rails 輕鬆打造完整 full stack 網站加 api...
DESCRIPTION
使用 Sinatra 結合 Ruby on Rails 輕鬆打造完整 Full Stack 網站加 API Service服務TRANSCRIPT
使⽤用 Sinatra 結合 Ruby on Rails 輕鬆打造完整 Full Stack
網站加 API Service服務 慕凡(@ryudoawaru) at MOPCON 2014
http://5xruby.tw
⼤大家好
是說議程好像和營運沒有神⾺馬關係
⾃自我介紹
遊戲業 10 年1997 - 2007
2007
2007.03 開始全職創業
2007.03 開始全職創業遊⺠民
Tomlan.TW主業:網站開發與維運
FSC 福特⾞車友會http://www.focus-sport.club.tw
FREEBBS台灣免費論壇http://www.freebbs.tw
⾞車界http://www.carclub.tw
五倍紅寶⽯石http://5xRuby.tw
全端技術教育訓練包含但不限於 Ruby / Rails / iOS / Git
專案開發與技術顧問服務Ruby / Rails / iOS
Co-Working Space台北⾞車站旁,三鐵共構加⾼高速網路
⽀支援各式社群活動以 Ruby 為主但不限於 Ruby
Ruby Taiwan 社群http://ruby.tw
Ruby Tuesday已舉辦 31 次
RubyConf Taiwan2010 - 2014,2015 預定於 9⽉月
WebConf Taiwan下⼀一屆預定於 2015
Agenda
• Sinatra 簡介
• Sinatra on Rails
• 撰寫 Mobile API Service 的⼼心得
Sinatra—歷史
• 2007年開始
• 主要作者Konstantin Haase(@rkh)
• ⺫⽬目前版本1.4
Micro Framework2,000⾏行程式碼,只有Rails 的 1%
With Minimal Effort只負責路由與Controller,其它組件任選
Smaller Change Between Releases
主要功能幾乎沒有變動,以新增功能為主
Easy to Bootstrap幾乎不需設定即可啟動
Let’s Try
Hello Sinatra
require 'sinatra' get '/hello' do 'Hello World!' end get '/hello/:name' do "Hello #{params[:name]}!" end
Request的⼀一⽣生
1. 將 HTTP Request 抽象為 request 物件
2. 依據 PATH 決定對應的路由(Route)們
3. 依序執⾏行路由中的程式,以返回值為 Response
RouteCode Block for Making Response
Route
get '/hello/:name' do # matches "GET /hello/foo" and "GET /hello/bar" # params[:name] is 'foo' or 'bar' "Hello #{params[:name]}!" end
1. HTTP VERB
2. PATH INFO
3. RETURN RESPONSE
Route Type
• Named Parameters
• Splat(Wildcard) Parameters
• Regular Expressions Parameters
• Block Parameters
Wildcard Params
get '/say/*/to/*' do # matches /say/hello/to/world params[:splat] # => ["hello", "world"] end
RegExp Params
get %r{/hello/([\w]+)} do "Hello, #{params[:captures].first}!" end
Block Params
get '/hello/:name' do |n| # n stores params[:name] "Hello #{n}!" end get '/download/*.*' do |path, ext| [path, ext] # => ["path/to/file", "xml"] end get %r{/hello/([\w]+)} do |c| "Hello, #{c}!" end
Post Form Params和 Rails ⼀一樣,被歸納在 params Hash 內
Render Response
Render View
get '/' do @products = Product.all #render index view by default layout file erb :index end
Default to ERB
Render View#./vies/layout.erb <!DOCTYPE html> <html> <head><title>Page Title</title></head> <body><%=yield%></body> </html> #./view/products.erb <ul> <%@products.each do |product|%> <li><%=product.name%></li> <%end%> </ul>
File Template
和 Rails Render的差異
• 沒有 Partial
• 檔名強制 Symbol
• 沒有 collection
Filter
before do @member = Member.find(session[:member_id]) end !after do LOGGER.info "Request Finished!" end
和 Route ⼀一樣有 Paramsbefore "/products/:product_id" do @current_product = Product.find(params[:product_id]) end !before /\/products\/(\d+?).*" do |product_id| @current_product = Product.find(product_id) end
可執⾏行複數 Filter
before do @member = Member.find(session[:member_id]) end !#GET /products/1 before "/products/:product_id" do @product = Product.find(params[:product_id]) end !get "/products/:id" do # @member / @product 都有值 end
依照載⼊入順序
Filter 參數延續傳遞問題
#GET /products/1 before "/products/:product_id" do #params[:product_id] => 1 end !get "/products/:id" do #params[:product_id] => nil #params[:id] => 1 end
Request Object
• 繼承⾃自 Rack::Request
• 和 Rails ⼀一樣,request 物件具有 xhr? / path… 等⽅方法讓你存取 request 的資訊
• 不具備部份 Rails的 request 物件的⽅方法例如 subdomain 等
• 預設 cookies 只能從這裡取
request.accept # 可接受Response Type request.body # request body request.scheme # "http" request.url # "http://example.com/example/foo" request.host # "example.com" request.path # "/example/foo" request.script_name # "/example" request.path_info # "/foo" request.query_string# GET 查詢參數 request.(get|post)? # 是否為指定 Method request.referrer # 參照⾴頁⾯面URL request.user_agent # user agent request.cookies # Cookie Hash request.xhr? # 是否為 Ajax request.ip # client IP address request.env # Rack ENV Hash
Error Block
not_found do 'This is nowhere to be found.' end error ActiveRecord::RecordNotFound do 404, erb(:not_found) end
Helper
helpers do def post_path(post) to "/posts/#{post.id}" end end
重要的 Helper們• session
• cookies
• to / url
• redirect
• halt
• settings
Session
get '/:value' do session[:value] = params[:value] end
和 Rails 相同,名為 session 的Hash
Use Another Session Store
require "rack/session/redis" use Rack::Session::Redis, { :url => "redis://localhost:6379/0", :namespace => "rack:session", :expire_after => 600 }
Cookie
get '/:value' do request.cookies['cdb_sid'] end
預設其實沒有 cookie helper,要從 request 物件取得 Hash
to Helper
#mount in '/' to('/') #=> ‘/' !#mount in '/api/v1' to('/') #=> '/api/v1/'
注意相對路徑,別名url
Redirect
get '/foo' do redirect to('/bar') end
等同 Rails 的 redirect_to
Halt
before "/products/:id" do begin @member = Member.find(session[:member_id]) rescue ActiveRecord::RecordNotFound halt 401, erb(:notfound) end end !delete "/products/:id" do #halt後,將不會執⾏行 end
離開現有流程,停⽌止後續 Route 處理並 Render Response
Setting Valueconfigure do set :foo, 'bar' enable :session #set :session, true disable :logging set(:css_dir) { File.join(views, 'css') } end !get '/' do settings.foo? # => true settings.foo # => 'bar' ... end
Configure Block
configure :development do #Only execute when development env enable :logging end !configure do enable :method_override set :view, "app/views" end
在程式啟動時執⾏行⼀一次,開啟 DB 連接或執⾏行設定
Variable Scopeclass MyApp < Sinatra::Base #此處為 Class Scope set :foo, 42 foo # => 42 get '/foo' do # 進⼊入 Request Scope end end
Class Scope
Instance Scope
Variable Scope
• Application / Class Scope
• 發⽣生於Class 宣告或 Configure 區塊
• Instance / Request Scope
• 發⽣生於Route / Filter 區塊
Class 宣告
開始
Request 發⽣生 尋找 Route
是否還有下⼀一個 Code Block?
執⾏行 Code Block
最後⼀一個 Code Block結束 返回 Response
等待下⼀一個 Request
發⽣生
否
是
包含FIlter
class MyApp
MyApp.new
此時還會執⾏行 After Filters
Variable Scope
• Application / Class Scope
• 只發⽣生在載⼊入程式時
• Instance / Request Scope
• 每次 Request 發⽣生,都會產⽣生⼀一個新的 Instance(實體)
變數傳遞持久性
Named Params
Form Params
Instance Variables
Request Object
持久性 僅限於 Route Block 有 有 有
Modular App Stylerequire 'sinatra/base' class MyApp < Sinatra::Base # OR Sinatra::Application set :sessions, true set :foo, 'bar' get '/' do 'Hello world!' end end
Modular V.S. Classic Style
Rack
Rackup File config.ru
Rackup File
# This file is used by Rack-based servers to start the application. !require ::File.expand_path('../config/environment', __FILE__) run Rails.application
Rack Middlewares in Rails
Modular App in Rackup File
#config.ru require 'sinatra/base' class MyApp < Sinatra::Base # OR Sinatra::Application set :sessions, true set :foo, 'bar' get '/' do 'Hello world!' end end run MyApp
Why need Modular?
• 在 Rackup 中被識別(middleware)
• 在 Rails 中被識別
• 做為⼀一個獨⽴立的模組
Ruby Web
Framework
Application Server
Middlewares End Point
Run Multi App in 1 Rackuprequire 'sinatra/base' class App1 < Sinatra::Application get '/' do 'app1' end end class App2 < Sinatra::Application get '/' do 'app2' end end map('/app1'){run App1} map('/app2'){run App2}
Sinatra on RailsMount Rack Middleware in your Rails App
Mount in Rails Route#routes.rb Rails.application.routes.draw do root 'hello#world' resources :customers mount Api::V1, at: '/api/v1' end #lib/api/v1.rb class Api::V1 < Sinatra::Application get '/' do 'API' end end
Mount Outside Rails# This file is used by Rack-based servers to start the application. require ::File.expand_path('../config/environment', __FILE__) require ::File.expand_path('../lib/api/v1', __FILE__) map '/api/v1' do run Api::V1 end run Rails.application
哪種⽅方式⽐比較好?
Benchmark
Test Suite
• MacPro Retina ’13 2012
• DB:PostgreSQL
• Rails:4.1,Sinatra:1.4.5
• App Server:Thin
測試程式
• https://github.com/ryudoawaru/rails-metal-test-example-mopcon2014
• 從 DB 的 Customer 資料庫抓 頭 10筆資料 render JSON
測試⽅方式
• ab -c 10 -n 10 <URL>
• 反覆測試五次
Model Code
class Customer < ActiveRecord::Base def self.fetch(idgt = nil) where("customerid > ?", idgt.to_i).limit(20).map(&:ser) end end
Rails Controller Code
class CustomersController < ApplicationController def index respond_to do |f| f.json do render json: Customer.fetch.to_json end end end end
Sinatra Code
module Api class V1 < Sinatra::Application get '/customers.json' do Customer.fetch.to_json end end end
config.ru# This file is used by Rack-based servers to start the application. require ::File.expand_path('../config/environment', __FILE__) require ::File.expand_path('../lib/api/v1', __FILE__) map '/ext/api/v1' do run Api::V1 end run Rails.application
routes.rb
Rails.application.routes.draw do mount Api::V1, at: '/api/v1' root 'hello#world' resources :customers end
URL 對應表
Rails http://localhost:3000/customers.json
Mount in Rails Route http://localhost:3000/api/v1/customers.json
Mount in Rackup http://localhost:3000/ext/api/v1/customers.json
測試結果
Rails Mount in Rails Route
Mount in Rackup (outside of Rails)
RPQ平均 150~ 200~ 300+
Why Rails Slow?
Middlewares Stack
Rack Middleware就是
Endpoint APP
Application Server
Middleware
Middleware
⼀一個⼀一直玩的概念
堆疊⽐比較
Rails + Sinatra
Application Server
Middleware
Middleware
Rails
Application Server
Middleware
Middleware
Middleware
PATH
Rails
Middleware
map ‘/’
map ‘/ext/api/v1’
其實 Rails 也有 rails-api Gem
減少 Middleware Stack
Sinatra on Rails 架構的好處
在同⼀一個專案下,實際分離前後台
• 程式碼在同⼀一個專案
• 維護性++
發揮各⾃自⻑⾧長處
• ⽤用 Rails 寫後台—> 快速⽅方便
• Sinatra 寫 API —> 簡潔有⼒力
共⽤用部份資源
• Model
• Library
• 載⼊入的 Gem
• Helper(不建議)
節省⾃自⾏行編寫 Bootstrap 程序的時間與精⼒力
沒有 Rails 就要⾃自⼰己寫
• Rubygem / Bundler
• Require
• Model / DB 連接
• Library的前置作業或設定(例如 Uploader)
前後台⽤用 Hostname 分離
• api.example.com -> API ⽤用
• backend.example.com -> 管理介⾯面⽤用
可以只開啟Rails或只開啟Sinatra
#config.ru if ENV['RUN_ONLY'] == 'rails' run Rails.application elsif ENV['RUN_ONLY'] == 'sinatra' run MySinatraApp else map '/api/v1' do run MySinatraApp end run Rails.application end
使⽤用 Sinatra 開發 Mobile API的注意事項
注意 Content-Type Header
before '*.json' do headers "Content-Type" => "text/json" end
活⽤用 Status Code
get '/events/:id.json' do begin event = Event.find(params[:id]) halt 404 end end
活⽤用 Response Header
Response Page Information
get '/events.json' do collection = Event.api_search(…) headers 'total_pages' => collection.total_pages.to_s headers 'total_count' => collection.total_count.to_s headers 'current_size' => collection.size.to_s render_collection(collection).to_json_with_encode end
Mobile API ⾝身份認證⽅方式
以⾃自有服務為主
Request Header通常 APP 的 WEB Client 都不具備 Session 功能
Auth By Header#Helper def current_mobile_session token = request.env[‘HTTP_AUTHTOKEN'] || session[:authtoken] @current_mobile_session ||= MobileSession.auth_api_session(token) end !def current_user @current_user ||= current_mobile_session.user if current_mobile_session.present? end
Token的來源
• 裝置的 UUID(裝置辨認)
• 某種時間段戳記(例如 EPOCH / ⼀一週 or ⼀一天秒數)
• User Table 的某個欄位(Access Token)
• 以上三者 MD5 加密
WebView in APP問題
• 有時會遇到需要辨認⾝身份的 WebView(如購物)
• WebView 在啟動同時發出的 Request 可以包含⾃自訂 Header,但是在 WebView 內的連結或表單不⾏行
• 反過來,在 WebView 內可以⽤用 Cookie / Session
流程
1. 開啟WebView,發出初始 HTML Request(有Header)
2. Server 端接收 Request 時,設定 Session
3. WebView 內由 User 點擊再發出的 Request 就會有 Session
範例
before do token = request.env['HTTP_AUTHTOKEN'] || session[:authtoken] ##接下來⽤用 token 進⾏行認證 end
JSON Generation
#config/initializer/hash.rb [Array, Hash].each do |klass| klass.class_eval do def to_json_with_encode JSON.generate(self, ascii_only: true) end end end
對於從寫 Web 轉到寫 Mobile API 的幾點⼩小建議
時間欄位請傳EPOCH 整數
class Customer < ActiveRecord::Base def created_at_i created_at.to_i end end
使⽤用 before / after_id⽽而⾮非分⾴頁
#in Controller # GET ‘/pms/before/12345’ get '/pms/before/:pmid' do Pm.before_pmid(params[:pmid]) .... end
減少 Request 數量顧及流暢性
N個願望⼀一次滿⾜足
get '/all.json' do {unread_messages: current_user.pms.all, events: Event.recent(10) }.to_json end
論寫 Sinatra 的必要性藏在魔法後⾯面的東⻄西
標準的 Rails 程式碼# in app/controllers/customers_controller.rb class CustomersController < ApplicatoController ... def index @customers = Customer.page(1) end end # in app/views/customers/index.html.erb <ul> <%=render @customers%> </ul> # in app/views/customers/_customer.html.erb <li><%=customer.id%>:<%=customer.name%></li>
它可以產⽣生的 HTML
<ul> <li>1:很好填</li> <li>2:⾃自⼰己填</li> <li>3:不想填</li> <li>4:給你填</li> </ul>
你知道 render 那⾏行發⽣生了神⾺馬事嗎?
順序
1. 查看@customers 集合中的成員 Class -> Customer
2. 以 Class Name 查詢是否有相對應的 Partial View 存在
3. 以 @customers 為 collection 的值來 render Partial
<%=render @customers%>
<%=render partial: 'customer', collection: @customers%>
另⼀一個常⾒見問題
# <%=form_for @customer do |f|%> .... <%end%> #將會產⽣生 <form action="/customers/1" method="post"> <input name="_method" value="put"/> ... </form>
假設 @customer 是⼀一筆存在的 Customer 紀錄, id為 1
Q:為何 @customer 可以轉成 /customers/1
@customer.new_record? # false @customer.class.to_s # Customer @customer.class.to_s.underscore # customer @customer.class.to_s.underscore.pluralize # => customers
Q:請問 pluralize 是 Ruby 預設的 String Instance
Method嗎?
i[~] $ irb 2.1.3 :001 > 'customer' => "customer" 2.1.3 :002 > 'customer'.pluralize NoMethodError: undefined method `pluralize' for "customer":String from (irb):2 from /Users/ryudo/.rvm/rubies/ruby-2.1.3/bin/irb:11:in `<main>' 2.1.3 :003 >
Q:_method 是什麼?
_method Param
• 瀏覽器不⽀支援 GET / POST 以外的 REQUEST METHOD
• ⽤用 _method 代替 REQUEST METHOD
• 簡稱 Method Override
寫 Sinatra ,預設你必需⾃自⾏行處理這些問題
http://shugo.net/tmp/rails-syndrome.pdf
“Difference between a master and a beginner? The master has failed more times than the beginner has
even tried.”⼤大師與新⼿手之間的差別,就是⼤大師失敗過的次
數,⽐比新⼿手嘗試過的次數還多
共勉之
Fin
Contact Me• 慕凡
• http://ryudo.tw
• Github:ryudoawaru
• Twitter:ryudoawaru
代客徵求 ROR Developer
• 台北知名網站公司
• ⽉月薪 45 - 60 K
• 職缺:Junior ROR Developer
• 詳情請洽 http://goo.gl/h2YOAU 或找我
• 寄履歷到 [email protected]