跳到主要內容

HTTP Method無法表達出某些動作

Introduction

在設計RestAPI時,並非所有domain操作都符合CRUD;例如,model中資料的同步(sync)、有transaction的行如轉換(transfer)、搬移(move)、複製(copy)等;因此做些案例研究看是否能找尋到自己能滿意的做法。本篇文章是根據研究結果,分享針對操作(action)做resource modeling的心得。

Model the action as a service resource

action本身就是一種service,如果能把它model成一個service resource,是再好不過的。最常見的例子是login/logout,可以model為sessions,以vCloud為例:

POST https://vcloud.example.com/api/sessions 
DELETE https://vcloud.example.com/api/sessions

在伺服器管理的domain中,可能有像修改firmware設定、更新firmware或掃描硬體等操作,這些該如何model為resource呢? 首先是修改firmware設定,以HPE Server Management API為例,它將firmware種類model為一種resource,而設定是它的sub-resource:

GET /Systems/1/BIOS/Settings
PATCH /Systems/1/BIOS/Settings

接著是更新firmware,以Dell ASM REST API為例,使用store resource的方式去操作:

PUT /ManagedDevice/firmware

最後是掃描硬體,以RHEL Virtualization為例,透過post去新增一個discover的sub-resource(task):

POST /api/hosts/2ab5e1da-b726-4274-bbf7-0a42b16a0fc3/iscsidiscover HTTP/1.1
Accept: application/xml
Content-Type: application/xml

<action>
    <iscsi>
        <address>mysan.example.com</address>
    </iscsi>
</action>

HTTP/1.1 202 Accept
Content-Type: application/xml

<action id="e9126d04-0f74-4e1a-9139-13f11fcbb4ab"
  href="/api/hosts/2ab5e1da-b726-4274-bbf7-0a42b16a0fc3/iscsidiscover/
  e9126d04-0f74-4e1a-9139-13f11fcbb4ab">
    <iscsi_target>iqn.2009-08.com.example:mysan.foobar</iscsi_target>
    ...
<action>

看起來好像很容易,但要做到這些需要經驗與想像力,否則model出來的resource可能會讓user覺得困惑。除此之外,還要考量是否有非同步需求;以更新firmware來說,雖然PUT不是不能回傳202,但比較少看到有人使用。由於以上原因,接下來我要分享比較簡單且有不少案例的方法。

A simple way: store resource/PATCH/controller resource

這個方法是根據這篇best practiceREST API Design Rulebook與case studies整理出來的,給大家當一個參考方法。

我們以下面的動作為例:

lock file
unlock file
power on
power off system
activate user
deactivate user
login 
logout
search
import
update firmware
config bios

Classify the actions

首先我們必須先分類;目前我分成兩類,

1. The positive and negative action,動作有正向與反向:

lock/unlock file
power on/off system
activate/deactivate
login/logout

2. The procedural action,動作是一個執行過程:

search
import
update firmware
config bios

The positive and negative action

假如你的動作是屬於這個種類,你有兩個選擇,

  • Treat it like a sub-resource.
  • Patch for the partial update.

如果可以是resource中的屬性,可以考慮用PATCH的方式去更新屬性值:

Patch /Users/12345
{“active”: false}

另外一個選擇就是把它當sub-resource,也就是store resource的做法;positive的action使用PUT,negative的action使用DELETE。以githubbox為例:

lock/unlock (github)
    PUT /repos/:owner/:repo/issues/:number/lock
    DELETE /repos/:owner/:repo/issues/:number/lock
apply/remove the watermark (box)
    PUT https://api.box.com/2.0/files/file_id/watermark
    DELETE https://api.box.com/2.0/files/file_id/watermark

The procedural action

這個能列舉的範例很多,只要不是CRUD的操作,你可以選擇把它model為controller resource:

Search
    POST /v1/users/search (instagram)
    POST /1/indexes/{indexName}/query (algolia)
    POST /indexes/hotels/docs/search (azure)
    POST facebook, twitter, box, github, etc..
POST /videos/reportAbuse (youtube)

如果不想濫用,你可以把動作是非同步做為前置條件。

Case Study: Power Management

以上述的方法,我們來討論電源管理該怎麼做。首先我們把電源當成一個resource,而狀態為其屬性;所以我們可以透過GET去取得狀態,而透過PUT或PATCH去修改狀態:

GET /hosts/123/power
{'state':'on'}
PUT /hosts/123/power
{'state':'on'}
PUT /hosts/123/power
{'state':'off'}

這樣的做法,有哪些問題我們需要考量?

  1. hypermedia: 我們有辦法表達出取得狀態、關機、開機等的link嗎?
  2. synchronized: 在我們做開機與關機動作後,是可以立即反應的嗎?
  3. implementation: 如果使用PATCH的話,我比較不是那麼喜愛,原因稍後再做說明。

考慮以上原因,或許把它做成store resource會比較好:

GET /hosts/123/power_status
{'state':'on'}
PUT /hosts/123/power_on
PUT /hosts/123/power_off

這裡我並沒有使用DELETE,因為在語義上使用DELETE power_on不會比PUT power_off來得清楚。此外,在使用這種方式後,hypermedia可以很容易的使用URI去表達出不同的意義。剩下的問題就是synchronized。

PUT回傳202的方式,目前有看過HPE OneView REST API存在這樣的設計;HTTP規格書也沒說這樣是不對的。我覺得值得討論的部分是: idempotent。假設PUT回傳202,這代表著server將產生一個asynchronized的task,每次PUT power_on所產生的task是否會相同呢? 假如不同,是不是代表違反了idempotent? 假如相同,實作會不會蠻奇怪的呢? 這個我目前沒有答案。

另外一個選擇,就是把它當controller resource,使用POST去操作:

POST /hosts/123/power_on
POST /hosts/123/power_off

有個實際案例就是vCloud的Power On/Off API。順便提一下,會使用這個範例是由於在Roy Fielding在2008年It is okay to use POST文章中,留言區有人提及此範例;而Roy Fielding也是傾向於將狀態與操作model為不同狀態,這使我思考為什麼他會這樣想。

Some questions

針對以上提到的方法,我還思考著幾個問題:

Why don't you use the query string or formdata to pass the action?

(這裡先撇開query string、formdata或request body等方式的差異) 對我而言,我目前認為有幾點需要考量:

  1. hypermedia: 假如要表達出query string等方式,會需要使用template的方式,實作上不會比單純透過URI Path的方式容易。
  2. http method convention: 如果在collection上使用POST,是否會讓新增與其它action讓使用者混淆。
  3. implementation: 我們看看如果使用URI的Path來實做lock與unlock可能會長怎樣:

	@RequestMapping(value = "/files/{fid}/lock", method = RequestMethod.PUT, produces = {"application/json"})
	public OutputData<HostRestBean> lockFile(@PathVariable("fid") String aFid, HttpServletResponse  aResponse){
		// ...
	}
 
	@RequestMapping(value = "/files/{fid}/unlock", method = RequestMethod.DELETE, produces = {"application/json"})
	public OutputData<HostRestBean> unlockFile(@PathVariable("fid") String aFid, HttpServletResponse  aResponse){
		// ...
	}

接著使用POST+RequestParam:

	@RequestMapping(value = "/files/{fid}", method = RequestMethod.POST, produces = {"application/json"})
	public OutputData<HostRestBean> opFile(@PathVariable("fid") String aFid, 
			@RequestParam(value="action", required=true) String action,
			HttpServletResponse  aResponse) {
		if("lock".equalsIgnoreCase(action)) {
			// ...
		} else if("unlock".equalsIgnoreCase(action)){
			// ...
		} else
			// ...
	}

你喜歡哪個? 假如File的action只有lock與unlock,那真的是天下太平;但事實上,action還有move、copy、rename等。考慮一下測試、維護、擴充的話,哪一個會比較好? 以擴充與維護來說,RequestParam的方式讓所有的action都必須接受同一組參數甚至輸出,增加了修改的麻煩;URI Path則由各別的實做去決定。測試則是因為實做已分開,根據各別操作的目的去測試即可。

Threat the P/N action as a store resource or use PATCH?

在我們的專案中,P/N action我傾向使用store resource大於PATCH。主要原因有以下:

  • 合適的media type: PATCH所能使用的media type種類相當多,對於同時支援xml與json格式的我們,要選哪一條路還無定論。PATCH種類有機會再介紹。
  • 不容易寫好維護的程式: 由於PATCH是部分更新,所以程式必須針對開放欄位做是否修改的判斷,也可能要為此打造輸入使用的物件。

@PatchMapping(value = "/files/{id}")
public ResponseEntity<String> editFile(@RequestBody FileBean updateFile, 
		@PathVariable("id") String fileId){
	File file = FileDao.getFile(fileId);
	if( file == null ) {
		return new ResponseEntity<String>("File doesn't exist",  HttpStatus.NOT_FOUND);
	}
 
	if( updateFile.locked != null )
		file.locked = Boolean.parseBoolean(updateFile.locked);
	if( updateFile.name != null )
		file.name = updateFile.name;
	return new ResponseEntity<String>("good",  HttpStatus.OK);
}

  • 符合多種情況的request參數: 面對不同的傳遞參數方式,目前我還沒找到一個共用的方法。

這樣抉擇的發生,是建立在動作屬於Resource中的一個attribute時。

Summary

面對一個非CRUD的domain操作,我們該如何model為resource呢?

  1. 盡力把它變成service resource,可參考他人做法。
  2. 確認是否為一個procedure,是的話就把它當controller resource;可以把非同步的性質當前置條件。
  3. 確認是否為resource中的attribute,是的話可以使用PATCH。
  4. 如果不想用PATCH,可以考慮作為store resource。

以上方法可以當參考,還是要以需求為重;另外補充我看過的其它的案例

Reference

留言

這個網誌中的熱門文章

解決RobotFramework從3.1.2升級到3.2.2之後,Choose File突然會整個Hand住的問題

考慮到自動測試環境的維護,我們很久以前就使用java去執行robot framework。前陣子開始處理從3.1.2升級到3.2.2的事情,主要先把明確的runtime語法錯誤與deprecate item處理好,這部分內容可以參考: link 。 直到最近才發現,透過SeleniumLibrary執行Choose File去上傳檔案的動作,會導致測試案例timeout。本篇文章主要分享心路歷程與解決方法,我也送了一條issue給robot framework: link 。 我的環境如下: RobotFramework: 3.2.2 Selenium: 3.141.0 SeleniumLibrary: 3.3.1 Remote Selenium Version: selenium-server-standalone-3.141.59 首先並非所有Choose File的動作都會hang住,有些測試案例是可以執行的,但是上傳一個作業系統ISO檔案一定會發生問題。後來我透過wireshark去比對新舊版本的上傳動作,因為我使用 Remote Selenium ,所以Selenium會先把檔案透過REST API發送到Remote Selenium Server上。從下圖我們可以發現,在3.2.2的最後一個TCP封包,比3.1.2大概少了500個bytes。 於是就開始了我trace code之路。包含SeleniumLibrary產生要送給Remote Selenium Server的request內容,還有HTTP Content-Length的計算,我都確認過沒有問題。 最後發現問題是出在socket API的使用上,就是下圖的這支code: 最後發現可能因為開始使用nio的方式送資料,但沒處理到尚未送完的資料內容,而導致發生問題。加一個loop去做計算就可以解決了。 最後我有把解法提供給robot framework官方,在他們出新的版本之前,我是將改完的_socket.py放在我們自己的Lib底下,好讓我們測試可以正常進行。(shutil.py應該也是為了解某個bug而產生的樣子..)

Show NIC selection when setting the network command with the device option

 Problem  在answer file中設定網卡名稱後,安裝時會停在以下畫面: 所使用的command參數如下: network --onboot = yes --bootproto =dhcp --ipv6 =auto --device =eth1 Diagnostic Result 這樣的參數,以前試驗過是可以安裝完成的。因此在發生這個問題後,我檢查了它的debug console: 從console得知,eth1可能是沒有連接網路線或者是網路太慢而導致的問題。後來和Ivy再三確認,有問題的是有接網路線的網卡,且問題是發生在activate階段: Solution 我想既然有retry應該就有次數或者timeout限制,因此發現在Anaconda的說明文件中( link ),有提到dhcptimeout這個boot參數。看了一些人的使用範例,應該是可以直接串在isolinux.cfg中,如下: default linux ksdevice = link ip =dhcp ks =cdrom: / ks.cfg dhcptimeout = 90 然而我在RHEL/CentOS 6.7與6.8試驗後都無效。 因此我就拿了顯示的錯誤字串,問問Google大師,想找一下Anaconda source code來看一下。最後找到別人根據Anaconda code修改的版本: link ,關鍵在於setupIfaceStruct函式中的setupIfaceStruct與readNetConfig: setupIfaceStruct: 會在dhcp時設定dhcptimeout。 readNetConfig: 在writeEnabledNetInfo將timeout寫入dhclient config中;在wait_for_iface_activation內會根據timeout做retry。 再來從log與code可以得知,它讀取的檔案是answer file而不是boot command line。因此我接下來的測試,就是在answer file的network command上加入dhcptimeout: network --onboot = yes --bootproto =dhcp --ipv6 =auto --device =eth1 --...

Robot Framework - Evaluate該怎麼用?

Evaluate該怎麼用? 前言 Builtin的RobotFramework Library提供了Evaluate Keyword。它所提供的功能是「執行Python描述句」。但實際上到底有什麼用途呢?原本我僅僅拿來將string轉為int的功用,經過一些查詢與試驗,我將心得整理給大家。 Builtin Builtin的function可以參考Library Doc for Evaluate。我以有使用過的function做說明。 數字轉換 Python提供了int、long、float與complex等function讓你可以將字串轉為數字,也可以透過它們做四則運算。首先以字串轉數字為例,我將8設於${num_str}中,再透過Evaluate+int轉為數字。這裡必須注意的是: 「int()中放變數必須以單引號'括起」。否則,假如你設定的數字為08,在轉換int時會出現Syntax Error。 ${num_str} | Set Variable | 8 ${num} | Evaluate | int('${num_str}') 其中int與long的第二個參數為base,這是根據你的input所決定: Comment | num = 9 ${num} | Evaluate | int('11', 8) Comment | num = 11 ${num} | Evaluate | int('11', 10) Comment | num = 17 ${num} | Evaluate | int('11', 16) 其它還有像bin、oct、hex,可以將整數轉為2、8、16進位。 運算 四則運算: 直接將運算子加上即可: ${num} | Evaluate | int('${hour}')*60 + int('${min}') 指數: 可以用pow。以下面兩個例子來說,第一個是2的3次方為8,第二個是2的3次方再mod 7為1。需注意的是: 「傳入值必須是數字不可為字串」。 ${num} | Evaluate | pow(2,3) ${num} | Evaluate | pow(2,3,7) 取最大最小值: 使用max/min,可以選擇丟一個array的方式...