微服务架构简介

微服务架构是一种软件架构模式/思想, 并不是某个具体的框架和技术。微服务架构提倡将大的系统构建成一系列按业务功能划分的, 可独立运行的, 内聚的自治微服务(子系统)。各个服务彼此之间职责明确, 分工协作。

在传统的单体式架构中, 所有的模块代码都构建在一个应用中, 所有的数据存放在一个数据库中。在这种模式下,随着需求的不断增加, 业务功能不断增加,应用系统将会变得越来越庞大和臃肿,可维护性逐渐降低,灵活性降低,代码发布频率越来越慢,新功能交付效率也变得越来越低下。

微服务架构可以帮助我们克服旧架构所面临的挑战,微服务强调的"小快灵",微服务将单体系统的功能模块拆分成独立的微服务。应用系统由若干个微服务构建而成的,每个微服务仅负责归属于自己业务领域的功能,每个微服务本身是内聚的,而服务与服务间通过松耦合的形式交互。微服务具有灵活部署、可扩展、技术异构等特点。

单体应用通常需要使用相同的技术栈, 必须基于相同的开发语言和框架, 要想引入新框架或新技术平台会非常困难。微服务架构因为各个服务之间相互独立,不同的微服务可以根据业务及团队的特点, 灵活地选择不同的技术栈进行开发。可以仅针对一个微服务进行新技术的改造,而不会影响系统中的其它微服务,有利于系统的演进。

单体应用对系统的任何修改都必须对整个系统进行重新的构建、测试。而微服务则可以基于微服务进行修改和重构,粒度更小,更能适应频繁的变更。

单体应用只能作为一个整体进行扩展和部署。而微服务之间相互独立,可以独立部署,部署更灵活,每个微服务都是灵活易变、可伸缩、可扩展、可组合的, 就像搭积木一样, 拼出不同的产品。微服务架构更适云化和容器化。

微服务需要对外提供服务给其他系统调用(交互),目前主流的技术架构包括: RPC和REST。

  • RPC框架有: Apache Dubbo (阿里巴巴), gRPC (Google), Apache Thrift(Facebook), …
  • REST框架有: Spring Boot/Spring Cloud, Jersey, …

需要注意的是REST和RPC也是一种技术思想, 而非一种规范。RPC和REST的一个主要区别是RPC传输的是二进制数据,而REST传输的通常是(JSON)字符串,在性能方面,通常RPC在性能上要优于REST,RPC在响应时间上更为出色。REST采用的通常是HTTP+JSON/XML, 相对的通用性更好,更容易被各个编程语言, 平台,甚至是工具(postman/curl/…)支持。通常来说内部的, 对性能敏感的服务,选择RPC更具有优势,而对外做开放平台开放的服务,通常采用REST会更好一些。

对于REST,目前主流的是使用JSON作为数据格式,因为JSON比XML 简洁得多,XML除了主要传输的数据之外, 有较多冗余用在定义格式上,JSON在网络带宽,序列化和解析的性能方面有明显的优势。

最后需要注意的是,并不是使用了RPC或者REST的应用系统就是微服务架构,如果只是在单体架构中,提供了RPC或者REST类型的服务,这还是一个单体的架构。还是那句话,微服务架构是种软件设计思想,而非某种具体的技术,RPC/REST只是适合于实现微服务架构,或者说某种具体框架是基于微服务思想,为构建微服务架构而创建出来的。

Restfull API简介

REST是(REpresentational State Transfer)的缩写,在2000年Roy Fielding的博士论文中被提出,REST也是一种设计思想。如果一个接口符合REST原则(约束条件),那我们就称这类接口为REST风格的接口,也就是RESTful API。REST并不绑定某种编程语言,你可以使用Java/Spring编写RESTful API,也可以使用PHP/Python,… anyway.

REST指的是资源在网络中以某种表现形式进行状态转移。首先, REST是面向资源的架构, (ROA - Resource-Oriented Architecture)。 REST把一切程序需要访问到的业务对象统一定义为资源, REST中的表述(Representational ),指的是资源的某种形式(如通过JSON/XML)的表述, 表述不一定是所有信息,可以只是关注的部分信息。在REST中,传输的都是资源的表述。

状态转移中的"状态"指的是资源的状态,资源状态存储在服务器端,客户端通过指定http(GET/POST/PUT/DELETE/…)请求方法, 对资源的状态进行增删查改(CURD)。通过调用,引起资源状态的改变,称为状态转移。

无状态(Stateless)是REST架构设计中一个重要的原则,无状态指的是服务器端不会为客户端请求创建会话(Session)信息,因此无状态的请求必须包含有能够让服务器理解请求的全部信息。

对于有状态的应用,服务端维护一个会话状态信息列表,客户端第一次请求,服务器会创建一个会话,并通过创意一个唯一的session-id来标识该会话,并通过http响应的Set-Cookie头部返回给客户端,后续客户端请求携带包含session-id信息的cookie头部,服务端解析cookie取出session-id,就可以通过session-id找到保存在对应的会话中的信息。例如, 当一个用户登录成功后,我们可以把用户名(username)放到session中保存,这样后续的请求,请求不需要附带户名信息,服务器也知道你是谁(与你有关的信息)。

对于无状态的Web应用,因为服务器上没有会话(上下文)信息,所以每次请求都需要带上用户名等信息,服务器端才能知道,当然出于安全考虑,不能你说你是谁就是谁,通常会引入OAuth机制,要求每次请求带上授权凭证(access-token),对身份进行核验。

为什么要强调无状态原则呢?因为会话信息(变量)是存放在服务器的内存中的,这就意味着有状态的请求只能由同一台服务器进行处理,而无状态请求则完全没有这个限制,请求能够被任何可用的服务器处理,无状态请求有较强的容错性和横向扩展性。

每一个资源通过唯一的URI(Uniform Resource Identifier)标识,一个URI定位一个资源。在URI的设计上我们通常需要遵循规范(guidelines),就像Java语言的编码规则: 类名要大写开头, 方法名变量名小写开头, … 虽然不是强制的, 但推荐这么做,遵循统一的规则便于开发这和使用者之间形成默契,更容易理解URI的用途。

规则一: URI的路径(Path)部分应该只有名词,不包含动词。URI的路径部分应该体现的是资源的层次关系。

//符合REST风格的URI
http://api.example.com/human-resources/employees     // 所有员工
http://api.example.com/human-resources/employees/1  //  员工编号为1的员工
http://api.example.com/human-resources/employees?country=china®ion=shanghai //中国,上海地区,所有员工

// 不符合REST风格的URI, 因为包含了动词(query-employees和update), 不是资源的层次关系。
http://api.example.com/human-resources/query-employees 
http://api.example.com/human-resources/update/employees/1

规则二: 应该通过不同的http请求方式(GET/POST/PUT/PATCH/DELETE)代表对资源的不同的操作, 而不是通过URI。

//符合REST风格的URI, 对资源的增删查改操作请求的都是同一个URI,只是使用不同的http请求的方法来区分不同的操作。
GET  http://api.example.com/human-resources/employees/1    //  获取服务器中员工编号为1的员工(资源)
POST http://api.example.com/human-resources/employees   //  在服务器中创建一个新的员工(资源)
PUT  http://api.example.com/human-resources/employees/1   //  更新服务器中员工编号为1的员工(资源)

// 不符合REST风格的URI, 使用不同的URI来对同一个资源进行增删查改操作
GET  http://api.example.com/human-resources/employees/query-employee?emp_id=1
GET  http://api.example.com/human-resources/employees/new?emp_id=1
GET  http://api.example.com/human-resources/update/employees/1

规则三: 使用请求参数(Request Parameters)传递与资源以及资源层次无关的信息。

//符合REST风格的URI
http://api.example.com/human-resources/employees?offset=0&limit=10 
http://api.example.com/human-resources/employees?sortby=ename&order=asc

// 不符合REST风格的URI
http://api.example.com/human-resources/employees?emp_id=1

规则四:URI全部小写,不要使用大写,或者大小写组合。 可以使用横杆"-"来增加URI的可读性,。

//符合REST风格
http://api.exmple.com/my-learning-exmple

//不符合REST风格, 使用了大小写组合
http://api.example.com/MyLearningExample
http://api.example.com/my_learning_example // 也不推荐使用下划线

对于URI设计的规则,个人的感悟是,要理解URI的本质,而不是生搬硬套。我们先看看URI的格式的定义:

http:     //127.0.0.1:8080 /human-resources/employees  ?country=china®ion=shanghai
[scheme:][  //authority  ] [         path           ] [             ?query          ] [#fragment]

URI的本质是唯一定位资源,可以理解为资源的名称空间,名称空间是通过层次结构(authority+path)来区分,这和java的包(package)机制类似,例如"org.springframework.boot.SpringApplication", 通过package将Spring的SpringApplication与其他同名的类区分开来。

例如我们设计一个人力资源系统,我们有employees资源, departments资源,...我们该如何设计资源URI?

1#. http://127.0.0.1:8080/employees?country=china®ion=shanghai  -- 不好的URI
2#. http://127.0.0.1:8080/china/shanghai/employees  -- 不好的URI
3#. http://127.0.0.1:8080/human-resources/employees?country=china®ion=shanghai   --好的URI

1# URI: 这个URI缺少层次结构, 当系统庞大, 有多个资源时, 容易产生名称冲突,
        而且1# URI没有层次结构也不利于我们一眼就看出这个employees资源是什么系统,干什么的;
2# URI: 这个URI看似有层次结构(中国, 上海地区, 的所有员工资源), 但这个层次结构设计不合理,  
         这样意味着(/china/guangzhou/employees: 中国, 广州地区, 的所有员工资源)也是一个不同资源, 
         实际上我们系统中所有员工(employees)是一个资源, china/shanghai/guangzhou/...只对employees资源的过滤(query);
3# URI: 是个合理的URI设计;体现在编码过程中:

//3# URI的控制器
@RestController
@RequestMapping("/human-resources/employees")
public class EmployeesController {
	@RequestMapping(method=RequestMethod.GET)
	public List<Employee> getEmployees(
			@RequestParam(name = "country", required = false) String country,
			@RequestParam(name = "region") Optional<String> region
			) { ...}
}


// 如果使用2# URI, 需要为每个地区的员工编写控制器
// 从OOP角度, 这些员工的基本是相同的, 仅区域属性不同,重用角度看,分成不同的类不合理, 也不通用。 
@RestController
@RequestMapping("/china/shanghai/employees") //Shanghai
public class ShanghaiEmployeesController {
	@RequestMapping(method=RequestMethod.GET)
	public List<Employee>getEmployees() {...}
}

@RestController
@RequestMapping("/china/guangzhou/employees") //Guangzhou
public class GuangzhouEmployeesController {
	@RequestMapping(method=RequestMethod.GET)
	public List<Employee> getEmployees() {...}
}

// other country and region ...

4#. http://127.0.0.1:8080/employees?employee_id=7369  -- 不好的URI
5#. http://127.0.0.1:8080/employees/7369  -- 好的URI

4# URI的语义是, 操作所有员工, 过滤出employee_id=7369的一个员工;
5# URL的语义是, 操作employee_id=7369的员工;
因为我们系统在编码上会设计一个Employee类, 代表员工, 所有员工我们会用一个列表表达List<Employee>,对于操作具体一个员工资源, 
5# URL逻辑更自然合理。在实际编码中也是:

@RestController
@RequestMapping("/human-resources/employees")
public class EmployeesController {
	@RequestMapping(method=RequestMethod.GET)
	public List<Employee> getEmployees(
			@RequestParam(name = "country", required = false) String country,
			@RequestParam(name = "region") Optional<String> region
			) {
			//...
	}
	@RequestMapping(method = RequestMethod.POST)
	public void addEmployee(@RequestBody Employee user) {
	}
	
	@RequestMapping(path = "/{empno}", method = RequestMethod.GET)
	public Employee getEmployee(@PathVariable("empno") String empNo) {
		//...
	}
	
	@RequestMapping(path = "/{empno}", method = RequestMethod.DELETE)
	public void deleteEmployee(@PathVariable("empno") String empNo) {}
	// ...
}

在理解原理和概念后,编码是很简单的,Spring Boot对开发Restful API提供了强大的支持,编写起来非常简单,下面我们看看简单的实现。

使用Spring Boot编写Restful API

配置数据源

开始前,我们先准备数据源, 数据库采用了H2的内存数据库,数据库案例用Oracle的scott案例数据库迁移过来。为了与之前的代码分开,我们把代码放在learning6这个包下面,因为我们要使用JDBC数据源,所以需要引入相关依赖:

...
<-- Spring Boot JDBC自动配置支持 -->
<dependency>
	<groupId>org.springframework.boot</groupId> 
	<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- H2数据库 JDBC驱动 -->
<dependency>
	<groupId>com.h2database</groupId>
	<artifactId>h2</artifactId>
</dependency>

<!-- 我们使用阿里的Druid连接池, spring-boot-starter-jdbc默认包含了HikariCP连接池, 使用HikariCP 可以不用额外引入-->
<dependency>
	<groupId>com.alibaba</groupId>
	<artifactId>druid</artifactId>
	<version>1.2.8</version>
</dependency>
...

因为有jdbc-starter自动配置JDBC数据源, 我们只需要按照约定配置,就可以直接使用了。

## application.properties中配置如下内容:
spring.datasource.type = com.alibaba.druid.pool.DruidDataSource
spring.datasource.url = jdbc:h2:mem:hr;mode=MySQL;database_to_lower=true
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username = sa
spring.datasource.password = passw0rd

## Enable H2 Console
spring.h2.console.enabled=true
#注意, 不要有空格
spring.h2.console.path=/h2
spring.h2.console.settings.web-allow-others=true

#数据源初始化时会运行resources/db/schema.sql文件,对数据库的结构进行操作。
spring.sql.init.schema-locations=classpath:db/schema.sql

#数据源初始化时会运行resources/db/data.sql文件, 对数据库的数据操作。
spring.sql.init.data-locations=classpath:db/data.sql

我们在resources/db目录下创建数据库初始化脚本:

---> schema.sql
-- from oracle sample db: scott
CREATE TABLE EMP(
	EMPNO NUMBER(4) NOT NULL,
	ENAME VARCHAR2(10),
	JOB VARCHAR(9),
	MGR NUMBER(4),
	HIREDATE DATE,
	SAL NUMBER(7,2),
	COMM NUMBER(7,2),
	DEPTNO NUMBER(2)
);

CREATE TABLE DEPT(
	DEPTNO NUMBER(2),
	DNAME VARCHAR(14),
	LOC VARCHAR(13) 
);

---> data.sql
INSERT INTO EMP VALUES(7369, 'SMITH', 'CLERK', 7902, PARSEDATETIME('17-Dec-1980', 'dd-MMM-yyyy', 'en', 'GMT'), 800, NULL, 20);
INSERT INTO EMP VALUES(7499, 'ALLEN', 'SALESMAN', 7698,PARSEDATETIME('20-FEB-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 1600, 300, 30);
INSERT INTO EMP VALUES(7521, 'WARD', 'SALESMAN', 7698,PARSEDATETIME('22-FEB-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 1250, 500, 30);
INSERT INTO EMP VALUES(7566, 'JONES', 'MANAGER', 7839,PARSEDATETIME('2-APR-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 2975, NULL, 20);
INSERT INTO EMP VALUES(7654, 'MARTIN', 'SALESMAN', 7698,PARSEDATETIME('28-SEP-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 1250, 1400, 30);
INSERT INTO EMP VALUES(7698, 'BLAKE', 'MANAGER', 7839,PARSEDATETIME('1-MAY-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 2850, NULL, 30);
INSERT INTO EMP VALUES(7782, 'CLARK', 'MANAGER', 7839,PARSEDATETIME('9-JUN-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 2450, NULL, 10);
INSERT INTO EMP VALUES(7788, 'SCOTT', 'ANALYST', 7566,PARSEDATETIME('09-DEC-1982', 'dd-MMM-yyyy', 'en', 'GMT'), 3000, NULL, 20);
INSERT INTO EMP VALUES(7839, 'KING', 'PRESIDENT', NULL,PARSEDATETIME('17-NOV-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 5000, NULL, 10);
INSERT INTO EMP VALUES(7844, 'TURNER', 'SALESMAN', 7698,PARSEDATETIME('8-SEP-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 1500, 0, 30);
INSERT INTO EMP VALUES(7876, 'ADAMS', 'CLERK', 7788,PARSEDATETIME('12-JAN-1983', 'dd-MMM-yyyy', 'en', 'GMT'), 1100, NULL, 20);
INSERT INTO EMP VALUES(7900, 'JAMES', 'CLERK', 7698,PARSEDATETIME('3-DEC-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 950, NULL, 30);
INSERT INTO EMP VALUES(7902, 'FORD', 'ANALYST', 7566,PARSEDATETIME('3-DEC-1981', 'dd-MMM-yyyy', 'en', 'GMT'), 3000, NULL, 20);
INSERT INTO EMP VALUES(7934, 'MILLER', 'CLERK', 7782,PARSEDATETIME('23-JAN-1982', 'dd-MMM-yyyy', 'en', 'GMT'), 1300, NULL, 10);

INSERT INTO DEPT VALUES (10, 'ACCOUNTING', 'NEW YORK');
INSERT INTO DEPT VALUES (20, 'RESEARCH', 'DALLAS');
INSERT INTO DEPT VALUES (30, 'SALES', 'CHICAGO');
INSERT INTO DEPT VALUES (40, 'OPERATIONS', 'BOSTON');

这样启动程序后我们可以通过:我们可以通过http://127.0.0.1:8080/h2访问H2 Console。

编写Spring控制器

package org.littlestar.learning6.controller;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import org.littlestar.learning6.entity.Employee;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
//import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RestController // => @Controller + @ResponseBody
@RequestMapping("/human-resources/employees")
public class EmployeesController {
	
	@Autowired JdbcTemplate jdbcTemplate;
	
	 GET http://127.0.0.1:8080/human-resources/employees
	@GetMapping // => @RequestMapping(method = RequestMethod.GET)
	public List<Employee> getEmployees() {
		final String sqlText = "select * from emp";
		List<Employee> emps = jdbcTemplate.query(sqlText, getEmployeeMap());
		return emps;
	}
	
	 POST http://127.0.0.1:8080/human-resources/employees
	@PostMapping // => @RequestMapping(method = RequestMethod.POST)
	public void addEmployee(@RequestBody Employee emp) {
		String sqlText = "insert into emp(empno,ename,job,mgr,hiredate,sal,comm,deptno) values (?,?,?,?,?,?,?,?)";
		jdbcTemplate.update(sqlText, emp.getEmpno(), emp.getEname(), emp.getJob(), emp.getMgr(), emp.getHiredate(),
				emp.getSal(), emp.getComm(), emp.getDeptno());
	}
	
	 GET http://127.0.0.1:8080/human-resources/employees/7369
	@GetMapping(path = "/{empno}") // => @RequestMapping(path = "/{empno}", method = RequestMethod.GET)
	public Employee getEmployee(@PathVariable("empno") String empNo) {
		final String sqlText = "select * from emp where empno=?";
		List<Employee> emps = jdbcTemplate.query(sqlText, getEmployeeMap(), empNo);
		return emps.isEmpty() ? null : emps.get(0);
	}
	
	 PUT http://127.0.0.1:8080/human-resources/employees/8000
	@PutMapping(path = "/{empno}") // => @RequestMapping(path = "/{empno}", method = RequestMethod.PUT)
	public void updateEmployee(@PathVariable("empno") String empNo, @RequestBody Employee emp) {
		String sqlText = "update emp set empno=? , ename=? , job=?, mgr=?, hiredate=?, sal=?, comm=?, deptno=? where empno=?";
		jdbcTemplate.update(sqlText, emp.getEmpno(), emp.getEname(), emp.getJob(), emp.getMgr(), emp.getHiredate(),
				emp.getSal(), emp.getComm(), emp.getDeptno(), empNo);
	}
	
	 DELETE http://127.0.0.1:8080/human-resources/employees/8000
	@DeleteMapping(path = "/{empno}") // => @RequestMapping(path = "/{empno}", method = RequestMethod.DELETE)
	public void deleteEmployee(@PathVariable("empno") String empNo) {
		String sqlText = "delete from emp where empno=?";
		jdbcTemplate.update(sqlText, empNo);
	}
	
	private RowMapper<Employee> getEmployeeMap() {
		RowMapper<Employee> rowMapper = new RowMapper<>() {
			@Override
			public Employee mapRow(ResultSet rs, int rowNum) throws SQLException {
				Employee emp = new Employee(
						rs.getInt("empno"),
						rs.getString("ename"),
						rs.getString("job"),
						rs.getInt("mgr"),
						rs.getDate("hiredate"),
						rs.getDouble("sal"),
						rs.getDouble("comm"),
						rs.getInt("deptno")
						);
				return emp;
			}
		};
		return rowMapper;
	}
}

编写启动类

package org.littlestar.learning6;

import java.sql.SQLException;

import org.h2.tools.Server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class App6 {
	public static void main(String[] args) {
		SpringApplication.run(App6.class, args);
	}

	// 启动H2服务器, 以便通过Dbeaver这类数据库工具连接
	// url = jdbc:h2:tcp://127.0.0.1:9092/mem:hr 
	// sa/passw0rd
	@Bean(initMethod = "start", destroyMethod = "stop")
	public Server h2Server() throws SQLException {
		return Server.createTcpServer("-tcpPort", "9092", "-tcpAllowOthers");
	}
}

测试Restful API

启动即可,测试Restful API通常可以Postman来进行:

  1. 查询7934员工:
  2. 创建一个员工(8000)
  3. 更新员工(8000)
  4. 删除员工(8001)