过去,当接到为一个网站构建一套API的任务时,我会定义一组URL来处理想要完成的各种任务。
/posts
”的地址获取。/users/sam/posts
”。/posts/the-post-id
”因为这是正确的做法,对吧?
其实,我思考这个思考了很久并且想出了一个我认为十分可行的替代方案。
这也许是一个糟糕的方案,也许是个有用的方案;它很有可能已经被实践过。让我们在进入主题前先欣赏一幅图片,因为媒体类信息总能“引起人们的兴趣”。
假设这么一个场景:我有一个销售T恤衫的网站。它的名字叫“Wit-T-Shirt”,这个名字诠释着服装上标语的欢腾。
在这个网站的某个地方有一个按钮,可以让用户将商品添加到购物车中。在浏览器中,单击这个按钮将调用一个名为“addProductToCart
”的函数,调用这个函数时会提交一个包含商品详细信息和执行该动作用户的ID的对象。
接下来会发生的事与本文的主题密切相关。
最终在服务器上,一个预期传入用户ID和商品详情信息的“addProductToCart
”函数将被调用,它会先检查库存情况,然后更新数据库里用户的详细信息,计算邮费以及进行其他操作。
整个过程的开始和结束方式都十分合理,就是中间的步骤令我非常困扰。
到目前为止,我也还是会用大多数人使用的方法来解决这个问题。
在客户端(“addProductToCart
”函数里),我会把数据分割开来,先创建一个URL并放入用户的ID,然后(在花了十分钟时间用谷歌搜索应该用PUT
还是POST
后)发起一个POST
方法的请求并将剩下的数据填充到这个请求的请求体里。
function addProductToCart(data) {
fetch(`/api/user/${data.userId}/cart`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data.productDetails)
});
}
在服务器端,我会创建一个路由来处理这个请求。这对所有的语言都是一样的,只不过我这里用的语言是NodeJS,同时使用了Express框架。
app.post('/api/user/:userId/cart', (req, res) => {
addProductToCart({
userId: req.params.userId,
productDetails: req.body,
});
});
我正在取分散在URL和方法请求体里的数据并且试图将他们重新组合到一起。URL里的用户ID,请求体里的商品详情和我想要添加一些东西到购物车里的事实都是从HTTP方法和路径的组合推断出来的。
哦!对于这个请求我甚至还没用到查询参数!如果可以,我更倾向于用一些代码来将JavaScript对象转换成一个满足“键/值”语法规则的字符串。
const queryString = Object.entries(params).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&');
说到URL编码,我感觉它挺奇怪的。我的意思是,想一想,路径是由一组变量构成的,这组变量里有由“/
”连接的资源描述符和ID的混合体,对了!还有个“?
”号,其后紧接着的部分是用“&
”符号连接的数组,数组里每一对都是由“=
”号分隔的键值对组成。所有的这些都是以限制了字符集的字符串的形式存在,多么可怕的信息传输工具!
如果有更好的方式就好了......
O API(Obvious API)是一种所见即所得的API。这个名字很蠢(尤其跟在O后面的空格)但是我坚持用这个。
让我们来看看用O API实现的上述场景。
由于我不再需要截断我的信息存储到HTTP请求规范的各个部分,因此对所有的请求我都可以使用同样的URL和HTTP方法,他们不再传达语义。
在请求的请求体中,我将明确指示我想执行什么(动作),以及执行动作需要的(数据)。
function addProductToCart(data) {
fetch('/api', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action: 'addProductToCart', //我想要服务器执行的动作
data, // 执行动作需要的数据
}),
});
}
而在服务器上,我会用下面的来替换我的路由:
app.post('/api', (req, res) => {
if (req.body.action === 'addProductToCart') {
addProductToCart(req.body.data);
}
});
由于现在我发送的所有信息都在请求的请求体中,我可以抽取所有HTTP其他的详细信息到一个类似“sendToServer
”这种名称直截了当的函数里。
function sendToServer(action, data) {
return fetch('/api', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action, data })
});
}
现在,当我想要与服务器通信时,我只需要处理“动作”和“数据”。
function addProductToCart(data) {
sendToServer('addProductToCart', data);
//更新界面同时也可以更新下客户端数据
}
(在什么地方使用catch
和response => response.json()
取决于你,我已经将他们从这些代码片段中脱离开来因为这两种方法之间并没什么差别。)
你可能会想,嗯...你个蠢蛋,这代码量还是一样的啊!
好吧,确实如你所说。但是这场游戏的目标不在于获得最少的代码行数,而在于让代码更为易读,易写,更不容易出错。
因此,在我看来这样做真正的好处是——它去除了那些隐晦地指出需要做什么,以及推断出来与之有关的需要做的事的相关代码。替而代之的是明确表示我们应该做什么的代码。
现在,如果我要扩展这个逻辑来替换多个端点时,我可以使用一个处理程序对象并使用括号调用其中适当的方法,当没有合适的方法匹配时,它也可以处理请求。
const handlers = {
addProductToCart(req, res) {
// 具体的实现代码省略
},
getProduct(req, res) {
// 具体的实现代码省略
},
searchProducts(req, res) {
//具体的实现代码省略
},
signIn(req, res) {
// 具体的实现代码省略
},
signOut(req, res) {
// 具体的实现代码省略
},
};
app.post('/api', (req, res) => {
const handler = handlers[req.body.action];
if (handler) {
handler(req, res);
} else {
//没有匹配到处理程序方法时
console.error(`${req.body.action} doesn't have a handler`);
res.sendStatus(500); // 或者做其他你想做的处理
}
});
无论是O API还是REST API,在服务器上处理这些请求的方式其实是一样的。只有一点例外,就是用REST API时要利用信息做相应处理前,得先把它们从请求体,请求参数和查询参数里集中起来。
另一个好处:如果你的服务端语言恰好是JavaScript,那么你可以以常量的方式共享这些动作,这意味着你可以消除依赖于客户端的“addProductToCart
”方法和服务端的“addProductToCart
”方法一一对应这种形式的脆弱性。
假设你有如下的伪枚举:
const ACTIONS = {
ADD_PRODUCT_TO_CART: 'ADD_PRODUCT_TO_CART',
GET_PRODUCT: 'GET_PRODUCT',
GET_PRODUCTS: 'GET_PRODUCTS',
SIGN_IN: 'SIGN_IN',
SIGN_OUT: 'SIGN_OUT',
// 其他方法
};
那么,在浏览器端,你可以使用:
sendToServer(ACTIONS.ADD_PRODUCT_TO_CART, data);
在服务端,如果你的处理程序和服务端程序是在同一个文件系统下,你也许会看到如下使用形式:
const handlers = {
[ACTIONS.ADD_PRODUCT_TO_CART]: require('./handlers/addProductToCart.js'),
[ACTIONS.GET_PRODUCT]: require('./handlers/getProduct.js'),
[ACTIONS.GET_PRODUCTS]: require('./handlers/getProducts.js'),
[ACTIONS.SIGN_IN]: require('./handlers/signIn.js'),
[ACTIONS.SIGN_OUT]: require('./handlers/signOut.js'),
};
如果你是Redux用户,这可能看起来很熟悉。你的动作创建者分派动作和负载以供存储处理的方式与将动作和负载分派给服务器的方式相同。
他们并没什么不同,两种情况中,你都是从一端发送一条信息到应用的另一端,以达到用某些数据处理一些事情的目的。谁会在乎是发生在浏览器端还是服务器端。
评论列表里David M指出了一个好的观点,如果你想保持一切优雅整洁,结构良好的REST URIs会很有帮助。但实际上,一切你可以用URI来创建的结构,你用动作名称表示的方法一样可以做到,比如:
const ACTIONS = {
USERS: {
CART: {
ADD: 'ADD_PRODUCT_TO_CART',
},
SIGN_IN: 'SIGN_IN',
SIGN_OUT: 'SIGN_OUT',
},
PRODUCTS: {
GET_PRODUCT: 'GET_PRODUCT',
GET_PRODUCTS: 'GET_PRODUCTS',
},
};
REST风格的“统一接口”原则可以在JSON对象的路径中实现。用POST
方法提交到“/users/cart
”的请求从本质上和USERS.CART.ADD
的差别也不是很大。
十分有趣的是只改变一点点东西就可以让它看起来像变成了完全不同的一种方式。也许我只需要好好睡一下,但是现在获取就像一个事件触发器,app.post(‘api’...)就像是一个监听器而请求体就像是一个"数据传输对象"。
所以难道我只是描述了一个事件驱动架构的弄砸了的版本或消息驱动架构或远程过程调用(或JSON-RPC)
还是任何其他我未听说过的?
如果你想这样认为的话,也许是吧!但是我确实只是通过改变网络请求的一些实现细节,做我一直在做的事。
如果我没有提及GraphQL,我会有所失职。所以(那就提一下吧)......
GraphQL。
如果很遗憾,你不是很看好我的观点,那么当你开始设计你下一个API时,你可以考虑下下面哪种情形更符合:
第一种:API服务于你的后端,你希望它支持对底层数据的CRUD操作进行受控访问。它是通用的,并且对请求的应用程序是透明的。
第二种:API服务于您的前端。API的作用是满足特定用户界面的需求。它必须以最合适的格式提供数据,并使客户端可以简单地向服务器发送指令, 从而使客户端可以开展渲染像素和处理用户交互的业务。
显然,如果第一种是你想要的,REST API是一个很好的解决方案。但用Roy Fielding自己的话说:“统一的界面降低了效率,因为信息是以标准化的形式传输的,而不是针对应用程序需求的。”所以…
如果你正在编写一个只会被你自己的前端代码消费的API,并且你看重的并不是满足需求外更复杂的代码,那么可以考虑权衡下O API的可行性。
补充下,因为REST是标准,所以你公开给人们的任何东西都应该是REST(尽管一些重量级的API架构,比如Slack的选择避开这个而变得更像RPC风格)。按照这种逻辑,也可以说,如果你有100多名开发人员,或许坚持采用最常用的方法是有好处的。
不需要非此即彼。如果你是REST的忠实粉丝,请考虑你最喜欢的那个部分。 如果能够以结构化方式识别资源非常重要,那么你就不需要把消息拆分,分散存到URL / method / query / body中。如果你依赖表示资源的URL(用于缓存/路由/日志记录),则可以将该操作名称放在URL中(如Slack所做的那样)。 (除非你基于查询参数进行缓存,那么显然你需要查询参数,并且可能应该使用REST。)
每当我写一篇这样性质(这种我说“你们都做错了,我想出了一种新的方式,即使我都可能不知道自己在说什么”的)文章时,我极有可能得到不少负面评论。
我并没有什么可说的,我只是想让你知道我看到了它。
十分感谢你的阅读,祝你有个超级棒的一天!
原文链接:O API — an alternative to REST APIs By David Gilbertson