聶永的博客

          記錄工作/學(xué)習(xí)的點(diǎn)點(diǎn)滴滴。

          HTTP API設(shè)計(jì)筆記

          前言

          最近一段時(shí)間,要為一個(gè)手機(jī)終端APP程序從零開(kāi)始設(shè)計(jì)一整套HTTP API,因?yàn)槊嫦虻挠脩艉芄潭ǎ粋€(gè)新的移動(dòng)端APP。目前還是項(xiàng)目初期,自然要求一切快速、從簡(jiǎn),實(shí)用性為主。

          下面將逐一論述我們是如何設(shè)計(jì)HTTP API,雖然相對(duì)大部分人而言,沒(méi)有什么新意,但對(duì)我來(lái)說(shuō)很新鮮的。避免忘卻,趁著空閑盡快記錄下來(lái)。

          技術(shù)堆棧的選擇

          PHP嘛?團(tuán)隊(duì)內(nèi)也沒(méi)幾個(gè)人熟悉。

          Java?好幾年沒(méi)有碰過(guò)了,那么復(fù)雜的解決方案,再加上團(tuán)隊(duì)內(nèi)也沒(méi)什么人會(huì) ……

          團(tuán)隊(duì)使用過(guò)Lua,基于OpenResty構(gòu)建過(guò)TCP、HTTP網(wǎng)關(guān)等,對(duì)Lua + Nginx組合非常熟悉,能夠快速的應(yīng)用在線上環(huán)境。再說(shuō)Lua語(yǔ)法小巧、簡(jiǎn)單,一個(gè)新手半天就可以基本熟悉,馬上開(kāi)工。

          看來(lái),Nginx + Lua是目前最為適合我們的了。

          HTTP API,需要充分利用HTTP具體操作語(yǔ)義,來(lái)應(yīng)對(duì)具體的業(yè)務(wù)操作方法。基于此,沒(méi)有閉門造車,我們選擇了 http://lor.sumory.com/ 這么一個(gè)小巧的框架,用于輔助HTTP API的開(kāi)發(fā)開(kāi)發(fā)。

          嗯,OpenResty + Lua + Lor,就構(gòu)成了我們簡(jiǎn)單技術(shù)堆棧。

          HTTP API簡(jiǎn)要設(shè)計(jì)

          HTTP API路徑和語(yǔ)義

          每一具體業(yè)務(wù)邏輯,直接在URL Path中體現(xiàn)出來(lái)。我們要的是簡(jiǎn)單快速,數(shù)據(jù)結(jié)構(gòu)之間的連接關(guān)系,盡可能的去淡化。eg:

          /resource/video/ID
          

          比如用戶反饋這一模塊,將使用下面比較固定的路徑:

          /user/feedback
          
          • GET,以用戶維度查詢反饋的歷史列表,可分頁(yè)
            • curl -X GET http://localhost/user/feedback?page=1
          • POST,提交一個(gè)反饋
            • curl -X POST http://localhost/user/feedback -d "content=hello"
          • DELETE,刪除一個(gè)或多個(gè)反饋,參數(shù)附加在URL路徑中。
            • curl -X DELETE http://localhost/user/feedback?id=1001
          • PUT,更新評(píng)論內(nèi)容
            • curl -X PUT http://localhost/user/feedback/1234 -d "content=hello2"

          用戶屬性很多,用戶昵稱只是其中一個(gè)部分,因此更新昵稱這一行為,HTTP的 PATCH 方法可更精準(zhǔn)的描述部分?jǐn)?shù)據(jù)更新的業(yè)務(wù)需求:

          /user/nickname
          
          • PATCH,更新用戶昵稱,昵稱是用戶屬性之一,可以使用更輕量級(jí)的 PATCH 語(yǔ)義
            • curl -X PATCH http://localhost/user/nickname -d "nickname=hello2"

          嗯,同一類的資源URL雖然固定了,但HTTP Method呈現(xiàn)了不同的業(yè)務(wù)邏輯需求。

          HTTP API的訪問(wèn)授權(quán)

          實(shí)際業(yè)務(wù)HTTP API的訪問(wèn)是需要授權(quán)的。

          傳統(tǒng)的Access Token解決方案,有session回話機(jī)制,一般需要結(jié)合Web瀏覽器,需要寫(xiě)入到Cookie中,或生產(chǎn)一個(gè)JSessionID用于標(biāo)識(shí)等。這針對(duì)單純面向移動(dòng)終端的HTTP API后端來(lái)講,并沒(méi)有義務(wù)去做這一的兼容,略顯冗余。

          另外就是 OAUTH 認(rèn)證了,有整套的認(rèn)證方案并已工業(yè)化,很是成熟了,但對(duì)我們而言還是太重,不太適合輕量級(jí)的HTTP API,不太可能花費(fèi)太多的精力去做它的運(yùn)維工作。

          最終選擇了輕量級(jí)的 Json Web Token,非常緊湊,開(kāi)箱即用。

          最佳做法是把JWT Token放在HTTP請(qǐng)求頭部中,不至于和其它參數(shù)混淆:

          curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiI2NyIsInV0eXBlIjoxfQ.LjkZYriurTqIpHSMvojNZZ60J0SZHpqN3TNQeEMSPO8" -X GET http://localhost/user/info
          

          下面是一副瀏覽器段的一般認(rèn)證流程,這與HTTP API認(rèn)證大體一致:

          JWT的Lua實(shí)現(xiàn),推薦: https://github.com/SkyLothar/lua-resty-jwt.git,簡(jiǎn)單夠用。

          JWT和Lor的結(jié)合

          jwt需要和業(yè)務(wù)進(jìn)行綁定,結(jié)合 lor 這個(gè)API開(kāi)發(fā)框架提供的中間件機(jī)制,可在業(yè)務(wù)處理之前,在合適位置進(jìn)行權(quán)限攔截。

          • 用戶需要請(qǐng)求進(jìn)行授權(quán)接口,比如登陸等
          • 服務(wù)器端會(huì)把用戶標(biāo)識(shí)符,比如用戶id等,存入JWT的payload負(fù)荷中,然后生成Token字符串,發(fā)給客戶端
          • 客戶端收到JWT生成的Token字符串,在后續(xù)的請(qǐng)求中需要附加在HTTP請(qǐng)求的Header中
          • 完成認(rèn)證過(guò)程

          不同于OAUTH,JWT協(xié)議的自包含特性,決定了后端可以將很多屬性信息存放在payload負(fù)荷中,其token生成之后后端可以不用存儲(chǔ);下次客戶端發(fā)送請(qǐng)求時(shí)會(huì)發(fā)送給服務(wù)器端,后端獲取之后,直接驗(yàn)證即可,驗(yàn)證通過(guò),可以直接讀取原先保存其中的所有屬性。

          下面梳理一下Jwt認(rèn)證和Lor的結(jié)合。

          • 全局?jǐn)r截,針對(duì)所有PATH,所有HTTP Method,這里處理JWT認(rèn)證,若認(rèn)證成功,會(huì)直接把用戶id注入到當(dāng)前業(yè)務(wù)處理上下文中,后面的業(yè)務(wù)可以直接讀取當(dāng)前用戶的id值
          app:use(function(req, res, next)
              local token = ngx.req.get_headers()["Authorization"]
              -- 校驗(yàn)失敗,err為錯(cuò)誤代碼,比如 400
              local payload, err = verify_jwt(token)
              if err then
                  res:status(err):send("bad access token reqeust")
                  return
              end
          
              -- 注入進(jìn)當(dāng)前上下文中,避免每次從token中獲取
              req.params.uid = payload.uid
          
              next()
          end)
          
          • 針對(duì)具體路徑進(jìn)行設(shè)定權(quán)限攔截,較粗粒度;比如 /user 只允許已登陸授權(quán)用戶訪問(wèn)
          app:use("/user", function(req, res, next)
              if not req.params.uid then
                  -- 注意,這里沒(méi)有調(diào)用next()方法,請(qǐng)求到這里就截止了,不在匹配后面的路由
                  res:status(403):send("not allowed reqeust")
              else
                  next() -- 滿足以上條件,那么繼續(xù)匹配下一個(gè)路由
              end
          end)
          
          • 一種是較細(xì)粒度,具體到每一個(gè)API接口,因?yàn)殡m然URL一致,但不同的HTTP Method有時(shí)請(qǐng)求權(quán)限還是有區(qū)別的
          local function check_token(req, res, next)
              if not req.params.uid then
                  res:status(403):send("not allowed reqeust")
              else
                  next()
              end
          end
          
          local function check_master(req, res, next)
              if not req.params.uid ~= master_uid then
                  res:status(403):send("not allowed reqeust")
              else
                  next()
              end
          end
          
          local lor = require("lor.index")
          local app = lor()
          
          -- 聲明一個(gè)group router
          local user_router = lor:Router()
          
          -- 假設(shè)查看是不需要用戶權(quán)限的
          user_router:get("/feedback", function(req, res, next)
          end)
          
          user_router:put("/feedback", check_token, function(req, res, next)
          end)
          
          user_router:post("/feedback", check_token, function(req, res, next)
          end)
          
          -- 只有管理員才有權(quán)限刪除
          user_router:delete("/feedback", check_master, function(req, res, next)
          end)
          
          -- 以middleware的形式將該group router加載進(jìn)來(lái)
          app:use("/user", user_router())
          
          ......
          
          app:run()
          

          為什么沒(méi)有選擇GraphQL API ?

          我們?cè)谏弦粋€(gè)項(xiàng)目中對(duì)外提供了GraphQL API,其(在測(cè)試環(huán)境下)自身提供文檔輸出自托管機(jī)制,再結(jié)合方便的調(diào)試客戶端,確實(shí)讓后端開(kāi)發(fā)和前端APP開(kāi)發(fā)大大降低了頻繁交流的頻率,節(jié)省了若干流量,但前期還是需要較多的培訓(xùn)投入。

          但在新項(xiàng)目中,一度想提供GraphQL API,遇到的問(wèn)題如下:

          • 全新的項(xiàng)目數(shù)據(jù)結(jié)構(gòu)屬性變動(dòng)太頻繁
          • 普遍求快,業(yè)務(wù)模型快速開(kāi)發(fā)、調(diào)試
          • 大家普遍對(duì)GraphQL API有些抵觸,使用JSON輸出格式的HTTP API是約定俗成的習(xí)慣選擇

          毫無(wú)疑問(wèn),以最低成本快速構(gòu)建較為完整的APP功能,HTTP API + JSON格式是最為舒服的選擇。

          雖然有些擔(dān)心服務(wù)器端的輸出,很多時(shí)候還是會(huì)浪費(fèi)掉一些流量,客戶端并不能夠有效的利用返回?cái)?shù)據(jù)的所有字段屬性。但和進(jìn)度以及人們已經(jīng)習(xí)慣的HTTP API調(diào)用方式相比,又微乎其微了。

          小結(jié)

          當(dāng)前這一套HTTP API技術(shù)堆棧運(yùn)行的還不錯(cuò),希望能給有同樣需要的同學(xué)提供一點(diǎn)點(diǎn)的參考價(jià)值 :))

          當(dāng)然沒(méi)有一成不變的架構(gòu)模型,隨著業(yè)務(wù)的逐漸發(fā)展,后面相信會(huì)有很多的變動(dòng)。但這是以后的事情了,誰(shuí)知道呢,后面有空再次記錄吧~

          posted on 2018-01-02 20:53 nieyong 閱讀(2457) 評(píng)論(0)  編輯  收藏 所屬分類: HTTP移動(dòng)后端

          公告

          所有文章皆為原創(chuàng),若轉(zhuǎn)載請(qǐng)標(biāo)明出處,謝謝~

          新浪微博,歡迎關(guān)注:

          導(dǎo)航

          <2018年1月>
          31123456
          78910111213
          14151617181920
          21222324252627
          28293031123
          45678910

          統(tǒng)計(jì)

          常用鏈接

          留言簿(58)

          隨筆分類(130)

          隨筆檔案(151)

          個(gè)人收藏

          最新隨筆

          搜索

          最新評(píng)論

          閱讀排行榜

          評(píng)論排行榜

          主站蜘蛛池模板: 华坪县| 皮山县| 上思县| 西乌| 黄龙县| 于都县| 松原市| 美姑县| 建平县| 奇台县| 闽侯县| 隆子县| 佛坪县| 廊坊市| 慈溪市| 黄浦区| 霍邱县| 巫山县| 定南县| 宣汉县| 海口市| 全州县| 平江县| 富顺县| 崇明县| 牡丹江市| 新闻| 津市市| 正镶白旗| 镇安县| 上犹县| 武定县| 玉环县| 阿坝县| 公主岭市| 龙南县| 罗江县| 迭部县| 莱阳市| 武汉市| 疏附县|