关于学习开发框架的思考与常用框架介绍

关于学习开发框架的思考与常用框架介绍

五月 17, 2020

关于开发框架

最近团队来了几乎没有任何开发经验的新手,为了能为其上手提供帮助,回想了自己当年初识SpringBoot那迷茫而又困惑的时光。为了能够以浅显的语言表述开发框架的作用,结合一年多来自己的理解,进行了一些思考。

首先是Wikipedia上对于开发框架Software Framework的定义:

In computer programming, a software framework is an abstraction in which software providing generic functionality can be selectively changed by additional user-written code, thus providing application-specific software.

用Java来类比,我认为开发框架就是抽象类。它们对各色各样的软件中必备、关键、繁琐的部分进行了抽象,规定下了通用的流程,而更靠近业务领域的一侧则留白给了developer填充自己的项目的逻辑。

而给新手带来困扰之处在于,一个开发框架留白之处常常显得支离破碎,在不了解框架定义的流程的初期,直接阅读项目代码无法捋顺完整的执行过程。毕竟这就是框架的初衷:剥离千篇一律的(意味着不论怎样的项目都是相类似的,也就意味着默认了绝大部分开发者都是对其了解的)技术流程,使得商业应用的程序员能够专注于自己的项目的业务逻辑,创造更多商业价值。

常用开发框架介绍

以Web应用框架(SpringBoot-Web)举例

一个Web应用程序,最为核心关键的流程就是将浏览器客户端发送的请求传递到正确的Controller方法进行处理,再将响应返回。

更为具体一点,以一个完整的URLhttp://192.168.101.231:8080/index来分析:

  1. http://描述了这一次访问使用的协议,也就是你会以怎样的方式进行沟通(想象成找人谈话时使用的语言)
  2. 192.168.101.231IP地址,描述了你想要请求的服务器的地址(尽管平常我们直接使用的都是网址,但网址本质上也会经过DNS解析最终变为IP地址)。这就像通过地址找到一栋大楼
  3. :8080指的是服务器上的端口。每台服务器上可能有很多程序运行着,当它们都需要网络接入的时候就必须用端口区分具体找的是哪个程序。端口就好比大楼的楼层,每个楼层都有不同的公司。因此IP+端口的组合就可以在互联网上定位到一个具体的程序了
  4. /index这最后的路径部分就是Web应用程序要处理的部分了,好比在公司的楼层内找寻不同的员工(controller方法)询问问题

IntelliJ IDEA新建项目(File->New->Project->Spring Initializr->Dependencies中勾选Web->Spring Web),直接运行main方法就可以立即启动一个除非主动关闭,否则就可以一直运行下去的Web后端服务程序

1
2
3
4
5
6
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

这就是刚学习完语言基础语法,写的算法题的解答代码可以从main方法开始一步一步走完并且最终结束,现在初识开发框架的新手第一个会迷茫的地方:绝大多数软件项目都是以永不停歇的服务的方式运行着,main方法虽然还是一切的开始,但是程序启动之后打出了一片日志,然后就陷入了沉寂——但也没有停止运行。当你尝试看看随后紧跟的就是框架本身代码的汪洋大海SpringApplication.run(),这片大海中包含着你的项目可以永远运行下去的奥秘。事实上,可能大多数项目的main方法——看起来——就只做了这一件事,启动服务,无他。

若是习惯了Ctrl+左键来一点点阅读代码,此时一定陷入了迷茫:找不到地方可以到达项目中其它代码。项目中的代码(main方法)和代码(其余代码)之间被割裂,填充在裂痕之中的还是深不可测的SpringBoot的代码。

这时就需要再回过头去看本篇开头的话:一个Web后端程序最核心的流程是什么?当服务成功启动之后,接下来才是重头戏——接待来自各个不同源头的客户端发来的各色各样的请求。事实上,在这种处理流程下,我们可以认为每一个Controller方法就成了一段处理流程的起点。

1
2
3
4
5
6
7
@RestController
public class IndexController {
@GetMapping("/index")
public String sayHello() {
return "hello";
}
}

新增一个简单的Controller类,配以@RestController, @GetMapping寥寥两个注解,重启应用后就能在浏览器上访问localhost:8080/index并且看到返回的Json响应数据了。

现在,摆在眼前的是两条路:

  1. 想要熟悉项目业务逻辑的,就可以把发送请求后发生的一切当作理所应当,从进入Controller方法之后开始逐步阅读项目中的代码/上手干活儿了;
  2. 但,保持一定的好奇心也是必要的——看起来简单的流程背后有太多事情已经发生了。对框架的行为一无所知会时常导致开发者错用框架的功能,甚至引发严重的问题。
    • 请求路径是如何被识别并路由到相应的方法的?
    • 不同的HTTP请求方法是怎样被路由到相应的方法的?(尝试再增加一个方法并且用@PostMapping注解)
    • 是谁实例化我的IndexController类为对象,并且调用我的实例方法(sayHello())的?
    • 附录中还介绍了更多功能

接下来会再介绍一些开发框架的核心功能与简单实用示例,但也会抛出一些问题作为深入学习它们的切入点。

其实不存在一个开发框架叫SpringBoot-Web,SpringBoot网罗了一大堆开发框架在内,并且提供了简单易用的默认配置。此处指的是包含了Spring,SpringMVC框架并内嵌了tomcat Web服务器的SpringBoot-Web“框架包”。章节中重点提及的Web请求路由、响应的功能其实是SpringMVC框架所提供的。

Spring

Spring最为核心本质的定义是一个IoC(inversion of control)容器。而IoC控制反转这个概念存在的意义则是为了提供对对象的创建、配置、销毁等(统称为生命周期)的管理,从根本上改变使用者对这些对象的获取方法。

再把以上的描述限定到最为常见的场景,可以简化为:Spring去除了项目中的new关键字。当你需要一个对象的时候,用@Autowired向Spring讨一个就行了。Spring好比租房的中介平台,有房东提供了房源,有租客需求房源。Spring的出现使得房东和租客不再需要互相认识,大家都找平台就行了。

复用上面SpringBoot项目的代码(SpringBoot中包含了Spring,因此不需要做什么依赖方面的改动)来举个例子:

1
2
3
4
5
6
@Component
public class TextHelper {
public String concatString(String s1, String s2) {
return s1 + s2;
}
}

新建的类被@Component注解了,这意味着这个类将会被Spring管理,也就意味着之后你可以在其它地方,比如之前写过的controller中,向Spring讨要TextHelper类的实例对象了:

1
2
3
4
5
6
7
8
9
10
@RestController
public class Index {
@Autowired
private TextHelper textHelper;

@GetMapping("/index")
public String sayHelloWorld() {
return textHelper.concatString("hello", "world");
}
}

此时访问localhost:8080/index已经可以看到拼接后的”helloworld”了,尽管我们从未根据传统使用new TextHelper()实例化对象。是Spring在幕后某处创建了这个对象,并且根据我们的要求(@Autowired)把它创建好了的这个对象注入了进来。

要留意的是@RestController这个注解也包含了@Component的功能;换句话说,Index类此时也是被Spring管理的。仔细想想其实非常简单:房东和租客都需要在平台上注册了,平台才能把房东的房子顺利地租给租客嘛。

但是!

Spring可以,并不代表着所有的Java类的实例化和获取都该交由Spring来处理。一般来说,只有注重于提供功能(方法调用)的Java类(又称为Java Bean)会交由Spring管理;而注重于数据存储的Java类(比如POJO)则不会,它们只是单纯的数据的载体。

关于Spring可能会有成吨的问题:

  • Spring是怎么知道哪些类需要它来创建对象?
  • Spring是怎么创建对象的?
  • Spring根据怎样的流程创建对象?我们能干预这些流程吗?
  • 假如有多处地方需求同一种对象,最后它们获取到的会是同一个对象引用吗?
  • 除了直接在Field上注解,还有别的讨要依赖的方式吗?
  • ……

Quartz

Quartz是一个定时任务调度框架。简单地讲就是根据配置的时间参数,自动调用配置好的目标方法。

Quartz使用起来相对复杂的地方在于一个完整的定时任务调度流程分了三段进行配置。下面以我们实际使用的Spring/Quartz项目的配置文件为例。

Quartz首先要求配置JobDetail用于描述定时任务被调度时执行哪个Java类的方法,比如我们希望这个定时任务执行ScheduledJobrun()方法:

1
2
3
4
5
6
@Component
public class ScheduledJob {
public void run(){
// do something
}
}

相应的配置就会是下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="utf-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd">

<context:component-scan base-package="com.example.demo"/>
<!-- <bean id="scheduledJob" class="com.example.demo.ScheduledJob"/>-->

<bean id="scheduledJobDetail" class="org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean">
<property name="targetObject" ref="scheduledJob"/>
<property name="targetMethod" value="run"/>
<property name="concurrent" value="false"/>
</bean>
...
</beans>

由于在配置中开启了component-scan(Spring的功能),而之前在ScheduledJob类上又添加了@Component注解,直接为targetObject这个property填入ref="scheduledJob"后Quartz就能自动找到相应的类的对象了。当然也可以采用被注释的行的配置方法指定Java类。

targetMethod属性的value则指定了该在选择的类中寻找哪个方法执行。

concurrent属性的值则会影响调度任务是否会并发执行。

1
2
3
4
5
<bean id="scheduledJobTrigger" class="org.springframework.scheduling.quartz.SimpleTriggerFactoryBean">
<property name="jobDetail" ref="scheduledJobDetail"/>
<property name="startDelay" value="1000"/>
<property name="repeatInterval" value="10000"/>
</bean>

配置的第二层是时间触发器的配置。简单的时间触发器引用了刚才配置完的jobDetail,和任务初始的延迟时间startDelay,还有任务每次调度时的间隔时间repeatInterval,属性值的单位都是毫秒。

1
2
3
4
5
6
7
8
9
<bean class="org.springframework.scheduling.quartz.SchedulerFactoryBean">
<property name="waitForJobsToCompleteOnShutdown" value="true"/>
<property name="triggers">
<list>
<ref bean="scheduledJobTrigger"/>
</list>
</property>
<property name="autoStartup" value="true"/>
</bean>

最后一层则是配置了调度器,比如哪些触发器需要进行调度,调度器关闭时是否要等待被调度的任务运行完毕,以及是否自动启动。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Server {
private ClassPathXmlApplicationContext quartzFactory;

public void launch() {
log.info("Data report server start...");
quartzFactory = new ClassPathXmlApplicationContext("classpath:serverContext.xml");
}

public static void main(String[] args) {
Server server = new Server();
server.launch();
}
}

最后是启动代码,读取了刚才配置的配置文件,随后Quartz就会自动启动,并且根据配置开始自动定时调度任务。

Hibernate

Hibernate是一个ORM框架,所谓ORM(object-relation mapping)是指将面向对象语言中的对象(object)与关系型(relation)数据库中的数据进行关联映射(mapping)的可逆过程,简单地说,可以方便程序员通过对对象的操作来操作数据。其核心的流程是在程序查询关系型数据库中时,把以表(行与列构成)的形式组织的数据自动转换成预先手动配置好的Java类的对象;又或者逆反此流程,将程序中创建的对象持久化到数据库。

由于对象世界与关系型数据库的世界存在着非常大的差异,而数据处理又往往和业务交织在一起,流程中需要程序员介入的地方相较前面介绍的框架显得更多更复杂。JPA(可以理解为官方提供的标准接口,而Hibernate是第三方开发的实现)定义了一系列的接口与规范来帮助程序员完成这些过程。

大多数情况下,我们并不是直接在使用Hibernate,而是通过JPA提供的接口在操作数据,就像我们使用集合接口一样:

1
2
List<Integer> list = new ArrayList<>();
list.add(1); // 调用List接口方法

JPA就像List接口,而Hibernate就是它的一个实现,比如ArrayList。当项目创建,引入了Hibernate作为依赖,服务启动运行时Spring就会初始化并加载Hibernate。模拟这个过程就像如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// JPA提供接口
public interface JPA {
}

// Hibernate提供实现
@Component
public class Hibernate implement JPA {
}

// 程序员自己编写的代码
@Component
public class IndexController {
// 通过Spring自动注入
@Autowired
private JPA jpa;

@GetMapping("/index")
public String sayHello() {
// 使用JPA定义的接口方法
return jpa.findSomethingFromDatabase();
}
}

总得来说,JPA的配置与使用主要分为两方面:

元数据映射(Mapping)

元数据映射指的是将Java类与数据库表进行关联,一般通过在Java类与字段上的注解来完成。

1
2
3
4
5
6
7
8
9
10
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

@Column(name = "name")
private String name;
}

只有被@Entity注解的类会被认为是需要ORM框架介入并进行管理的。这也就意味着这个类的对象是可以进行持久化的,通常我们又叫这些类为Model或实体类。

实体操作

JPA对实体对象的操作通过javax.persistence.EntityManager来进行。但是需要注意的是,实体对象必须处于PersistenceContext持久化上下文内,才能被EntityManager管理和操作。还是以租房平台为例子:当不在PersistenceContext时,实体对象只是一个普通简单的Java对象而已,就像一个尚未在平台中注册的用户。EntityManager此时就是租房平台的应用,当实体对象进入到PersistenceContext后,EntityManager才能发挥它的威力。

关于实体对象在PersistenceContext中的状态,以及EntityManager能够采取的操作,在另一篇Blog The persistence life cycle中有更详细的解释。这里只举几个简单和重要的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Repository
public class UserRepository {
@Autowired
private EntityManager entityManager;

// 持久化一个刚才配置完映射的User对象实例到数据库
public void saveUser() {
User user = new User();
entityManager.persist(user);
}

// 使用JPQL语法查找一个User并在修改其数据后保存
public void updateUser() {
String queryString = "SELECT u FROM User u WHERE u.id = :id AND u.name is null";
User user = entityManager.createQuery(queryString, User.class)
.setParameter("id", 1L)
.getSingleResult();

user.setName("anotherName");
entityManager.merge(user);
}

// 使用SQL删除User
public void deleteUser() {
entityManager.createNativeQuery("DELETE FROM users").executeUpdate();
}
}

附录

更多SpringBoot的使用例子

尝试将Controller方法的返回类型改为ResponseEntity,并指定返回的HTTP状态

1
2
3
4
@GetMapping("/index")
public ResponseEntity sayHello() {
return ResponseEntity.badRequest().body("Cannot process this request");
}

SpringBoot做了怎样的工作处理HttpResponse?

尝试让Controller方法直接返回Model对象

先定义一个Model对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Model {

private String name;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public Model(String name) {
this.name = name;
}
}

修改一下Controller方法

1
2
3
4
@GetMapping("/model")
public Model getModel() {
return new Model("cat");
}
  • 去浏览器看看返回的数据长什么样
  • 很显然这也是框架的功劳