2.1 第一个Java REST服务

本节讲述基于Java SE环境的Jersey官方文档中提供的示例simple-service(参考地址:https://jersey.java.net/documentation/latest/user-guide.html),并在此基础上扩展自定义的REST资源服务。

2.1.1 环境准备

在动手之前,我们需要准备开发REST服务的环境,包括JDK、Maven和IDE。

阅读指南

2.1节示例所在目录是jax-rs2-guide\sample\2\0simple-service。

源代码地址为:https://github.com/feuyeux/jax-rs2-guide/tree/master/sample/2/0simple-service。

1.配置JDK

Jersey对JDK的版本要求是1.6及以上,读者可根据项目情况,参考1.4节的Jersey 2依赖和GlassFish项目中的讲述,选择JDK 1.6以后的版本来开发基于Jersey的REST项目。本章示例使用的操作系统是Windows 7的64位版本,JDK版本是jdk-7u25-windows-x64.exe。下载并安装好JDK,然后修改系统环境变量:添加JAVA_HOME参数,并将其bin目录追加到path中。示例如下。

JAVA_HOME=D:\Program Files\Java\jdk1.7.0_25
Path=%JAVA_HOME%\bin;……

设置完毕后,Windows操作系统需要重启控制台使Java生效;在Linux下,需要使用命令source path2profile启用Java。

2.配置Maven

Maven是Apache的项目,是当今流行的项目构建工具。读者可以通过其官网(http://maven.apache.org)了解更多信息。需要注意的是,Maven版本中的3.x相比2.x有性能上的优势,推荐使用Maven 3.x。Maven 3.0.x和Maven 3.1.x的区别在于其内部实现,作为用户使用而言,笔者没有推荐倾向。对于工具版本的选择,并不推荐使用最新版,除非新版本更加稳定可靠。比如3.0.5这个版本是Maven 3.0.x的修复和维护版本,这意味着该版本较之前面的版本更趋稳定。作为演示,本例选择的是最新版本maven-3.1.0。下载并解压Maven,然后在系统环境变量中定义M2_HOME并指向Maven解压后的路径,在Path中添加M2_HOME下的bin目录,或定义M2变量为M2_HOME下的bin目录,然后将其添加到Path中,如下所示。

M2_HOME=D:\-aquarius\apache-maven-3.1.0
MAVEN_OPTS=-Xms128m-Xmx512m
Path=%M2_HOME%\bin;……

MAVEN_OPTS用于定义Maven运行时JVM虚拟机参数,通常至少需要定义JVM堆的最大值-Xmx以支持构建较大的项目。Maven的版本测试命令是mvn-v,其测试结果如下所示。

mvn-v
Apache Maven 3.1.0(893ca28a1da9d5f51ac03827af98bb730128f9f2; 2013-06-28 10:15:32+0800)
Maven home: D:\-aquarius\apache-maven-3.1.0
Java version: 1.7.0_25, vendor: Oracle Corporation
Java home: D:\Program Files\Java\jdk1.7.0_25\jre
Default locale: zh_CN, platform encoding: GBK
OS name: "windows 7", version: "6.1", arch: "amd64", family: "windows"
3.使用IDE

也许读者希望就某种IDE讲述示例,由于本书的全部示例都是基于Maven的Java项目,当今的主流IDE对集成Maven的支持都非常好,因此本书在讲述技术细节时,将不特别针对某种IDE的使用有倾向性的推荐和描述。

本书尽可能确保所提供的全部示例的源代码在以下IDE中编译、运行和测试无误。Eclipse Indigo(3.7.2)IDE中的服务器还是要自行配置,否则纯属环境配置问题,而非示例代码问题。下面是常见的IDE列表。

□Eclipse Juno(4.3)。

□IntelliJ IDEA 12.1.6+。

□NetBeans IDE 7.3.1。

读者可以根据个人使用偏好选择IDE。Jersey的开发和Web开发相比,形式上基本一致,所以不必更换IDE来运行本书示例。

2.1.2 创建服务

准备好环境,我们就可以动手操作了。我们从Maven原型开始创建,然后测试并分析该示例。

1.从Maven原型创建项目

Jersey官方文档中提供的例子simple-service是一个Maven原型项目,我们从这里开始。所谓“原型项目”即指通过Maven命令即可从Maven中央仓库取回一个已经具备基本功能、依赖包完好、编译和运行测试无误的示例项目。mvn archetype:generate字面解读为:以指定的原型为模板,生成或者创建新的Maven项目。在控制台执行如下命令来生成我们想要的simple-service项目,项目的目标存储路径由读者自行选择。

mvn archetype:generate-DarchetypeArtifactId=jersey-quickstart-grizzly2-DarchetypeGroupId=org.glassfish.jersey.archetypes-DinteractiveMode=false-DgroupId=com.example-DartifactId=simple-service-Dpackage=com.example-DarchetypeVersion=2.9

控制台命令成功执行后,会在当前目录下创建simple-service目录。该目录包含了最简REST示例,即simple-service项目的全部源代码。

阅读指南

如果读者在确保本地配置无误的情况下,执行上述命令失败,说明该官方例子项目已经失效或者由于非技术原因导致的无法访问,读者可以直接使用本书提供的源代码。Maven管理的项目版本清晰,一般活跃的项目的失效的可能性比较低。

2.测试项目可用性

simple-service项目在本地创建成功后,首先要确定该项目在本机的可用性。进入simple-service项目的根目录,然后执行Maven的测试命令,若simple-service项目在环境中已经可用,则控制台将输出如下信息。

simple-service>mvn clean test
[INFO]------------------------------------------------------------------------
[INFO] Building simple-service 1.0-SNAPSHOT
[INFO]------------------------------------------------------------------------
……
-------------------------------------------------------
TESTS
-------------------------------------------------------
……
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO]------------------------------------------------------------------------
[INFO] Total time: 4.791s
[INFO] Finished at: Sat Jul 27 12:10:43 CST 2013
[INFO] Final Memory: 13M/224M

该项目默认使用JDK 1.6编译,如果用户的JDK版本不同,在执行测试之前,首先要修改Maven的配置文件pom.xml,设置编译插件的JDK版本参数。pom.xml文件位于项目根目录下。使用读者熟悉的编辑器打开pom文件,下面以文本编辑器为例:

notepad D:\simple-service\pom.xml

搜索关键字maven-compiler-plugin,定位<source>和<target>两行。以JDK 1.7为例,修改为<source>1.7</source>和<target>1.7</target>,保存并退出。最后执行Maven命令“mvn clean test”进行测试。

3.项目分析

项目测试通过意味着本地环境没有问题了,接下来可以开始学习simple-service项目了,为下一步扩展项目做好准备。

在simple-service项目的根目录执行“tree/f”命令,可以纵览simple-service的文件结构,如图2-1所示。

图2-1 simple-service项目源代码组织结构

在图2-1所示的目录结构中,对开发者有价值的内容包括源代码Main.java、MyResource.java和测试代码MyResourceTest.java。

首先,定位该项目中的有用信息以去除项目中的噪声。什么是噪声呢?无论使用哪一种IDE做开发,其自身都会产生具有该IDE方言的项目文件。除非特殊情况,我们不必理会这些项目文件,如果是团队开发,在提交到版本控制库的时候,应该对其过滤(使用GIT做SCM时,过滤文件位于项目根目录,名称为.gitignore)。target目录是Maven的生成目录,类似构建工具Ant、Gradle的build或者bin目录,我们也可以忽略。

小白讲堂

Eclipse产生的项目文件包括.settings目录,以及.classpath和.project文件。

IntelliJ产生的项目文件包括.idea目录,以及项目同名的.iml文件。

NetBeans产生的项目文件包括nb-configuration.xml文件。

(1)资源类分析

套用Web开发中典型的三层逻辑,资源类位于逻辑分层的最高层——API层,其下为Service层和数据访问层,如图2-2所示。在三层逻辑中,API层用于对外公布接口,对于REST应用,API层的资源类用于对外公布REST服务接口。其下的两层,REST应用的开发和标准Web开发的区别不是很大。

图2-2 逻辑分层

本例中的API层资源类是MyResource,代码示例如下。

package com.example;
...
//关注点1:资源路径
@Path("myresource")
public class MyResource {
// 关注点2:资源方法
  @GET
  @Produces(MediaType.TEXT_PLAIN)
  public String getIt() {
      return "Got it!";
  }
}

在这段代码中,资源类MyResource使用了JAX-RS标准中定义的@Path注解来声明名为myresource的资源路径,见关注点1。该类只有一个处理HTTP协议的GET方法的getIt()方法,没有输入参数,输出是一个String类型,传输格式是字符串类型(MediaType.TEXT_PLAIN),返回字符串值是“Got it!”,见关注点2。

在没有测试、运行这个例子之前,我们可以推测通过REST访问getIt()方法的资源路径如下,接下来的讲述将验证该资源路径。资源路径即是对外公布的REST服务接口。

HTTP服务器路径/REST服务名称/myresource/

(2)入口类分析

因为这是一个Java SE的应用,所以需要一个入口类来启动服务器并加载项目资源。对于Java EE的应用则无须定义这样的入口类,因为Java EE容器本身扮演着入口类的角色。Main类是simple-service项目的主类,即入口类,代码示例如下。

package com.example;
...
public class Main {
// 关注点1:服务器路径
    public static final String BASE_URI = "http://localhost:8080/myapp/";
    public static HttpServer startServer() {
// 关注点2:加载资源
        final ResourceConfig rc = new ResourceConfig().packages("com.example");
// 关注点3:Grizzly HTTP 服务器
        return GrizzlyHttpServerFactory.createHttpServer(URI.create(BASE_URI), rc);
    }
    public static void main(String[] args) throws IOException {
        final HttpServer server = startServer();
        System.out.println(String.format("Jersey app started with WADL available at "
               + "%sapplication.wadl\nHit enter to stop it...", BASE_URI));
        System.in.read();
        server.shutdownNow();
    }
}

Main类定义了HTTP服务器的路径(BASE_URI),即http://localhost:8080/myapp/,见关注点1。在其构造中映射了源代码中资源所在的包名new ResourceConfig().packages(“com.example”),这意味着,服务器启动时会自动扫描该包下的所有类,根据该包中所含类的REST资源路径的注解,在内存中做好映射,见关注点2。这样一来,客户端请求指定路径后,服务器就可以根据映射,分派请求给相应的资源类实例的相应方法了。这就是Jersey中的IoC机制。这个例子不是运行在Servlet容器中的,相反,它是一个Java SE应用。该服务自带了HTTP服务器的实现,本例使用的服务器是Grizzly,见关注点3。

宅人坑事

在1.4节中,我们对Grizzly有过介绍,这里额外要说的是,Grizzly是Jersey提供的集成测试中默认的内嵌测试服务器,有了Grizzly,我们就可以在不启动额外Servlet容器服务器的情况下,测试REST服务。这点对于Maven老用户来说,一定会联想到在运行Servlet容器测试时使用Jetty插件的情形。略有不同的是,Jetty的角色之于Maven是一个声明式的(配置参数)、无须编码的插件,而Grizzly是编码式的。

(3)测试类分析

最后我们来看看真正用于单元测试的测试类MyResourceTest。它只有一个测试方法testGetIt()用来测试MyResource类公布的资源路径myresource。其代码示例如下。

package com.example;
...
public class MyResourceTest {
// 关注点1:全局字段
    private HttpServer server;
    private WebTarget target;
// 关注点2:准备测试环境
    @Before
    public void setUp() throws Exception {
        server = Main.startServer();
        Client c = ClientBuilder.newClient();
        target = c.target(Main.BASE_URI);
    }
// 关注点3:释放测试环境
    @After
    public void tearDown() throws Exception {
        server.shutdownNow();
    }
// 关注点4:测试GET 方法
    @Test
    public void testGetIt() {
        String responseMsg = target.path("myresource").request().get(String.class);
        assertEquals("Got it!", responseMsg);
    }
}

MyResourceTest定义了两个全局字段,分别是Grizzly服务器类HttpServer和JAX-RS 2.0的客户端资源定位类WebTarget,见关注点1。

注解为@Before的setUp()方法是JUnit测试前要执行的方法,为测试准备环境。首先启动Grizzly服务器,然后实例化一个Client,最后将服务器的资源地址作为参数,传给客户端示例,获取到客户端资源定位类实例target,见关注点2。

testGetIt()方法用于测试GET方法,使用资源定位类WebTarget实例target向myresource资源发出get请求,将返回值作为相等断言的输入参数,和“Got it!”进行比对,见关注点4。

注解为@After的方法tearDown()在测试结束后执行,以释放测试环境中的资源,如停止服务器实例、释放资源等,见关注点3。这里需要注意的是,释放资源过程无须处理客户端实例,该实例的连接已经在获取响应实体时断开,详见后文的讲述。

到此,我们已经掌握了simple-service项目。在入门学习中,模仿是以后做到收放自如的第一步。接下来,我们重用Main作为环境支撑,模仿MyResource写一个自定义的资源类,并进行扩展性的尝试:引入实体类、逻辑分层类,支持更多的传输类型。还记得本章开始时关于设计一个更新设备的API的疑问吗?继续轻松的学习之旅吧。

2.1.3 扩展服务

在Maven原型示例的基础上进行模仿来扩展该项目的REST服务接口是本节的第二个任务,通过这一过程实现对Jersey使用的初步认识。下面我们要完成的任务是实现一个更新设备的API,这将包括开发设备实体类、资源类和逻辑分层类,然后对其进行测试,以检验我们的成果。

1.增加设备实体类

资源类和逻辑分层类在图2-2中已经展示,实体类是资源自身的信息,用于序列化和持久化资源。设备的实体类用于传输和持久化设备资源,设备的实体类命名为Device,该类是一个最基本的POJO类,包含两个属性,分别是设备的IP和设备的状态。其代码示例如下。

// 关注点1:JAXB 根元素
@XmlRootElement(name = "device")
public class Device {
  private String deviceIp;
  private int deviceStatus;
  public Device() {
  }
  public Device(String deviceIp) {
          super();
          this.deviceIp = deviceIp;
  }
// 关注点2:JAXB 属性 @XmlAttribute
  public String getIp() {
          return deviceIp;
  }
  public void setIp(String deviceIp) {
          this.deviceIp = deviceIp;
  }
  @XmlAttribute
  public int getStatus() {
          return deviceStatus;
  }
  public void setStatus(int deviceStatus) {
          this.deviceStatus = deviceStatus;
  }
}

该类标注了JAXB标准定义的@XmlRootElement和@XmlAttribute注解,以便将Device类和XML格式的设备数据相互转换并在服务器和客户端之间传输,见关注点1和关注点2。

小白讲堂

Jersey内部使用JAXB处理Java类(POJO)和XML格式的信息、JSON格式的信息映射,JAXB通过POJO中定义的XML注解(比如@XmlRootElement代表根节点,@XmlAttribute代表一个节点的属性等)将其与XML格式的信息对应起来。

2.增加设备资源类

创建了设备实体类后,我们需要一个资源类来公布设备的REST API。源代码中已经提供了资源类MyResource,为实现设备管理,我们模仿该类,创建设备资源类DeviceResource,代码示例如下。

@Path("device")
public class DeviceResource {
// 关注点1:注入Dao实例
    private final DeviceDao deviceDao;
    public DeviceResource() {
        deviceDao = new DeviceDao();
    }
// 关注点2:GET方法
    @GET
    @Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
    public Device get(@QueryParam("ip") final String deviceIp) {
    Device result = null;
        if(deviceIp != null) {
            result = deviceDao.getDevice(deviceIp);
        }
        return result;
    }
// 关注点3:PUT方法
    @PUT
    @Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
    public Device put(final Device device) {
        Device result = null;
        if(device != null) {
            result = deviceDao.updateDevice(device);
        }
        return result;
    }
}

在这段代码中,关注点2中的GET方法和2.1.2节定义的GET方法作用相同,用于获取设备信息,输入参数deviceIp是以设备IP作为查询条件,输出是设备实体类的实例,其表述格式可以是JSON或者XML。关注点3是DeviceResource类处理HTTP协议的PUT方法。put()方法定义了两个注解,@PUT是标识处理PUT请求,@Produces是标识返回实体的类型,本例中同时支持JSON格式和XML格式。细心的读者会质疑持久层Dao类DeviceDao的实例化是在资源类中完成的,见关注点1,可能会发现这不符合三层设计,也不符合IoC。我们会在后续示例中逐渐做到更加严谨,这只是第一个入门例子,放轻松。

3.增加设备逻辑类

本例演示了简单的分层结构,数据的持久化由DeviceDao类完成,该类简单地模拟了设备持久化。其示例代码如下。

public class DeviceDao {
    ConcurrentHashMap<String, Device> fakeDB = new ConcurrentHashMap<>();
    public DeviceDao() {
    // 关注点1:测试数据,初始化了两个设备实例
        fakeDB.put("10.11.58.163", new Device("10.11.58.163"));
        fakeDB.put("10.11.58.184", new Device("10.11.58.184"));
    }
    public Device getDevice(String ip) {
        return fakeDB.get(ip);
    }
    public Device updateDevice(Device device) {
        String ip = device.getIp();
        fakeDB.put(ip, device);
        return fakeDB.get(ip);
    }
}

在这段代码中,DeviceDao类简单地使用HashMap实现了内存级别的持久化。为了单元测试的需要,本例使用了硬编码的测试数据,持久层加载时,为测试环境初始化了两个设备实例,见关注点1。这部分代码在实际项目的单元测试中,不要使用硬编码,可以使用内存数据库比如H2、Derby等替换;在集成环境中,应使用真实的数据库进行替换。

2.1.4 测试和运行服务

到此,完成了simple-service扩展的代码开发部分,接下来为代码编写单元测试类,以检验扩展的REST接口是否正常工作。

DeviceResourceTest类模仿测试类MyResourceTest,分别测试了上述的GET和PUT请求处理方法,示例代码如下。

public class DeviceResourceTest {
    private HttpServer server;
    private WebTarget target;
    @Before
    public void setUp() throws Exception {
        server = Main.startServer();
        final Client c = ClientBuilder.newClient();
        target = c.target(Main.BASE_URI);
    }
    @After
    public void tearDown() throws Exception {
        server.shutdownNow();
    }
    // 关注点1:测试GET方法
    @Test
    public void testGetDevice() {
        final String testIp = "10.11.58.184";
        // 关注点2:带参数的GET 请求
        final Device device = target.path("device")
       .queryParam("ip", testIp).request().get(Device.class);
        // 关注点3:设备IP的断言
        Assert.assertEquals(testIp ,device.getIp());
    }
        // 关注点4:测试PUT方法
    @Test
    public void testUpdateDevice() {
        final String testIp = "10.11.58.163";
        final Device device = new Device(testIp);
        device.setStatus(1);
        Entity<Device> entity = Entity.entity(device, MediaType.APPLICATION_XML_TYPE);
        final Device result = target.path("device").request().put(entity, Device.class);
        // 关注点5:设备状态的断言
        Assert.assertEquals(1, result.getStatus());
    }
}

GET测试方法testGetDevice()测试资源路径为device的get请求,见关注点1。在关注点2,target传递了一个字符串参数ip,其值为“10.11.58.184”。断言验证返回的设备IP是否与预期相同,见关注点3。PUT测试方法testUpdateDevice()创建了一个Device实例,并以put方法将其提交到device资源路径,见关注点4。断言验证返回设备状态是否与预期一致,见关注点5。

完成测试类后,打开控制台,在项目的存储目录再次运行“mvn clean test”命令,如果测试通过,即断言验证成功,说明扩展实现已经成功结束。

宅人坑事

关于单元测试,想和诸君分享一点体会。单元测试是对实现方法所设置的第一道检验屏障,是持续集成中至关重要的环节,是质量保证体系中测试覆盖率的基础。成为优秀开发者就要遵守良好的习惯,单元测试不可忽视,甚至应该遵循测试先行、测试驱动开发这些软件工程中优秀的方法论。

如果开发者写不好单元测试或者忽略其重要性,将这一步骤交由工具来生成,以便完成集成测试的流程和代码覆盖率的KPI,那么,笔者所持的观点是,这是一种倒退性实践,极力反对这种方式。

最后,我们运行Main类,通过浏览器来体验一下第一个REST服务。在浏览器地址栏中输入http://localhost:8080/myapp/device?ip=10.11.58.184,预期得到的输出为:<device"10.11.58.184"status="0"/>。同时,这个测试也验证了前文中推测的资源地址URL规则:

HTTP服务器路径/REST服务名称/myresource/

到此,我们已经快速掌握了一个非常简单的Jersey 2的Java SE应用。接下来,我们介绍更加常用的基于Servlet容器的REST Web服务。