Web 服务,以这样或那样的形式,已经存在了近二十年。比如,XML-RPC 服务出现在 90 年代后期,紧接着是用 SOAP 分支编写的服务。在 XML-RPC 和 SOAP 这两个开拓者之后出现后不久,REST 架构风格的服务在大约 20 年前也出现了。REST 风格(以下简称 Restful)服务现在主导了流行的网站,比如 eBay、Facebook 和 Twitter。尽管分布式计算的 Web 服务有很多替代品(如 Web 套接字、微服务和远程过程调用的新框架),但基于 Restful 的 Web 服务依然具有吸引力,原因如下:
Restful 服务建立在现有的基础设施和协议上,特别是 Web 服务器和 HTTP/HTTPS 协议。一个拥有基于 HTML 的网站的组织可以很容易地为客户添加 Web 服务,这些客户对数据和底层功能更感兴趣,而不是对 HTML 的表现形式感兴趣。比如,亚马逊就率先通过网站和 Web 服务(基于 SOAP 或 Restful)提供相同的信息和功能。
Restful 服务将 HTTP 当作 API,因此避免了复杂的软件分层,这种分层是基于 SOAP 的 Web 服务的明显特征。比如,Restful API 支持通过 HTTP 命令(POST-GET-PUT-DELETE)进行标准的 CRUD(增加-读取-更新-删除)操作;通过 HTTP 状态码可以知道请求是否成功或者为什么失败。
Restful Web 服务可以根据需要变得简单或复杂。Restful 是一种风格,实际上是一种非常灵活的风格,而不是一套关于如何设计和构造服务的规定。(伴随而来的缺点是,可能很难确定哪些服务不能算作 Restful 服务。)
作为使用者或者客户端,Restful Web 服务与语言和平台无关。客户端发送 HTTP(S) 请求,并以适合现代数据交换的格式(如 JSON)接收文本响应。
几乎每一种通用编程语言都至少对 HTTP/HTTPS 有足够的(通常是强大的)支持,这意味着 Web 服务的客户端可以用这些语言来编写。
TOMCAT_HOME/webapps 目录是已部署的 Web 网站和服务的默认目录。部署网站或 Web 服务的直接方法是复制以 .war 结尾的 JAR 文件(也就是 WAR 文件)到 TOMCAT_HOME/webapps 或它的子目录下。然后 Tomcat 会将 WAR 文件解压到它自己的目录下。比如,Tomcat 会将 novels.war 文件解压到一个叫做 novels 的子目录下,并且保留 novels.war 文件。一个网站或 Web 服务可以通过删除 WAR 文件进行移除,也可以用一个新版 WAR 文件来覆盖已有文件进行更新。顺便说一下,调试网站或服务的第一步就是检查 Tomcat 已经正确解压 WAR 文件;如果没有的话,网站或服务就无法发布,因为代码或配置中有致命错误。
public class Novel implements Serializable, Comparable<Novel> { static final long serialVersionUID = 1L; private String author; private String title; private int id;
public Novel() { }
public void setAuthor(final String author) { this.author = author; } public String getAuthor() { return this.author; } public void setTitle(final String title) { this.title = title; } public String getTitle() { return this.title; } public void setId(final int id) { this.id = id; } public int getId() { return this.id; }
public int compareTo(final Novel other) { return this.id - other.id; } }
public class Novels { private final String fileName = "/WEB-INF/data/novels.db"; private ConcurrentMap<Integer, Novel> novels; private ServletContext sctx; private AtomicInteger mapKey;
public Novels() { novels = new ConcurrentHashMap<Integer, Novel>(); mapKey = new AtomicInteger(); }
public void setServletContext(ServletContext sctx) { this.sctx = sctx; } public ServletContext getServletContext() { return this.sctx; }
public ConcurrentMap<Integer, Novel> getConcurrentMap() { if (getServletContext() == null) return null; // not initialized if (novels.size() < 1) populate(); return this.novels; }
public String toXml(Object obj) { // default encoding String xml = null; try { ByteArrayOutputStream out = new ByteArrayOutputStream(); XMLEncoder encoder = new XMLEncoder(out); encoder.writeObject(obj); encoder.close(); xml = out.toString(); } catch(Exception e) { } return xml; }
public String toJson(String xml) { // option for requester try { JSONObject jobt = XML.toJSONObject(xml); return jobt.toString(3); // 3 is indentation level } catch(Exception e) { } return null; }
public int addNovel(Novel novel) { int id = mapKey.incrementAndGet(); novel.setId(id); novels.put(id, novel); return id; }
private void populate() { InputStream in = sctx.getResourceAsStream(this.fileName); // Convert novel.db string data into novels. if (in != null) { try { InputStreamReader isr = new InputStreamReader(in); BufferedReader reader = new BufferedReader(isr);
String record = null; while ((record = reader.readLine()) != null) { String[] parts = record.split("!"); if (parts.length == 2) { Novel novel = new Novel(); novel.setAuthor(parts[0]); novel.setTitle(parts[1]); addNovel(novel); // sets the Id, adds to map } } in.close(); } catch (IOException e) { } } } }
public class NovelsServlet extends HttpServlet { static final long serialVersionUID = 1L; private Novels novels; // back-end bean
// Executed when servlet is first loaded into container. @Override public void init() { this.novels = new Novels(); novels.setServletContext(this.getServletContext()); }
// GET /novels // GET /novels?id=1 @Override public void doGet(HttpServletRequest request, HttpServletResponse response) { String param = request.getParameter("id"); Integer key = (param == null) ? null : Integer.valueOf((param.trim()));
// Check user preference for XML or JSON by inspecting // the HTTP headers for the Accept key. boolean json = false; String accept = request.getHeader("accept"); if (accept != null && accept.contains("json")) json = true;
// If no query string, assume client wants the full list. if (key == null) { ConcurrentMap<Integer, Novel> map = novels.getConcurrentMap(); Object list = map.values().toArray(); Arrays.sort(list);
String payload = novels.toXml(list); // defaults to Xml if (json) payload = novels.toJson(payload); // Json preferred? sendResponse(response, payload); } // Otherwise, return the specified Novel. else { Novel novel = novels.getConcurrentMap().get(key); if (novel == null) { // no such Novel String msg = key + " does not map to a novel.\n"; sendResponse(response, novels.toXml(msg)); } else { // requested Novel found if (json) sendResponse(response, novels.toJson(novels.toXml(novel))); else sendResponse(response, novels.toXml(novel)); } } }
// POST /novels @Override public void doPost(HttpServletRequest request, HttpServletResponse response) { String author = request.getParameter("author"); String title = request.getParameter("title");
// Are the data to create a new novel present? if (author == null || title == null) throw new RuntimeException(Integer.toString(HttpServletResponse.SC_BAD_REQUEST));
// Create a novel. Novel n = new Novel(); n.setAuthor(author); n.setTitle(title);
// Save the ID of the newly created Novel. int id = novels.addNovel(n);
// Generate the confirmation message. String msg = "Novel " + id + " created.\n"; sendResponse(response, novels.toXml(msg)); }
// PUT /novels @Override public void doPut(HttpServletRequest request, HttpServletResponse response) { /\* A workaround is necessary for a PUT request because Tomcat does not generate a workable parameter map for the PUT verb. \*/ String key = null; String rest = null; boolean author = false;
/\* Let the hack begin. \*/ try { BufferedReader br = new BufferedReader(new InputStreamReader(request.getInputStream())); String data = br.readLine(); /\* To simplify the hack, assume that the PUT request has exactly two parameters: the id and either author or title. Assume, further, that the id comes first. From the client side, a hash character # separates the id and the author/title, e.g.,
id=33#title=War and Peace \*/ String[] args = data.split("#"); // id in args[0], rest in args[1] String[] parts1 = args[0].split("="); // id = parts1[1] key = parts1[1];
String[] parts2 = args[1].split("="); // parts2[0] is key if (parts2[0].contains("author")) author = true; rest = parts2[1]; } catch(Exception e) { throw new RuntimeException(Integer.toString(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)); }
// If no key, then the request is ill formed. if (key == null) throw new RuntimeException(Integer.toString(HttpServletResponse.SC_BAD_REQUEST));
// Look up the specified novel. Novel p = novels.getConcurrentMap().get(Integer.valueOf((key.trim()))); if (p == null) { // not found String msg = key + " does not map to a novel.\n"; sendResponse(response, novels.toXml(msg)); } else { // found if (rest == null) { throw new RuntimeException(Integer.toString(HttpServletResponse.SC_BAD_REQUEST)); } // Do the editing. else { if (author) p.setAuthor(rest); else p.setTitle(rest);
String msg = "Novel " + key + " has been edited.\n"; sendResponse(response, novels.toXml(msg)); } } }
// DELETE /novels?id=1 @Override public void doDelete(HttpServletRequest request, HttpServletResponse response) { String param = request.getParameter("id"); Integer key = (param == null) ? null : Integer.valueOf((param.trim())); // Only one Novel can be deleted at a time. if (key == null) throw new RuntimeException(Integer.toString(HttpServletResponse.SC_BAD_REQUEST)); try { novels.getConcurrentMap().remove(key); String msg = "Novel " + key + " removed.\n"; sendResponse(response, novels.toXml(msg)); } catch(Exception e) { throw new RuntimeException(Integer.toString(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)); } }
// Methods Not Allowed @Override public void doTrace(HttpServletRequest request, HttpServletResponse response) { throw new RuntimeException(Integer.toString(HttpServletResponse.SC_METHOD_NOT_ALLOWED)); }
@Override public void doHead(HttpServletRequest request, HttpServletResponse response) { throw new RuntimeException(Integer.toString(HttpServletResponse.SC_METHOD_NOT_ALLOWED)); }
@Override public void doOptions(HttpServletRequest request, HttpServletResponse response) { throw new RuntimeException(Integer.toString(HttpServletResponse.SC_METHOD_NOT_ALLOWED)); }
// Send the response payload (Xml or Json) to the client. private void sendResponse(HttpServletResponse response, String payload) { try { OutputStream out = response.getOutputStream(); out.write(payload.getBytes()); out.flush(); } catch(Exception e) { throw new RuntimeException(Integer.toString(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)); } } }
一些请求(特别是 POST 和 PUT)会有报文,而其他请求(特别是 GET 和 DELETE)没有。如果有报文(可能为空),以两个换行符将报头和报文分隔开;HTTP 报文包含一系列键-值对。对于无报文的请求,比如说查询字符串,报头元素就可以用来发送信息。下面是一个用 ID 2 对 /novels 资源的 GET 请求:
@Override public void doPost(HttpServletRequest request, HttpServletResponse response) { String author = request.getParameter("author"); String title = request.getParameter("title"); ...
对于没有报文的 DELETE 请求,过程基本是一样的:
1 2 3 4 5
@Override public void doDelete(HttpServletRequest request, HttpServletResponse response) { String param = request.getParameter("id"); // id of novel to be removed ...
doGet 方法需要区分 GET 请求的两种方式:一种是“获得所有”,而另一种是“获得某一个”。如果 GET 请求 URL 中包含一个键是一个 ID 的查询字符串,那么这个请求就被解析为“获得某一个”:
1 2
http://localhost:8080/novels?id=2 ## GET specified
public void doTrace(HttpServletRequest request, HttpServletResponse response) { throw new RuntimeException(Integer.toString(HttpServletResponse.SC_METHOD_NOT_ALLOWED)); }
测试“小说”服务
用浏览器测试 web 服务会很不顺手。在 CRUD 动词中,现代浏览器只能生成 POST(创建)和 GET(读取)请求。甚至从浏览器发送一个 POST 请求都有点不好办,因为报文需要包含键-值对;这样的测试通常通过 HTML 表单完成。命令行工具,比如说 curl,是一个更好的选择,这个部分展示的一些 curl 命令,已经包含在我网站的 ZIP 文件中了。