第九章 后端数据访问

从这一章开始,教程将从前端开发转到后端开发。前后端依赖接口进行解耦合,接口风格采用 RESTful 风格。之前我们已经了解到,Web前端通过访问RESTFul接口来获得后端提供的数据。这章将讲述使用 SpringBoot 搭建后端服务。后端服务从前到后分为三层,即接口层(Controller)、业务逻辑层(service)和数据访问层(dao),其中接口层是与前端进行交互的接口,而数据访问层负责与数据库交换数据。除此之外,我们还需要抽象并实现实体对象(domain)。本章将讨论实体对象(domain)和数据访问层(dao),下一章将讨论接口层(Controller)和业务逻辑层(service)。

1 数据持久化方案选择

通过之前的教程我们已经了解到,web项目的前端代码通过发送请求从java后端申请到所期望的数据。那么,后端的数据是从哪里来呢?答案是数据库。后端从数据库中获得数据并进行加工、包装后将其返回给前端代码。

现在主流的数据库有MySQL、Oracle、MongoDB等,而我们在这个项目中选择使用MySQL,因为它对新手更友好,使用更为方便,与Spring框架的交互性也较强,同时它本身也是一种使用较广泛,功能全面的关系型数据库。

2 创建所需数据库表

2.1 创建数据库

创建数据库的语句十分简单:

CREATE DATABASE 数据库名;

输入完成后点击运行即可。需要注意的是,在SQL编辑器中,每条语句之间需要用分号分隔。

在本例中,我们建立一个名为heroes的数据库。

2.2 创建数据表

创建数据表与创建数据库类似,语句本身并不复杂。语句基本格式为:

 CREATE TABLE 表名称
 (
 列名称1 数据类型,
 列名称2 数据类型,
 列名称3 数据类型,
 ....
 )

但是,在创建数据表时,我们往往想要为其中的列添加各种各样的约束。在这里介绍NOT NULL、UNIQUE、DEFAULT、主键、外键的创建方法。

NOT NULL:非空约束,设置某一字段值不可为空。只需在创建列时,在声明数据类型之后加上not null,如name varchar(255) not null.

UNIQUE:单一性约束,表示该列的值具有唯一性。在声明完所有列后,添加UNIQUE(列名)即可。如:

CREATE TABLE 表名称
(
    name varchar(255),
    列名称2数据类型,
    列名称3数据类型,
    UNIQUE(name)
)

DEFAULT: 设置默认值,若添加或修改一条数据后,该列值为空(null),则将该列值设置为该默认值。其声明方法与NOT NULL类似,在声明数据类型之后添加DEFAULT ‘默认值’,如name varchar(255) DEFAULT ‘Sam’.

主键(PRIMARY KEY):主键唯一标识表中的每条数据,主键与UNIQUE的区别在于,每个表只能有一个主键,空主键不可为空。主键可以由一列或多列构成。当只有一列时,我们可以通过如NOT NULL约束一样的声明方法,如name varchar(255) DEFAULT ‘Sam’ PRIMARY KEY。也可以用另一种方法,在声明完所有列后,添加PRIMARY KEY (列名)。若由多列构成主键,只能依靠第二种方法声明,格式为PRIMARY KEY (列名1,列名2)。

外键(FOREIGN KEY):外键用于与其他表产生关联,外键往往指向另一个表的主键。外键的创建格式为FOREIGN KEY (列名) REFERENCES目标表名(列名),在声明完所有列后添加。

在本例中,我们需要建立两张表,一张为用户表,表中应记录用户的登录信息及姓名:

CREATE TABLE users(
    id bigint PRIMARY KEY,
    username varchar(255) not null,
    password varchar(255) not null,
    name varchar(255)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

另一张表为存储英雄信息的表,从前端开发时我们知道,一个英雄应包含姓名、性别等基本信息,同时还应该有一个字段标识它是由哪个用户创建的,这个字段就是连接英雄表与用户表的外键。

CREATE TABLE heroes(
    id bigint PRIMARY KEY,
    name varchar(255) not null,
    gender varchar(10),
    description varchar(255),
    ownerId bigint,
    FOREIGN KEY (ownerId) REFERENCES users(id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

需要注意,在前端的英雄类中,描述变量为desc,但是在数据库中我们是不能这样命名的,因为desc在数据库SQL语句中是保留字,代表了排序中的降序。因此,在这里我们将其命名为description。

此外,也许你已经注意到,在创建表时加在最后的ENGINE=InnoDB DEFAULT CHARSET=utf8;这是将数据表类型声明为InnoDB,这是一种支持外键的数据库类型,声明为这种类型后我们才能添加外键。

在创建完数据表后,我们在数据库的工作基本完成,下一节开始我们将目光转回Spring后端,将数据库、后端、前端联系起来。

3 JPA配置

JPA全称为Java Persistence AP,即Java持久化接口。其原理为将数据库中的数据映射为Spring框架中的实体,即将数据库中的表映射为后端中的一个实体类,将表中的一条条数据映射为该实体类的对象,极大地简化了操作难度。我们将主要在我们后端的Model(即domain)层与dao层使用它。

而Spring JPA的配置也十分简单。首先,我们在配置文件pom.xml中添加JPA依赖:

<dependency>
<groupId>org.springframework.boot</groupId>
]<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

接下来,在application.yml中spring配置部分同样添加jpa配置信息:

jpa:
    show-sql :true

最后,在该部分中配置数据库连接信息与json支持:

jackson:
serialization:
    INDENT_OUTPUT:true
datasource:
    url: jdbc:mysql://localhost:(数据库端口)/(数据库名)?useUnicode=true&characterEncoding=UTF-8
    username :(数据库用户名)
    password :(数据库密码)
    driverClassName :com.mysql.jdbc.Driver

至此,jpa的配置完成。

4 创建实体

我们之前提到过,JPA将数据库中的表映射至Spring后端的一个类,这个类即实体(Entity)。这一节我们将进行实体的创建。

首先,在我们后端的model层建立一个新的class。很明显,我们需要告诉它,它是一个实体,它对应的是数据库中的某一张表。在JPA中,我们通过@Entity与@Table(name=”表名”)来实现这一功能。

我们也可以省略@Table注解,直接用@Entity(name=”表名”)来实现。

在实体类中,我们可以将数据库表中的字段映射为类中的私有变量。需要注意的是,变量名有固定的命名规则:若变量名与字段名不一致,需要在声明变量前引用@Column注解符来标识数据库字段,用法为@Column(name=”字段名”);若变量名与字段名一致,若字段名中无下划线,则变量名无需做改变,若字段名中带有下划线,则在变量名中需要去掉每个下划线,并将下划线后的首字母大写。此外,对于所有变量,我们需要为它们添加get与set方法。

4.1实体的唯一标识

在数据库中,对于一个数据表我们往往要设置一个或多个字段为主键(一般来说,扮演这一身份的是名为id的字符串或数),来唯一标识每一条数据。在JPA实体中,这一操作也是必要的。而完成这一操作的,是@Id注解符。在声明变量前引用@Id注解符,可将该变量设置为唯一标识。

但是在实际应用中,添加一条数据在很多情况下,无论是出于主观或客观,我们无法让用户来指定一个实体的Id。因此,我们需要一种方法,让程序能够自动为实体设置主键,即@GeneratedValue注解符。一般我们通过直接引用这个注解符而不指定参数(即使用默认参数AUTO),来让程序根据数据库类型自动选择一种最合适的主键产生策略,例如MySQL数据库的自增长策略。

4.2映射

实体经常会与其他实体产生关联,对于这一概念的理解,我们也可以参考数据库中的外键,事实上它们的功能也是相差无几的。我们往往将数据库表中的外键在实体类中设置为映射。映射使得我们能将一个实体作为另一个实体的属性。映射又分为许多种:一对一,一对多,多对一,多对多。在分别说明它们之前,我们需要了解主控方与被控方的概念。我们可以将主控方理解为:标识关联的一方,即外键所在的那张表。理解这一概念会使我们对于映射的学习更为简单。

一对一:例如一家商店有唯一的一名管理员,而管理员也唯一管理一家商店,那么它们就构成一对一的关联。虽然从实体结构上来说,一对一显得有些多余:我们完全可以将商店信息作为管理员的属性,或者将管理员信息作为商店的属性。但是从逻辑上讲,将他们视作两个实体显然更加合理。我们通过在主控方中引用@OneToOne与@JoinColumn(name="外键字段名")就可以将一个字段设置为与被控方的映射,这样在查询到这个实体的信息时,我们就可以通过这个映射,同时得到其他实体的信息(如查询商店信息我们会获得管理员信息),而如果我们在被控方同样想得到主控方的信息,那么只需用@OneToOne(mappedBy="主控方变量名")即可。

多对一与一对多:之所以把这两种放在一起,是因为显然它们是类似的。并且在多对一与一对多的映射中,多的一方往往是主控方,一的一方往往是被控方。因此在主控方,我们需要用到的是@ManyToOne,在被控方的则是@OneToMany。在声明@ManyToOne后,我们需要通过@JoinColumn(name=”对应字段名”)来标识外键。而在@OneToMany的情况中,参数name的值则应为主控方的对应字段名。并且我们知道@OneToMany标识的数据应为一个数组列表,因此我们常用到”List<主控方实体>”来声明它。

现在假想一种情况:若对同一个外键,我们在两个实体(假设为商店与其管理员)中我们同时建立了一对多与多对一映射。那么在从接口返回商店数据时,会在其中带有其管理员的数据,而在管理员数据中又会带有商店数据......成为一个死循环,耗光我们的内存而我们什么也没有得到。针对这种情况,我们需要使一个映射不表示在所返回数据中:在不需要返回数据的注解上加上@JsonIgnore。当然,这并不代表这部分数据我们永远得不到,我们仍然可以通过它的get方法取得它。

多对多:多对多的映射相比较复杂,因为显然单靠外键无法标识多对多的关系。在数据库中,为了表明两个表之间有多对多关系,我们往往需要用到一张中间表来记录这种关系。也正因为如此,在多对多关系中,并没有主控方与被控方的概念,而我们用到的也不再仅仅是@JoinColumn,而是@JoinTable,其用法为在其中一方A加上

@ManyToMany
@JoinTable(name = "中间表名",
joinColumns = { @JoinColumn(name = "该实体对应外键字段名") },
inverseJoinColumns = { @JoinColumn(name = "另一实体对应外键字段名") })

在另一方B,我们可以用类似的格式来声明,也可以仅仅在@ManyToMany中加上参数mappedBy=”A中的变量名”。与之前类似,我们仍然需要在其中一方加上@JsonIgnore,并且我们在两边声明变量时都要将其声明为List型变量。

4.3 继承

在JPA实体中的继承概念与我们在C++、Java中的类继承概念类似,而区别在于这里的继承有多种策略,对应不同的数据库结构,而实体书写本身区别并不是很大,只需在父类声明时用@Inheritance(strategy=策略参数)声明继承策略(单表继承除外),并且在子类声明时extends父类,并且在其中声明父类中没有的属性。下面简单介绍三种继承策略及其对应数据库结构:

Single Table:若没有出现@Inheritance,则默认采用单表继承。在这种继承关系中,父类、所有子类都储存在数据库的同一张表中,通过一个字段来区分每条数据的所属类。需要用到的是在父类实体上的

@DiscriminatorColumn(name="区分用字段名",
discriminatorType=DiscriminatorType.CHAR(字段数据类型))`

与在父类、每个子类都需要用到的

@DiscriminatorValue\(“区分用标识名”\)

这种策略的优点是查询效率高,因为只需涉及到一张表,但缺点是该张表的字段是父类与所有子类的全集,必然会造成大量空字段的产生,浪费数据库空间。

Joined-subclass(@Inheritance(strategy==InheritanceType.JOINED)):在这种继承中,父类与各子类分别通过一张表存储在数据库中,父类与子类之间通过建立外键产生关联,子类表中只储存父类中没有的属性字段。它的优缺点与单表继承恰恰相反,有效利用空间但查询效率低下。

Table-per-concrete-class(@Inheritance(strategy==InheritanceType.TABLE_PER_CLASS)):在这种继承关系中,继承只存在于逻辑上,在数据库结构中并没有直接体现。其表现为父类子类分表通过一张表存储,并且每个子类表都有父类表的所有字段,各表之间并没有直接关联。

在本例中,我们需要根据数据库中的两个数据表,创建两个实体类。在model层建立User.java类:

package cn.edu.tju.se.hero.domain;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToMany;
import com.fasterxml.jackson.annotation.JsonIgnore;
@Entity(name="users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String username;
    private String password;
    private String name;
    @JsonIgnore
    @OneToMany
    @JoinColumn(name ="ownerId")
    private List<Hero> myHeroes;
    public Long getId()
    {
        return id;
    }
    public void setId(Long id)
    {
        this.id = id;
    }
    public String getUsername()
    {
        return username;
    }
    public void setUsername(String username)
    {
        this.username = username;
    }
    public String getPassword()
    {
        return password;
    }
    public void setPassword(String password)
    {
        this.password = password;
    }
    public String getName()
    {
        return name;
    }
    public void setName(String name)
    {
        this.name = name;
    }
    public List<Hero> getMyHeroes()
    {
        return myHeroes;
    }
    public void setMyHeroes(List<Hero> myHeroes)
    {
        this.myHeroes = myHeroes;
    }
}

我们在查看一个用户信息时,不需要同时看到他创建的所有英雄,我们可以利用get方法实现这个功能,因此在声明myHeroes时,我们加上了@JsonIgnore注解。

接下来是Hero.java:

package cn.edu.tju.se.hero.domain;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;

@Entity(name = "heroes")
public class Hero {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String name;
    private String gender;
    @Column(name = "description")
    private String desc;
    @ManyToOne
    @JoinColumn(name = "ownerId")
    private User owner;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

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

    public String getGender() {
        return gender;
    }

    public void setGender(String gender) {
        this.gender = gender;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }

    public User getOwner() {
        return owner;
    }

    public void setOwner(User owner) {
        this.owner = owner;
    }
}

为了与前端保持一致,我们引用@Column注解将description映射属性重命名为了desc。

到此为止,JPA实体的相关基本知识已经介绍完成,下面我们将了解如何将这些实体投入使用。

5 通过JPA Repository仓库实现数据访问层(dao)对数据库增删改及简单的查询操作

之前我们已经提到过,通过Entity,我们将数据库中的表映射为实体类。与此类似,我们通过一些工具,也能在Spring框架中实现如在数据库中进行的增删改查等操作,Repository接口就是其中之一。它是Jpa的一个核心接口,只要继承这个接口,我们就可以直接引用一些已经预先定义好的函数。而它的使用也十分简单,只要在dao层自定义一个接口类,并使它继承JpaRepository<该接口类想要操作的实体类, Id数据类型>即可。关于其中部分默认函数的使用,如下表:

函数名 参数类型 返回类型 功能
save 实体 实体 添加、修改(由Id值是否已存在决定)
delete 实体Id 根据Id删除数据
findAll List<实体> 查询所有数据
fingAll List<Id> List<实体> 根据Id集对应查询数据集
findOne 实体Id 实体 根据Id查询实体

 5.1 findBy函数

除了我们在表格中提到的基本函数,JpaRepository还为我们提供了另一种利器:findBy函数,让我们可以通过特定属性来查找数据,具体用法为findBy加上在实体中定义的变量名(注意!首字母大写!),例如我需要根据name(String类型数据)查找User,那么只需定义List<User> findByName(String name);即可,并不需要实现它,因为JpaRepository会自动为我们实现。由于除Id以外的属性并没有唯一性约束,因此我们要把结果当作一个集合(即使你确信这个集合只会有一个元素),把返回结果设置为集合。此外,有时我们希望通过除等式以外的条件形式查询,如不等式、like子句等,JpaRepository也提供了支持,用法与之前介绍的findBy函数类似,格式如下图,在此就不一一详细介绍:

5.2 Query注解

上文所列出的方法当然不会涵盖所有查询情况,为了解决余下的查询问题,JpaRepository提供了@Query注解。在用法上与findBy函数没有太多区别,只是在函数声明前加上@Query(“自定义的查询语句即可”)。在语句中,参数的形式为?加上参数序号,如第一个参数为?1,第二个为?2,以此类推。特别注意,字符串参数不需要加引号。

Query注解查询功能上最大的不同点在于它能够查询单个字段值或length、count等关键字,只需把返回值改为Object[]的集合。如:

@Query("select u.id, LENGTH(u.firstname) as fn_len from User u where u.lastname =?1")
List<Object[]> findByAsArray(String lastname);

此外,Query注解使我们能对某条数据的部分属性进行修改。之前我们提到的save的修改只是简单的替换,可以将其理解为首先根据参数实体的Id在数据库中查询,若已存在则删除之前存在的该条数据,之后将参数实体添加至数据库中。我们可以不采用这种方法,直接通过Query注解修改部分属性,只是需要加入@Modifying,来说明这是一条修改用的语句。而通过这种方法进行修改的返回值也不再是实体,而是如在数据库中操作一样,返回一个标志成功与否的int值。用法举例如下:

@Modifying
@Query("update User u set u.firstname = ?1 where u.lastname = ?2")
int setFixedFirstnameFor(String firstname, String lastname);

但是,根据query语句格式我们知道,这种修改实体的方法更多地适用于定向修改,即修改预先设定好的字段,对于不定字段的修改,我们仍然使用JpaRepository已经实现的save方法。

现在我们着眼于我们自己的heroes实例,首先是用户的相关功能。从前端的service们知道,与用户相关的数据库相关功能有:查询所有用户、根据id查询用户、根据用户名(username)查询用户、添加用户、修改用户信息、删除用户。我们可以发现,JpaRepository自带的默认函数已经可以满足我们的大部分需求。实际实现如下:

package cn.edu.tju.se.hero.dao;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import cn.edu.tju.se.hero.domain.User;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    public List<User> findAll();// 查询所有用户
    public User findOne(Long id);// 根据id查询用户
    public User save(User user);// 添加、修改用户
    public void delete(Long id);// 删除用户
    public List<User> findByUsername(String username);// 根据用户名查询用户
}

接下来是Hero的相关业务,但是我们首先关注这些业务里的一个:根据ownerId查询他创建的所有英雄。如果按我们刚才所提到的实现方法,应该在HeroRepository中声明findByOwnerId,但是我们在创建User实体时,根据外键给User添加了一个属性:MyHeroes,这实际上就是他创建的英雄列表,我们只需要根据Id找到用户,再用get方法获取英雄列表即可。除此之外的Hero相关业务有:查询所有英雄、根据Id查询英雄、添加英雄、修改英雄信息、根据id删除英雄。实际实现如下:

package cn.edu.tju.se.hero.dao;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import cn.edu.tju.se.hero.domain.Hero;
@Repository
public interface HeroRepository extends JpaRepository<Hero, Long> {
    public List<Hero> findAll(); // 查询所有英雄
    public Hero findOne(Long id); // 根据id查询英雄
    public Hero save(Hero hero); // 添加、修改英雄
    public void delete(Long id); // 删除英雄
}

可以发现,JpaRepository已经为我们实现了所有逻辑,我们只需要创建接口并继承它就可以了。

JpaRepository固然非常强大,让我们能非常便利地在Spring框架中操纵数据库,但它有一个局限性:Query支持的SQL语句并不包括having、group by等更为复杂的查询条件,下一节我们会介绍如何实现这些更为复杂的操作。

6 实现复杂操作

与上一节不同,这一节我们需要自己来实现一些函数,因此我们需要为复杂操作重新建立一个类,而不能在接口中定义。此外,由于我们这次不再继承JpaRepository接口,所以我们需要在类声明前加入@Repository,使这个类在之后使用中能被自动编译。

要实现复杂的操作,我们同样需要一个帮手,这次不是一个接口,而是一个叫做EntityManagerFactory的类。我们首先要做的也不是继承,而是声明这个类的一个变量并设置它的set方法:

private EntityManagerFactory emf;
@PersistenceUnit(用于产生一个EntityManagerFactory对象,不用深究)
public void setEntityManagerFactory\(EntityManagerFactory emf\) {
    this.emf = emf;
}

之后,我们可以创建我们自己的函数,并通过EntityManagerFactory生成一个EntityManager来将一条Query语句映射至数据库,并通过它返回Query语句的查询结果集,实际使用举例如下,在商店销售记录中找出总消费大于某个值的客户id(Long型):

public List<Long> findGoodCustomer(Long mNumber)
{
    EntityManager em = emf.createEntityManager();
    Query query = em.createQuery("select user_id from sale_records group by user_id having sum(paid)>"+mNumber+"");
    List<Long> goodCustomers=query.getResultList();
    return goodCustomers;
}

实际使用时,只需换掉上例中的query语句与返回变量类型等即可。

results matching ""

    No results matching ""