写点什么

Spring MVC 与 JAX-RS 比较与分析

  • 2010-07-27
  • 本文字数:8635 字

    阅读完需:约 28 分钟

导言

过去几年,REST 逐渐成为影响 Web 框架、Web 协议与 Web 应用设计的重要概念。如果你还不了解 REST,那这个简短的介绍将有助你快速掌握REST,此外还可以点击这里了解关于REST 的更多信息。

现在有越来越多的公司希望能以简单而又贴合Web 架构本身的方式公开Web API,因此REST 变得越来越重要也就不足为奇了。使用Ajax 进行通信的富浏览器端也在朝这个目标不断迈进。这个架构原则提升了万维网的可伸缩性,无论何种应用都能从该原则中受益无穷。

JAX-RS (JSR 311)指的是 Java API for RESTful Web Services, Roy Fielding 也参与了 JAX-RS 的制订,他在自己的博士论文中定义了REST。对于那些想要构建RESTful Web Services 的开发者来说,JAX-RS 给出了不同于JAX-WS(JSR-224)的另一种解决方案。目前共有4 种JAX-RS 实现,所有这些实现都支持Spring, Jersey 则是 JAX-RS 的参考实现,也是本文所用的实现。

如果你使用 Spring 进行开发,那可能想知道(或者有人曾问过你)Spring MVC 与 JAX-RS 有何异同点?更进一步,如果你手头有一个 Spring MVC 应用,使用了控制类继承(SimpleFormController 等),你可能还意识不到现在的 Spring MVC 对 REST 广泛的支持。

本文将介绍 Spring 3 中的 REST 特性并与 JAX-RS 进行对比,希望能帮助你理顺这两种编程模型之间的异同点。

开始前,有必要指出 JAX-RS 的目标是 Web Services 开发(这与 HTML Web 应用不同)而 Spring MVC 的目标则是 Web 应用开发。Spring 3 为 Web 应用与 Web Services 增加了广泛的 REST 支持,但本文则关注于与 Web Services 开发相关的特性。我觉得这种方式更有助于在 JAX-RS 的上下文中讨论 Spring MVC。

要说明的第二点是我们将要讨论的 REST 特性是 Spring Framework 的一部分,也是现有的 Spring MVC 编程模型的延续,因此,并没有所谓的“Spring REST framework”这种概念,有的只是 Spring 和 Spring MVC。这意味着如果你有一个 Spring 应用的话,你既可以使用 Spring MVC 创建 HTML Web 层,也可以创建 RESTful Web Services 层。

关于文中的代码片段

文中的代码片段假想了一个简单的领域模型:两个 JPA 注解实体,分别是 Account 和 Portfolio,其中一个 Account 对应多个 Portfolio。持久层使用 Spring 配置,包含了一个 JPA 仓储实现,用于获取和持久化实体实例。Jersey 和 Spring MVC 用于构建 Web Services 层,通过调用底层的 Spring 托管应用来服务客户端请求。

引导程序与 Web 层包装

我们会在 Spring MVC 和 JAX-RS 中都使用 Spring 实现依赖注入。Spring MVC DispatcherServlet 和 Jersey SpringServlet 会把请求代理给 Spring 管理的 REST 层组件(控制器或资源),后者会由业务或持久层组件包装起来,如下图所示:

Jersey 和 Spring MVC 都使用 Spring 的 ContextLoaderListener 加载业务与持久层组件,比如 JpaAccountRepository:

复制代码
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>
classpath:META-INF/spring/module-config.xml
</param-value>
</context-param>
<listener>
<listener-class>
org.springframework.web.context.ContextLoaderListener
</listener-class>
</listener>

ContextLoaderListener 可用于任何 Web 或 REST 框架环境中。

在 Jersey 中创建 Spring 管理的 JAX-RS 资源

Jersey 支持在 REST 层中使用 Spring,两个简单的步骤就能搞定(事实上有 3 步,还需要将构建依赖加到 maven artifact com.sun.jersey.contribs:jersey-spring 中)。

步骤一:将如下配置片段加到 web.xml 中以保证 Spring 能够创建 JAX-RS 根资源:

复制代码
<servlet>
<servlet-name>Jersey Web Application</servlet-name>
<servlet-class>
com.sun.jersey.spi.spring.container.servlet.SpringServlet
</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>Jersey Web Application</servlet-name>
<url-pattern>/resources/*</url-pattern>
</servlet-mapping>

步骤二:使用 Spring 和 JAX-RS 注解声明根 JAX-RS 资源类:

复制代码
@Path("/accounts/")
@Component
@Scope("prototype")
public class AccountResource {
@Context
UriInfo uriInfo;
@Autowired
private AccountRepository accountRepository;
}

如下是对这些注解的说明:

@Component 将 AccountResource 声明为 Spring bean。

@Scope 声明了一个 prototype Spring bean,这样每次使用时都会实例化(比如每次请求时)。

@Autowired 指定了一个 AccountRepository 引用,Spring 会提供该引用。

@Path 是个 JAX-RS 注解,它将 AccountResource 声明为“根”JAX-RS 资源。

@Context 也是一个 JAX-RS 注解,要求注入特定于请求的 UriInfo 对象。

JAX-RS 有“根”资源(标记为 @Path)和子资源的概念。在上面的示例中,AccountResource 就是个根资源,它会处理以“/accounts/”开头的路径。AccountResource 中的方法如 getAccount() 只需声明针对类型级别的相对路径即可。

复制代码
@Path("/accounts/")
@Component
@Scope("prototype")
public class AccountResource {
@GET
@Path("{username}")
public Account getAccount(@PathParam("username") String username) {
}
}

访问路径“/accounts/{username}”(其中的 username 是路径参数,可以是某个账户的用户名)的请求将由 getAccount() 方法处理。

根资源由 JAX-RS 运行时(在本示例中是 Spring)实例化,子资源则由应用本身实例化。比如说,对于“/accounts/{username}/portfolios/{portfolioName}”这样的请求,AccountResource(由路径的第一部分“/accounts”标识)会创建一个子资源实例,请求会被代理给该实例:

复制代码
@Path("/accounts/")
@Component
@Scope("prototype")
public class AccountResource {
@Path("{username}/portfolios/")
public PortfolioResource getPortfolioResource(@PathParam("username") String username) {
return new PortfolioResource(accountRepository, username, uriInfo);
}
}

PortfolioResource 本身的声明并没有使用注解,因此其所有的依赖都是由父资源传递过来的:

复制代码
public class PortfolioResource {
private AccountRepository accountRepository;
private String username;
private UriInfo uriInfo;
public PortfolioResource(AccountRepository accountRepository, String username, UriInfo uriInfo) {
this.accountRepository = accountRepository;
this.username = username;
this.uriInfo = uriInfo;
}
}

JAX-RS 中的根与子资源创建了一个处理链,它会调用多个资源:

请记住,资源类是 Web Services 层组件,应当关注于 Web Services 相关的处理,比如输入转换、准备响应、设定响应代码等等。此外,将 Web Services 逻辑与业务逻辑分隔开来的实践需要将业务逻辑包装到单独的方法中以作为事务边界。

创建 Spring MVC @Controller 类

对于 Spring MVC 来说,我们需要创建 DispatcherServlet,同时将 contextConfigLocation 参数指定为 Spring MVC 配置:

复制代码
<servlet>
<servlet-name>Spring MVC Dispatcher Servlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>
/WEB-INF/spring/*.xml
</param-value>
</init-param>
</servlet>

要想在 Spring MVC(@MVC)中使用基于注解的编程模型还需要少量的配置。下面的 component-scan 元素会告诉 Spring 去哪里寻找 @Controller 注解类。

复制代码
<context:component-scan base-package="org.springframework.samples.stocks" />

接下来,我们声明了 AccountController,如下代码所示:

复制代码
@Controller
@RequestMapping("/accounts")
public class AccountController {
@Autowired
private AccountRepository accountRepository;
}

@RequestMapping 注解会将该控制器映射到所有以“/accounts”开头的请求上。AccountController 中的方法如 getAccount() 只需声明针对“/accounts”的相对地址即可。

复制代码
@RequestMapping(value = "/{username}", method = GET)
public Account getAccount(@PathVariable String username) {
}

Spring MVC 则没有根资源与子资源的概念,这样每个控制器都是由 Spring 而非应用来管理的:

复制代码
@Controller
@RequestMapping("/accounts/{username}/portfolios")
public class PortfolioController {
@Autowired
private AccountRepository accountRepository;
}

对“/accounts/{username}/portfolios”的请求会被直接代理给 PortfolioController,AccountController 则完全不会参与其中。需要注意的是,该请求也可以直接由 AccountController 处理,这样就不需要 PortfolioController 了。

Web 层组件范围

在 JAX-RS 中,AccountResource 是通过前请求(per-request)语义声明的,这也是 JAX-RS 默认的推荐设置。这么做可以将特定于请求的数据注入并存储到资源类本身当中,这适用于由 JAX-RS 所管理的根级别资源。子资源由应用实例化,并不会直接从这种方法中获益。

在 Spring MVC 中,控制器永远都是单例的,他们将特定于请求的数据作为方法参数。JAX-RS 也可以这么做,以单例的方式创建资源。

将请求映射到方法上

接下来,我们看看 Spring MVC 和 JAX-RS 如何将请求映射到方法上。@Path 和 @RequestMapping 都可以从 URL 中抽取出路径变量:

复制代码
@Path("/accounts/{username}")
@RequestMapping("/accounts/{username}")

这两个框架也都可以使用正则表达式抽取路径变量:

复制代码
@Path("/accounts/{username:.*}")
@RequestMapping("/accounts/{username:.*}"

Spring MVC 的 @RequestMapping 可以根据查询参数的有无来匹配请求:

复制代码
@RequestMapping(parameters="foo")
@RequestMapping(parameters="!foo")

或是根据查询参数值进行匹配:

复制代码
@RequestMapping(parameters="foo=123")

@RequestMapping 还可以根据头信息的有无来匹配请求:

复制代码
@RequestMapping(headers="Foo-Header")
@RequestMapping(headers="!Foo-Header")

或是根据头信息的值进行匹配:

复制代码
@RequestMapping(headers="content-type=text/*")

处理请求数据

HTTP 请求中包含着应用需要提取和处理的数据,如 HTTP 头、cookie、查询字符串参数、表单参数以及请求体(XML、JSON 等)中所包含的大量数据。在 RESTful 应用中,URL 本身也可以带有重要的信息,如通过路径参数指定需要访问哪个资源、通过文件扩展名(.html, .pdf)指定需要何种内容类型等。HttpServletRequest 提供了处理这一切的所有底层访问机制,但直接使用 HttpServletRequest 实在是太乏味了。

请求参数、Cookies 和 HTTP 头

Spring MVC 和 JAX-RS 拥有能够抽取这种 HTTP 请求值的注解:

复制代码
@GET @Path
public void foo(@QueryParam("q") String q, @FormParam("f") String f, @CookieParam("c") String c,
@HeaderParam("h") String h, @MatrixParam("m") m) {
// JAX-RS
}
@RequestMapping(method=GET)
public void foo(@RequestParam("q") String q, @CookieValue("c") String c, @RequestHeader("h") String h) {
// Spring MVC
}

上面的注解非常像,区别在于 JAX-RS 支持矩阵参数(matrix parameters)的抽取,拥有单独的注解来处理查询字符串和表单参数。矩阵参数并不常见,他们类似于查询字符串参数,但却使用了特殊的路径片段(比如 GET /images;name=foo;type=gif)。稍后将介绍表单参数。

假如使用了前请求范围声明资源,那么 JAX-RS 可以在属性和 setters 方法上使用上述注解。

Spring MVC 有个特性能让我们少敲几个字符,如果注解名与 Java 参数名相同,那么就可以省略掉上面的注解名了。比如说,名为“q”的请求参数要求方法参数也得为“q”:

复制代码
public void foo(@RequestParam String q, @CookieValue c, @RequestHeader h) {
}

这对于那些在参数中使用了注解而导致方法签名变长的情况来说实在是太方便了。请记住,这个特性要求代码使用调试符号进行编译。

类型转换与 HTTP 请求值的格式化

HTTP 请求值(头、cookies 和参数)是不变的字符串并且需要解析。

JAX-RS 通过寻找 valueOf() 方法或是在客户化的目标类型中接收字符串的构造方法来解析请求数据。JAX-RS 支持如下类型的注解方法参数,包括路径变量、请求参数、HTTP 头值和 cookies:

  • 原生类型。
  • 拥有接收单个字符串参数的构造方法的类型。
  • 拥有一个接收单个字符串参数的名为 valueOf 的静态方法的类型。
  • List、Set或是 SortedSet,其中的 T 满足上面 2 个或 3 个要求。

Spring 3 支持上面所有要求。除此之外,Spring 3 提供了一种全新的类型转换与格式化机制,并且可以使用注解实现。

表单数据

如前所述,JAX-RS 处理查询字符串参数和表单参数的方式是不同的。虽然 Spring MVC 只有一个 @RequestParam,但它还提供了一种 Spring MVC 用户很熟悉的数据绑定机制来处理表单输入。

比如说,如果一个表单提交了 3 个数据,那么一种可能的处理方式就是声明一个带有 3 个参数的方法:

复制代码
@RequestMapping(method=POST)
public void foo(@RequestParam String name, @RequestParam creditCardNumber, @RequestParam expirationDate) {
Credit card = new CreditCard();
card.setName(name);
card.setCreditCardNumber(creditCardNumber);
card.setExpirationDate(expirationDate);
{1}
}

然而,随着表单数据量的增加,这种处理方式就会变得不切实际。借助于数据绑定,Spring MVC 可以创建、组装并传递包含有嵌套数据(账单地址、邮件地址等)、任意结构的表单对象。

复制代码
@RequestMapping(method=POST)
public void foo(CreditCard creditCard) {
// POST /creditcard/1
// name=Bond
// creditCardNumber=1234123412341234
// expiration=12-12-2012
}

要想与 Web 浏览器协同工作,表单处理是个重要环节。另一方面,Web Services 客户端一般会在请求体中提交 XML 或 JSON 格式的数据。

处理请求体中的数据

无论是 Spring MVC 还是 JAX-RS 都能够自动处理请求体中的数据:

复制代码
@POST
public Response createAccount(Account account) {
// JAX_RS
}
@RequestMapping(method=POST)
public void createAccount(@RequestBody Account account) {
// Spring MVC
}

JAX-RS 中的请求体数据

在 JAX-RS 中,类型 MessageBodyReader 的实体供应者负责转换请求体数据。JAX-RS 的实现需要拥有一个 JAXB MessageBodyReader,这可以使用具有注解 @Provider 的客户化 MessageBodyReader 实现。

Spring MVC 中的请求体数据

在 Spring MVC 中,如果想通过请求体数据初始化方法参数,那可以将 @RequestBody 注解加到该方法参数前,这与之前介绍的表单参数初始化正好相反。

在 Spring MVC 中,HttpMessageConverter 类负责转换请求体数据,Spring MVC 提供了一个开箱即用的 Spring OXM HttpMessageConverter。它支持 JAXB、Castor、JiBX、XMLBeans 和 XStream,此外还有一个用于处理JSON Jackson HttpMessageConverter。

HttpMessageConverter 会注册到 AnnotationMethodHandlerAdapter 上,后者会将到来的请求映射到 Spring MVC @Controllers 上。下面是其配置:

复制代码
<bean class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter" >
<property name="messageConverters" ref="marshallingConverter"/>
</bean>
<bean id="marshallingConverter" class="org.springframework.http.converter.xml.MarshallingHttpMessageConverter">
<constructor-arg ref="jaxb2Marshaller"/>
<property name="supportedMediaTypes" value="application/vnd.stocks+xml"/>
</bean>
<oxm:jaxb2-marshaller id="jaxb2Marshaller"/>

下图阐述了该配置:

Spring 3 新增的 mvc 客户化命名空间将上述配置自动化了,只需增加如下配置片段即可:

复制代码
<mvc:annotation-driven />

如果 JAXB 位于类路径上,它会注册一个用于读写 XML 的转换器;如果 Jackson 位于类路径上,它会注册一个用于读写 JSON 的转换器。

准备响应

典型的响应需要准备响应代码、设定 HTTP 响应头、将数据放到响应体当中,还需要处理异常。

使用 JAX-RS 设定响应体数据

在 JAX-RS 中,要想将数据加到响应体中,只需要从资源方法中返回对象即可:

复制代码
@GET
@Path("{username}")
public Account getAccount(@PathParam("username") String username) {
return accountRepository.findAccountByUsername(username);
}

JAX-RS 会寻找类型 MessageBodyWriter 的实体供应者,它能将对象转换为所需的内容类型。JAX-RS 实现需要具备一个 JAXB MessageBodyWriter,这可以使用具有注解 @Provider 的客户化 MessageBodyWriter 实现。

使用 Spring MVC 设定响应体数据

在 Spring MVC 中,响应是通过一个视图解析过程来实现的,这样就可以从一系列视图技术中选择了。但在与 Web Services 客户端交互时,更加合理的方式则是舍弃视图解析过程,转而使用方法所返回的对象:

复制代码
@RequestMapping(value="/{username}", method=GET)
public @ResponseBody Account getAccount(@PathVariable String username) {
return accountRepository.findAccountByUsername(username);
}

如果对控制器方法或其返回类型应用注解 @ResponseBody,那么就会使用 HttpMessageConverter 处理返回值,然后用该返回值设定响应体。用于请求体参数的 HttpMessageConverter 集合也用于响应体,因此无需再做任何配置。

状态代码与响应头

JAX-RS 使用一个链式 API 来构建响应:

复制代码
@PUT @Path("{username}")
public Response updateAccount(Account account) {
// ...
return Response.noContent().build(); // 204 (No Content)
}

这可以与 UriBuilder 联合使用来为 Location 响应头创建实体链接:

复制代码
@POST
public Response createAccount(Account account) {
// ...
URI accountLocation = uriInfo.getAbsolutePathBuilder().path(account.getUsername()).build();
return Response.created(accountLocation).build();
}

上面代码中所用的 uriInfo 要么被注入到根资源(使用了 @Context)中,要么是从父资源传递给子资源。它可以附加到当前请求的路径之后。

Spring MVC 提供了一个注解来设定响应代码:

复制代码
@RequestMapping(method=PUT)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateAccount(@RequestBody Account account) {
// ...
}

可以直接使用 HttpServletResponse 对象设定 Location 头:

复制代码
@RequestMapping(method=POST)
@ResponseStatus(CREATED)
public void createAccount(@RequestBody Account account, HttpServletRequest request,
HttpServletResponse response) {
// ...
String requestUrl = request.getRequestURL().toString();
URI uri = new UriTemplate("{requestUrl}/{username}").expand(requestUrl, account.getUsername());
response.setHeader("Location", uri.toASCIIString());
}

异常处理

JAX-RS 允许资源方法抛出 WebApplicationException 类型的异常,该异常会包含一个响应。下面的示例代码将一个 JPA NoResultException 转换为特定于 Jersey 的 NotFoundException,这会导致一个 404 的错误:

复制代码
@GET
@Path("{username}")
public Account getAccount(@PathParam("username") String username) {
try {
return accountRepository.findAccountByUsername(username);
} catch (NoResultException e) {
throw new NotFoundException();
}
}

WebApplicationException 实例会封装必要的逻辑来生成特定的响应,但每个独立的资源类方法中都需要捕获异常。

Spring MVC 支持定义控制器级别的方法来处理异常:

复制代码
@Controller
@RequestMapping("/accounts")
public class AccountController {
@ResponseStatus(NOT_FOUND)
@ExceptionHandler({NoResultException.class})
public void handle() {
// ...
}
}

如果任何控制器方法抛出了 JPA 的 NoResultException 异常,上面的处理器方法就会得到调用并处理该异常,然后返回一个 404 错误。这样,每个控制器就都能处理异常了,好象来自同一个地方一样。

总结

希望本文有助于你理解 Spring MVC 构建 RESTful Web Services 的方式及其与 JAX-RS 编程模型之间的异同点。

如果你是个 Spring MVC 用户,那么你可能用它开发过 HTML Web 应用了。REST 概念适用于 Web Services 和 Web 应用,尤其是富客户端交互上更是如此。除了本文介绍的特性之外,Spring 3 还增加了对 RESTful Web 应用的支持。这是部分新特性的列表:用于从 URL 模板构建 URL 的新的 JSP 客户化标签、基于 HTTP PUT 和 DELETE 模拟表单提交的 Servlet 过滤器、根据内容类型自动选择视图的 ContentTypeNegotiatingViewResolver、新的视图实现等等。此外,Spring 文档也改进颇多。

关于作者

Rossen Stoyanchev 是 SpringSource 的高级咨询师。在其职业生涯中,他做过贸易应用、记账系统和电子商务等 Web 应用。在 SpringSource 中,Rossen 专注于 Web 技术,包括咨询、培训和“Rich-Web Development With Spring”课程的内容开发,该课程旨在帮助受训者成为认证的 Spring Web 应用开发者。

查看英文原文: A Comparison of Spring MVC and JAX-RS


给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2010-07-27 00:0620382
用户头像

发布了 88 篇内容, 共 262.6 次阅读, 收获喜欢 8 次。

关注

评论

发布
暂无评论
发现更多内容

Lock-It for Mac(应用程序加密工具) 1.2.0激活版

iMac小白

如何使用香山之香山工具详解:difftest应用及配合波形检验

源芯

芯片设计 risc-v 开源芯片 高性能处理器香山

Cycling 74 Max for Mac(音乐可视化编程软件) v8.6.1激活版

iMac小白

淘宝1688京东...商品详情API接口(属性,详情图,价格,sku评价等接口)可高并发批量采集

Anzexi58

API 文档

开发者分享:利用 EMQX Cloud 与 ESP32 微控制器实现智能液冷散热系统

EMQ映云科技

mqtt mqtt broker

Starknet(strk) 跨链桥教程:手把手教你用bitget钱包跨链

威廉META

Menuwhere for Mac(菜单栏工具) v2.2.2免激活版

iMac小白

Sermant运行流程学习笔记,速来抄作业

华为云开发者联盟

开发 华为云 华为云开发者联盟 sermant

广州孚瑞经营改善 企业经营持续增长方案提供商

广东孚瑞经营改善

精益生产

释放全球互连的数字潜力!MWC 2024云网高峰论坛召开,中国电信天翼云扬帆起航!

天翼云开发者社区

云计算 峰会 世界移动通信大会

金融信创湖仓一体数据平台架构实践

数新网络官方账号

金融 信创 数据平台 湖仓一体

IT外包提升企业灵活性与敏捷性

Ogcloud

IT IT外包 IT外包公司 IT外包服务

如何做代币分析:以 LDO 币为例

Footprint Analytics

blockchain Token LDO

从《繁花》看图数据库的关联力!

博文视点Broadview

向“新”而行,以科技创新培育新质生产力!

天翼云开发者社区

人工智能 云计算 算力

小程序技术实践:如何快速开发适配鸿蒙的App

Geek_2305a8

前端的你常用的编程语言有哪些?

小齐写代码

NineData云原生智能数据管理平台新功能发布|2024年2月版

NineData

MySQL 数据复制 tdsql NineData GaiaDB

LLM 推理优化探微 (2) :Transformer 模型 KV 缓存技术详解

Baihai IDP

程序员 AI LLM 白海科技 KV缓存

SD-WAN: 灵活部署,助力云服务

Ogcloud

SD-WAN 企业网络 SD-WAN组网 SD-WAN服务商 SDWAN

淘宝天猫详情接口API:快速实现商品信息查询和展示

联讯数据

Tower for Mac(强大的Git客户端) v10.5注册激活版

iMac小白

钉钉如何通过AppLink快速连接仓储系统

RestCloud

钉钉 APPlink 自动化集成 仓储系统

Vocabulary

EchoZhou

数字先锋| 上云!让“媒”好“发声”

天翼云开发者社区

云计算 网络 上云

如何做代币分析:以 USDC 币为例

Footprint Analytics

blockchain Token

Databend Labs 成立 3 周年!

Databend

好物期刊#1:我每天都使用的在线工具

JavaPub

程序员 计算机 在线工具

利用API接口进行竞品价格监控的综合指南

Noah

如何基于容器网络流量指标进行弹性伸缩

华为云开发者联盟

容器 开发 华为云 华为云开发者联盟

软件测试学习笔记丨WebSocket原理&使用

测试人

软件测试 测试开发

Spring MVC与JAX-RS比较与分析_Java_Rossen Stoyanchev_InfoQ精选文章