主要内容:Spring Boot 2基础知识、异常处理、测试、CORS配置、Actuator监控、SpringFox Swagger集成;Angular基础知识、国际化、测试、NZ-ZORRO;Angular与Spring Boot、Spring Security、JWT集成;利用Swagger UI、Postman进行Rest API测试;Spring Boot、Angular部署、集成Sonar和Jenkins等。

本文参考了Rich Freedman先生的博客"Integrating Angular 2 with Spring Boot, JWT, and CORS",使用了部分代码(tour-of-heroes-jwt-full),博客地址请见文末参考文档。前端基于Angular官方样例Tour of Heroes。完整源码请从github下载:heroes-api, heroes-web

说明:最新代码使用Keycloak进行认证与授权,删除了原JWT、用户、权限、登录等相关代码,本文档代码保存在jwt-1.0.0 branch。

技术堆栈

  • Spring Boot 2.2.5.RELEASE
  • Spring Security
  • Spring Data
  • Spring Actuator
  • JWT
  • Springfox Swagger 2.9.2
  • lombok
  • MapStruct

  • Angular 9
  • NG-ZORRO

测试工具: Postman
代码质量检查: Sonar
CI: Jenkins
推荐IDE: IntelliJ IDEA、WebStorm/Visual Studio Code

Java代码中使用了lombok注解,IDE需安装lombok插件。

Spring Boot

创建Spring Boot App

创建Spring Boot项目最简易的方式是使用SPRING INITIALIZR
Angular 9集成Spring Boot 2详解
输入Group、Artifact,选择Dependency(Web、JPA、Security、Actuator、H2、PostgreSQL、Lombok)后,点击Generate,会自动下载zip包。

解压zip包,可以发现Initializr工具为我们创建了基本目录结构,配置了POM依赖,生成了SpringBootApplication类。继续之前,我们先启动程序,看一下最初的样子,进入根目录执行以下命令:

mvn spring-boot:run

访问 http://localhost:8080/ 。因为我们添加了Security依赖,所以会自动启用用户验证。

Angular 9集成Spring Boot 2详解

默认用户名为"user",密码显示在console log中。

接下来,编辑POM文件,添加java-jwt、springfox-swagger和MapStruct。我们选用了两个数据库H2、PostgreSQL,分别用于开发、测试环境,将其修改到两个profile dev和prod内。完成的POM文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://×××w.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.itrunner</groupId>
    <artifactId>heroes-api</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <name>heroes</name>
    <description>Demo project for Spring Boot</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.5.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <project.profile>dev</project.profile>
        <java.version>1.8</java.version>
        <jwt.version>3.10.0</jwt.version>
        <swagger.version>2.9.2</swagger.version>
        <mapstruct.version>1.3.1.Final</mapstruct.version>
    </properties>

    <profiles>
        <profile>
            <id>dev</id>
            <activation/>
            <properties>
                <project.profile>dev</project.profile>
            </properties>
            <dependencies>
                <dependency>
                    <groupId>com.h2database</groupId>
                    <artifactId>h2</artifactId>
                    <scope>runtime</scope>
                </dependency>
            </dependencies>
        </profile>

        <profile>
            <id>prod</id>
            <properties>
                <project.profile>prod</project.profile>
            </properties>
            <dependencies>
                <dependency>
                    <groupId>org.postgresql</groupId>
                    <artifactId>postgresql</artifactId>
                    <scope>runtime</scope>
                </dependency>
            </dependencies>
        </profile>
    </profiles>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>${jwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>${swagger.version}</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>${swagger.version}</version>
        </dependency>
        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${mapstruct.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>${mapstruct.version}</version>
                        </path>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                            <version>${lombok.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Application配置

默认,Spring Boot从下列位置加载 application.properties 或 application.yml 配置文件,优先级从高到低依次是:

  • 当前路径的/config目录
  • 当前路径
  • classpath 的 /config package
  • classpath根

为适应不同的环境,可配置profile-specific属性文件,命名方式为application-{profile}.properties。使用spring.profiles.active属性指定激活哪个或哪些profile,特定profile文件会覆盖application.properties的配置。

本文以YML为例,配置了dev和prod两个profile:
application.yml

spring:
  profiles:
    active: @project.profile@
  banner:
    charset: utf-8
    image:
      location: classpath:banner.jpg
    location: classpath:banner.txt
  messages:
    encoding: UTF-8
    basename: messages
  resources:
    add-mappings: true

management:
  server:
    port: 8090
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: always
info:
  app:
    name: heroes
    version: 1.0

springfox:
  documentation:
    swagger:
      v2:
        path: /api-docs

api:
  base-path: /api

security:
  ignore-paths: /api-docs,/swagger-resources/**,/swagger-ui.html**,/webjars/**
  auth-path: /api/auth
  cors:
    allowed-origins: "*"
    allowed-methods: GET,POST,DELETE,PUT,OPTIONS
    allowed-headers: Accept,Accept-Encoding,Accept-Language,Authorization,Connection,Content-Type,Host,Origin,Referer,User-Agent,X-Requested-With
  jwt:
    header: Authorization
    secret: mySecret
    expiration: 7200
    issuer: ITRunner

application-dev.yml

spring:
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
    show-sql: true
  datasource:
    platform: h2
    initialization-mode: always
server:
  port: 8080
security:
  cors:
    allowed-origins: "*"

application-prod.yml

spring:
  jpa:
    database-platform: org.hibernate.dialect.PostgreSQLDialect
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        default_schema: heroes
        format_sql: true
        jdbc:
          lob:
            non_contextual_creation: true
    show-sql: true
  datasource:
    platform: postgresql
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/postgres
    username: hero
    password: mypassword
    initialization-mode: never
server:
  port: 8000
security:
  cors:
    allowed-origins: itrunner.org

配置中包含了Banner、Swagger、CORS、JWT、Actuator等内容,其中active profile使用@project.profile@与pom属性建立了关联,这些配置将在后面的演示中用到。

可以使用注解@Value("${property}")注入属性值,如:

@Value("${api.base-path}")
private String apiPath;

这种方式可能会很冗长,且不利于复用,更好的方式是使用Java Bean来管理自定义配置,如下面的SecurityProperties:

package org.itrunner.heroes.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@Getter
@Setter
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
    private String[] ignorePaths;
    private String authPath;
    private Cors cors;
    private Jwt jwt;

    @Getter
    @Setter
    public static class Cors {
        private List<String> allowedOrigins;
        private List<String> allowedMethods;
        private List<String> allowedHeaders;
    }

    @Getter
    @Setter
    public static class Jwt {
        private String header;
        private String secret;
        private Long expiration;
        private String issuer;
    }
}

自定义Banner

  banner:
    charset: utf-8
    image:
      location: classpath:banner.jpg
    location: classpath:banner.txt
  resources:
    add-mappings: true

Spring Boot启动时会在控制台输出Banner信息,支持文本和图片。图片支持gif、jpg、png等格式,会转换成ASCII码输出。

Log配置

Spring Boot Log支持Java Util Logging、 Log4J2、Logback,默认使用Logback。

Log可以在application.properties或application.yml中配置,如:

logging.file=/var/log/heroes.log
logging.level.org.springframework.web=debug

推荐使用独立的配置文件,根据使用的日志系统,将加载下面的文件:

Logging System Customization
Logback logback-spring.xml or logback.xml
Log4j2 log4j2-spring.xml or log4j2.xml
JDK (Java Util Logging) logging.properties

推荐使用 -spring 命名。

logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProfile name="dev">
        <property name="LOG_FILE" value="heroes.log"/>
        <property name="LOG_FILE_MAX_HISTORY" value="2"/>
    </springProfile>
    <springProfile name="prod">
        <property name="LOG_FILE" value="/var/log/heroes.log"/>
        <property name="LOG_FILE_MAX_HISTORY" value="30"/>
    </springProfile>

    <include resource="org/springframework/boot/logging/logback/base.xml"/>

    <logger name="root" level="WARN"/>

    <springProfile name="dev">
        <logger name="root" level="INFO"/>
    </springProfile>
        <springProfile name="prod">
        <logger name="root" level="INFO"/>
    </springProfile>
</configuration>

国际化

在配置文件中,可以定义国际化资源文件位置、编码,默认分别为messages、UTF-8:

messages:
  encoding: UTF-8
  basename: messages

messages.properties

hero.notFound=Could not find hero with id {0}

Messages Component

package org.itrunner.heroes.util;

import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

@Component
public class Messages {
    @Resource
    private MessageSource messageSource;

    public String getMessage(String code) {
        return getMessage(code, null);
    }

    public String getMessage(String code, Object[] objects) {
        return messageSource.getMessage(code, objects, LocaleContextHolder.getLocale());
    }
}

初始化数据

开发时常使用嵌入式数据库,如H2,Spring Boot会自动配置,不需提供URL,仅需包括数据库依赖。为启动时初始化数据,定义initialization-mode为always。

spring:
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
    show-sql: true
  datasource:
    platform: h2
    initialization-mode: always

Spring Boot加载data.sql或data-${platform}.sql初始化数据。

data-h2.sql

INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'Dr Nice', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));
INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'Narco', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));
INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'Bombasto', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));
INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'Celeritas', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));
INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'Magneta', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));
INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'RubberMan', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));
INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'Dynama', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));
INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'Dr IQ', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));
INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'Magma', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));
INSERT INTO HERO(ID, HERO_NAME, CREATED_BY, CREATED_DATE) VALUES(NEXTVAL('HERO_SEQ'), 'Tornado', 'admin', to_date('01-07-2019', 'dd-MM-yyyy'));

INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED) VALUES (NEXTVAL('USER_SEQ'), 'admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin@itrunner.org', TRUE);
INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED) VALUES (NEXTVAL('USER_SEQ'), 'jason', '$2a$10$6m2VoqZAxa.HJNErs2lZyOFde92PzjPqc88WL2QXYT3IXqZmYMk8i', 'jason@itrunner.org', TRUE);
INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED) VALUES (NEXTVAL('USER_SEQ'), 'coco', '$2a$10$TBPPC.JbSjH1tuauM8yRauF2k09biw8mUDmYHMREbNSXPWzwY81Ju', 'coco@itrunner.org', FALSE);

INSERT INTO AUTHORITY (ID, AUTHORITY_NAME) VALUES (NEXTVAL('AUTHORITY_SEQ'), 'ROLE_USER');
INSERT INTO AUTHORITY (ID, AUTHORITY_NAME) VALUES (NEXTVAL('AUTHORITY_SEQ'), 'ROLE_ADMIN');

INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_ID) VALUES (1, 1);
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_ID) VALUES (1, 2);
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_ID) VALUES (2, 1);
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_ID) VALUES (3, 1);

说明:

  1. 不同数据库语法不同时,需分别创建初始化文件,命名格式data-${platform}.sql,比如data-h2.sql、data-postgresql.sql
  2. 密码与用户名相同

Domain

"Tour of Heroes"中使用了angular-in-memory-web-api,这里用H2数据库取代,增加Hero Domain。
Hero Domain

package org.itrunner.heroes.domain;

import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.Date;

@Entity
@Data
@NoArgsConstructor
@Table(name = "HERO", uniqueConstraints = {@UniqueConstraint(name = "UK_HERO_NAME", columnNames = {"HERO_NAME"})})
public class Hero {
    @Id
    @Column(name = "ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "HERO_SEQ")
    @SequenceGenerator(name = "HERO_SEQ", sequenceName = "HERO_SEQ", allocationSize = 1)
    private Long id;

    @Column(name = "HERO_NAME", length = 30, nullable = false)
    private String name;

    @Column(name = "CREATE_BY", length = 50, updatable = false, nullable = false)
    private String createBy;

    @Column(name = "CREATE_TIME", updatable = false, nullable = false)
    @Temporal(TemporalType.TIMESTAMP)
    private Date createTime;

    @Column(name = "LAST_MODIFIED_BY", length = 50)
    private String lastModifiedBy;

    @Column(name = "LAST_MODIFIED_TIME")
    @Temporal(TemporalType.TIMESTAMP)
    private Date lastModifiedTime;

    public Hero(Long id, String name) {
        this.id = id;
        this.name = name;
    }
}

我们的例子将包含用户验证功能,新增User、Authority Domain:
User Domain

package org.itrunner.heroes.domain;

import lombok.Data;

import javax.persistence.*;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.List;

@Entity
@Data
@Table(name = "USERS", uniqueConstraints = {
        @UniqueConstraint(name = "UK_USERS_USERNAME", columnNames = {"USERNAME"}),
        @UniqueConstraint(name = "UK_USERS_EMAIL", columnNames = {"EMAIL"})})
public class User {
    @Id
    @Column(name = "ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "USER_SEQ")
    @SequenceGenerator(name = "USER_SEQ", sequenceName = "USER_SEQ", allocationSize = 1)
    private Long id;

    @Column(name = "USERNAME", length = 50, nullable = false)
    @NotNull
    @Size(min = 4, max = 50)
    private String username;

    @Column(name = "PASSWORD", length = 100, nullable = false)
    @NotNull
    @Size(min = 4, max = 100)
    private String password;

    @Column(name = "EMAIL", length = 50, nullable = false)
    @NotNull
    @Size(min = 4, max = 50)
    private String email;

    @Column(name = "ENABLED")
    @NotNull
    private Boolean enabled;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "USER_AUTHORITY", joinColumns = {@JoinColumn(name = "USER_ID", referencedColumnName = "ID", foreignKey = @ForeignKey(name = "FK_USER_ID"))},
            inverseJoinColumns = {@JoinColumn(name = "AUTHORITY_ID", referencedColumnName = "ID", foreignKey = @ForeignKey(name = "FK_AUTHORITY_ID"))})
    private List<Authority> authorities;
}

Authority Domain

package org.itrunner.heroes.domain;

import lombok.Data;

import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.util.List;

@Entity
@Data
@Table(name = "AUTHORITY")
public class Authority {
    @Id
    @Column(name = "ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "AUTHORITY_SEQ")
    @SequenceGenerator(name = "AUTHORITY_SEQ", sequenceName = "AUTHORITY_SEQ", allocationSize = 1)
    private Long id;

    @Column(name = "AUTHORITY_NAME", length = 50, nullable = false)
    @NotNull
    @Enumerated(EnumType.STRING)
    private AuthorityName name;

    @ManyToMany(mappedBy = "authorities", fetch = FetchType.LAZY)
    private List<User> users;
}

AuthorityName

package org.itrunner.heroes.domain;

public enum AuthorityName {
    ROLE_USER, ROLE_ADMIN
}

DTO与MapStruct

DTO用于展示层与服务层之间的数据传输。

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@JsonPropertyOrder({"id", "name"})
public class HeroDto {
    private Long id;
    @NotNull
    @Size(min = 3, max = 30)
    private String name;
}

MapStruct是对象映射转换工具,在编译时自动生成mapping code,相对其它工具更高效。

package org.itrunner.heroes.mapper;

import org.itrunner.heroes.domain.Hero;
import org.itrunner.heroes.dto.HeroDto;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
import org.springframework.data.domain.Page;

import java.util.List;

@Mapper
public interface HeroMapper {
    HeroMapper MAPPER = Mappers.getMapper(HeroMapper.class);

    HeroDto toHeroDto(Hero hero);

    Hero toHero(HeroDto heroDto);

    List<HeroDto> toHeroDtos(List<Hero> heroes);

    default Page<HeroDto> toHeroDtoPage(Page<Hero> heroPage) {
        return heroPage.map(this::toHeroDto);
    }
}

Repository

Spring Data JpaRepository提供了常用的CRUD等方法,定义repository接口时常继承它。

Spring Data支持从方法名推导SQL,如:

UserRepository

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

Supported keywords inside method names

Keyword Sample JPQL snippet
And findByLastnameAndFirstname … where x.lastname = ?1 and x.firstname = ?2
Or findByLastnameOrFirstname … where x.lastname = ?1 or x.firstname = ?2
Is, Equals findByFirstname, findByFirstnameIs, findByFirstnameEquals … where x.firstname = ?1
Between findByStartDateBetween … where x.startDate between ?1 and ?2
LessThan findByAgeLessThan … where x.age < ?1
LessThanEqual findByAgeLessThanEqual … where x.age <= ?1
GreaterThan findByAgeGreaterThan … where x.age > ?1
GreaterThanEqual findByAgeGreaterThanEqual … where x.age >= ?1
After findByStartDateAfter … where x.startDate > ?1
Before findByStartDateBefore … where x.startDate < ?1
IsNull, Null findByAge(Is)Null … where x.age is null
IsNotNull, NotNull findByAge(Is)NotNull … where x.age not null
Like findByFirstnameLike … where x.firstname like ?1
NotLike findByFirstnameNotLike … where x.firstname not like ?1
StartingWith findByFirstnameStartingWith … where x.firstname like ?1 (parameter bound with appended %)
EndingWith findByFirstnameEndingWith … where x.firstname like ?1 (parameter bound with prepended %)
Containing findByFirstnameContaining … where x.firstname like ?1 (parameter bound wrapped in %)
OrderBy findByAgeOrderByLastnameDesc … where x.age = ?1 order by x.lastname desc
Not findByLastnameNot … where x.lastname <> ?1
In findByAgeIn(Collection\<Age> ages) … where x.age in ?1
NotIn findByAgeNotIn(Collection\<Age> ages) … where x.age not in ?1
True findByActiveTrue() … where x.active = true
False findByActiveFalse() … where x.active = false
IgnoreCase findByFirstnameIgnoreCase … where UPPER(x.firstame) = UPPER(?1)

若查询参数多,方法名会很长,可读性差,不建议使用方法名推导方式。

更灵活的方式是使用@Query注解定义SQL,如:
HeroRepository

public interface HeroRepository extends JpaRepository<Hero, Long> {

    @Query("select h from Hero h where lower(h.name) like CONCAT('%', lower(:name), '%')")
    List<Hero> findByName(@Param("name") String name);

}

也可以使用参数序号:

public interface HeroRepository extends JpaRepository<Hero, Long> {

    @Query("select h from Hero h where lower(h.name) like CONCAT('%', lower(?1), '%')")
    List<Hero> findByName(String name);

}

更新操作需添加@Modifying注解:

@Modifying
@Query("update User u set u.username = ?1 where u.email = ?2")
int updateUsername(String username, String email);

默认,repository实例的 CRUD 方法是事务性的。读操作的事务属性readOnly设为true,可以查看SimpleJpaRepository源码。

Service

演示Service的使用。使用多个repository时,可以在service层配置事务。

HeroService

package org.itrunner.heroes.service;

import org.itrunner.heroes.domain.Hero;
import org.itrunner.heroes.dto.HeroDto;
import org.itrunner.heroes.exception.HeroNotFoundException;
import org.itrunner.heroes.repository.HeroRepository;
import org.itrunner.heroes.util.Messages;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static org.itrunner.heroes.mapper.HeroMapper.MAPPER;

@Service
@Transactional(readOnly = true)
public class HeroService {
    private final HeroRepository repository;
    private final Messages messages;

    @Autowired
    public HeroService(HeroRepository repository, Messages messages) {
        this.repository = repository;
        this.messages = messages;
    }

    public HeroDto getHeroById(Long id) {
        Hero hero = repository.findById(id).orElseThrow(() -> new HeroNotFoundException(messages.getMessage("hero.notFound", new Object[]{id})));
        return MAPPER.toHeroDto(hero);
    }

    public Page<HeroDto> getAllHeroes(Pageable pageable) {
        Page<Hero> heroes = repository.findAll(pageable);
        return MAPPER.toHeroDtoPage(heroes);
    }

    public List<HeroDto> findHeroesByName(String name) {
        List<Hero> heroes = repository.findByName(name);
        return MAPPER.toHeroDtos(heroes);
    }

    @Transactional
    public HeroDto saveHero(HeroDto heroDto) {
        Hero hero = MAPPER.toHero(heroDto);
        hero = repository.save(hero);
        return MAPPER.toHeroDto(hero);
    }

    @Transactional
    public void deleteHero(Long id) {
        repository.deleteById(id);
    }
}

Rest Controller

Spring使用@RestController注解创建RESTful web service,@RestController是@Controller 和 @ResponseBody注解的组合,它的每个方法都继承type-level @ResponseBody。Spring HttpMessageConverter转换response对象为JSON,不需要手工转换。spring-boot-starter-web -> spring-boot-starter-json默认使用了Jackson,因此自动选择使用MappingJackson2HttpMessageConverter来转换对象。

HeroController
演示了如何定义REST GET、POST、PUT、DELETE方法,如何定义分页方法。

package org.itrunner.heroes.controller;

import org.itrunner.heroes.dto.HeroDto;
import org.itrunner.heroes.service.HeroService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;

@RestController
@RequestMapping(value = "/api/heroes", produces = MediaType.APPLICATION_JSON_VALUE)
public class HeroController {
    private final HeroService service;

    @Autowired
    public HeroController(HeroService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public HeroDto getHeroById(@PathVariable("id") Long id) {
        return service.getHeroById(id);
    }

    @GetMapping
    public Page<HeroDto> getHeroes(@SortDefault.SortDefaults({@SortDefault(sort = "name", direction = Sort.Direction.ASC)}) Pageable pageable) {
        return service.getAllHeroes(pageable);
    }

    @GetMapping("/")
    public List<HeroDto> searchHeroes(@RequestParam("name") String name) {
        return service.findHeroesByName(name);
    }

    @PostMapping
    public HeroDto addHero(@Valid @RequestBody HeroDto hero) {
        return service.saveHero(hero);
    }

    @PutMapping
    public HeroDto updateHero(@Valid @RequestBody HeroDto hero) {
        return service.saveHero(hero);
    }

    @DeleteMapping("/{id}")
    public void deleteHero(@PathVariable("id") Long id) {
        service.deleteHero(id);
    }
}

Bean Validation

在REST方法中使用@RequestBody注解组合 javax.validation.Valid 或 Spring @Validated注解,会启用Bean Validation,如:

@PostMapping
public HeroDto addHero(@Valid @RequestBody HeroDto hero) {
    return service.saveHero(hero);
}

@PutMapping
public HeroDto updateHero(@Valid @RequestBody HeroDto hero) {
    return service.saveHero(hero);
}

默认,验证错误抛出MethodArgumentNotValidException,将转换成 400(BAD_REQUEST) 响应。但输出的信息不友好,下一节我们重写了ResponseEntityExceptionHandler的handleMethodArgumentNotValid方法,如保存或更新Hero时未输入name,则会显示如下信息:
Angular 9集成Spring Boot 2详解

异常处理

HeroController中没有处理异常的代码,如数据操作失败会返回什么结果呢?例如,添加了重复的记录,会显示如下信息:
Angular 9集成Spring Boot 2详解
Spring Framework提供默认的HandlerExceptionResolver:DefaultHandlerExceptionResolver、ExceptionHandlerExceptionResolver、ResponseStatusExceptionResolver等,可查看全局异常处理方法DispatcherServlet.processHandlerException()了解处理过程。最终,BasicErrorController的error(HttpServletRequest request)方法返回ResponseEntity:

public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
    Map<String, Object> body = getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
    HttpStatus status = getStatus(request);
    return new ResponseEntity<>(body, status);
}

显然返回500错误是不合适的,错误信息也需要修改,可使用@ExceptionHandler自定义异常处理机制,如下:

@ExceptionHandler(DataAccessException.class)
public ResponseEntity<Map<String, Object>> handleDataAccessException(DataAccessException exception) {
    LOG.error(exception.getMessage(), exception);
    Map<String, Object> body = new HashMap<>();
    body.put("message", exception.getMessage());
    return ResponseEntity.badRequest().body(body);
}

如@ExceptionHandler中未指定参数将会处理方法参数列表中的所有异常。

对于自定义的异常,可使用@ResponseStatus注解定义code和reason,未定义reason时message将显示异常信息。

package org.itrunner.heroes.exception;

import org.springframework.web.bind.annotation.ResponseStatus;

import static org.springframework.http.HttpStatus.NOT_FOUND;

@ResponseStatus(code = NOT_FOUND)
public class HeroNotFoundException extends RuntimeException {
    public HeroNotFoundException(String message) {
        super(message);
    }
}

Angular 9集成Spring Boot 2详解

更通用的方法是使用全局异常处理机制,创建ResponseEntityExceptionHandler的子类,添加@ControllerAdvice注解,覆盖必要的方法,如下:
RestResponseEntityExceptionHandler

package org.itrunner.heroes.exception;

import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import javax.persistence.EntityNotFoundException;
import java.util.List;

import static org.springframework.core.NestedExceptionUtils.getMostSpecificCause;

@ControllerAdvice(basePackages = {"org.itrunner.heroes.controller"})
public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({
            EntityNotFoundException.class,
            DuplicateKeyException.class,
            DataIntegrityViolationException.class,
            DataAccessException.class,
            Exception.class
    })
    public final ResponseEntity<Object> handleAllException(Exception e) {
        logger.error(e.getMessage(), e);

        if (e instanceof EntityNotFoundException) {
            return notFound(getExceptionName(e), e.getMessage());
        }

        if (e instanceof DuplicateKeyException) {
            return badRequest(getExceptionName(e), e.getMessage());
        }

        if (e instanceof DataIntegrityViolationException) {
            return badRequest(getExceptionName(e), getMostSpecificMessage(e));
        }

        if (e instanceof DataAccessException) {
            return badRequest(getExceptionName(e), getMostSpecificMessage(e));
        }

        return badRequest(getExceptionName(e), getMostSpecificMessage(e));
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
        StringBuilder messages = new StringBuilder();
        List<ObjectError> globalErrors = ex.getBindingResult().getGlobalErrors();
        globalErrors.forEach(error -> messages.append(error.getDefaultMessage()).append(";"));
        List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
        fieldErrors.forEach(error -> messages.append(error.getField()).append(" ").append(error.getDefaultMessage()).append(";"));
        ErrorMessage errorMessage = new ErrorMessage(getExceptionName(ex), messages.toString());
        return badRequest(errorMessage);
    }

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
        return new ResponseEntity<>(new ErrorMessage(getExceptionName(ex), ex.getMessage()), headers, status);
    }

    private ResponseEntity<Object> badRequest(ErrorMessage errorMessage) {
        return new ResponseEntity<>(errorMessage, HttpStatus.BAD_REQUEST);
    }

    private ResponseEntity<Object> badRequest(String error, String message) {
        return badRequest(new ErrorMessage(error, message));
    }

    private ResponseEntity<Object> notFound(String error, String message) {
        return new ResponseEntity(new ErrorMessage(error, message), HttpStatus.NOT_FOUND);
    }

    private String getExceptionName(Exception e) {
        return e.getClass().getSimpleName();
    }

    private String getMostSpecificMessage(Exception e) {
        return getMostSpecificCause(e).getMessage();
    }
}

ErrorMessage

package org.itrunner.heroes.exception;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Getter;

import java.util.Date;

@Getter
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class ErrorMessage {
    private Date timestamp;
    private String error;
    private String message;

    public ErrorMessage() {
        this.timestamp = new Date();
    }

    public ErrorMessage(String error, String message) {
        this();
        this.error = error;
        this.message = message;
    }
}

再次测试,输出结果如下:
Angular 9集成Spring Boot 2详解
说明:

  1. ResponseEntityExceptionHandler对内部Spring MVC异常进行了处理,但未将错误信息写入Response Body中,需覆盖handleExceptionInternal方法自定义处理方式。另外,为了返回详细的校验错误信息,覆盖了handleMethodArgumentNotValid方法。
  2. @RestController内定义的ExceptionHandler优先级更高。
  3. 此处仅为示例,对错误信息应进行适当的处理,信息应清晰,不包含敏感数据

Spring Security和CORS

Spring Security 是一个功能强大且高度可定制的身份验证和访问控制框架。如配置了Spring Security依赖,默认则启用Security。自定义WebSecurityConfigurerAdapter可设置访问规则。

出于安全原因,浏览器限制从脚本内发起跨源(域或端口)的HTTP请求,Web应用程序只能从加载应用程序的同一个域请求HTTP资源。CORS(Cross-Origin Resource Sharing) 是W3C的一个规范,大多数浏览器都已实现,允许Web应用服务器控制跨域访问,而不是使用一些安全性较低和功能较弱的方法,如 IFRAME 或 JSONP。

CORS
For simple cases like this GET, when your Angular code makes an XMLHttpRequest that the browser determines is cross-origin, the browser looks for an HTTP header named Access-Control-Allow-Origin in the response. If the response header exists, and the value matches the origin domain, then the browser passes the response back to the calling javascript. If the response header does not exist, or it's value does not match the origin domain, then the browser does not pass the response back to the calling code, and you get the error.

For more complex cases, like PUTs, DELETEs, or any request involving credentials (which will eventually be all of our requests), the process is slightly more involved. The browser will send an OPTION request to find out what methods are allowed. If the requested method is allowed, then the browser will make the actual request, again passing or blocking the response depending on the Access-Control-Allow-Origin header in the response.

Spring Web支持CORS,只需配置一些参数。为快速测试我们的Application,先不进行用户验证,禁用CSRF。

package org.itrunner.heroes.config;

import org.itrunner.heroes.config.SecurityProperties.Cors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity
@SuppressWarnings("SpringJavaAutowiringInspection")
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SecurityProperties securityProperties;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable().authorizeRequests().anyRequest().permitAll();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        Cors cors = securityProperties.getCors();
        configuration.setAllowedOrigins(cors.getAllowedOrigins());
        configuration.setAllowedMethods(cors.getAllowedMethods());
        configuration.setAllowedHeaders(cors.getAllowedHeaders());
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

}

说明:前后台域名不一致时,如未集成CORS,前端Angular访问会报如下错误:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8080/api/heroes. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing)

启动Spring Boot

在IDE中选中dev profile,启动HeroesApplication。

package org.itrunner.heroes;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication
@EnableJpaRepositories(basePackages = {"org.itrunner.heroes.repository"})
@EntityScan(basePackages = {"org.itrunner.heroes.domain"})
public class HeroesApplication {

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

单元测试

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>

spring-boot-starter-test导入了Spring Boot test模块、JUnit Jupiter、AssertJ、Hamcrest、Mockito等许多有用的library。

组合使用JUnit Jupiter和Mockito进行单元测试,示例:

package org.itrunner.heroes.service;

import org.itrunner.heroes.domain.Hero;
import org.itrunner.heroes.dto.HeroDto;
import org.itrunner.heroes.repository.HeroRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

class HeroServiceTest {
    @Mock
    private HeroRepository heroRepository;

    @InjectMocks
    private HeroService heroService;

    @BeforeEach
    void setup() {
        MockitoAnnotations.initMocks(this);

        List<Hero> heroes = new ArrayList<>();
        heroes.add(new Hero(1L, "Rogue"));
        heroes.add(new Hero(2L, "Jason"));

        given(heroRepository.findById(1L)).willReturn(Optional.of(heroes.get(0)));
        given(heroRepository.findAll(PageRequest.of(0, 10))).willReturn(Page.empty());
        given(heroRepository.findByName("o")).willReturn(heroes);
    }

    @Test
    void getHeroById() {
        HeroDto hero = heroService.getHeroById(1L);
        assertThat(hero.getName()).isEqualTo("Rogue");
    }

    @Test
    void getAllHeroes() {
        Page<HeroDto> heroes = heroService.getAllHeroes(PageRequest.of(0, 10));
        assertThat(heroes.getTotalElements()).isEqualTo(0);
    }

    @Test
    void findHeroesByName() {
        List<HeroDto> heroes = heroService.findHeroesByName("o");
        assertThat(heroes.size()).isEqualTo(2);
    }
}

Actuator监控

Actuator用来监控和管理应用,Spring Boot提供许多内建endpoint。

下面表格列出了支持的endpoint。

与技术无关的Endpoint

ID Description
auditevents Exposes audit events information for the current application. Requires an AuditEventRepository bean.
beans Displays a complete list of all the Spring beans in your application.
caches Exposes available caches.
conditions Shows the conditions that were evaluated on configuration and auto-configuration classes and the reasons why they did or did not match.
configprops Displays a collated list of all @ConfigurationProperties.
env Exposes properties from Spring’s ConfigurableEnvironment.
flyway Shows any Flyway database migrations that have been applied. Requires one or more Flyway beans.
health Shows application health information.
httptrace Displays HTTP trace information (by default, the last 100 HTTP request-response exchanges). Requires an HttpTraceRepository bean.
info Displays arbitrary application info.
integrationgraph Shows the Spring Integration graph. Requires a dependency on spring-integration-core.
loggers Shows and modifies the configuration of loggers in the application.
liquibase Shows any Liquibase database migrations that have been applied. Requires one or more Liquibase beans.
metrics Shows ‘metrics’ information for the current application.
mappings Displays a collated list of all @RequestMapping paths.
scheduledtasks Displays the scheduled tasks in your application.
sessions Allows retrieval and deletion of user sessions from a Spring Session-backed session store. Requires a Servlet-based web application using Spring Session.
shutdown Lets the application be gracefully shutdown. Disabled by default.
threaddump Performs a thread dump.

Web Application Endpoint

ID Description
heapdump Returns an hprof heap dump file.
jolokia Exposes JMX beans over HTTP (when Jolokia is on the classpath, not available for WebFlux). Requires a dependency on jolokia-core.
logfile Returns the contents of the logfile (if logging.file.name or logging.file.path properties have been set). Supports the use of the HTTP Range header to retrieve part of the log file’s content.
prometheus Exposes metrics in a format that can be scraped by a Prometheus server. Requires a dependency on micrometer-registry-prometheus.

要启用Actuator,需增加spring-boot-starter-actuator依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

默认,除shutdown外所有endpoint都是启用的,如下配置启用shutdown:

management.endpoint.shutdown.enabled=true

可以禁用所有的endpoint,只启用需要的:

management.endpoints.enabled-by-default=false
management.endpoint.info.enabled=true

默认Exposure配置
默认暴露所有JMX endpoint,Web只可访问info和health endpoint。

Property Default
management.endpoints.jmx.exposure.exclude
management.endpoints.jmx.exposure.include *
management.endpoints.web.exposure.exclude
management.endpoints.web.exposure.include info, health

如未配置management.server.port,则actuator访问端口与application相同,为了安全一般定义不同的端口并设定address。默认base-path为/actuator(即访问endpoint时的前置路径)。可以自定义app信息,info下所有的属性都会显示在info endpoint中:

management:
  server:
    port: 8090
    address: 127.0.0.1
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: env,health,info,mappings
  endpoint:
    health:
      show-details: always
      show-components: always
info:
  app:
    name: heroes
    version: 1.0
    encoding: @project.build.sourceEncoding@
    java:
     source: @java.version@
     target: @java.version@

默认,访问Actuator需要用户验证,可以在WebSecurityConfig的configure(HttpSecurity http)方法中增加配置:

.authorizeRequests()
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()

访问Actuator:
Health Endpoint:http://localhost:8090/actuator/health
Info Endpoint: http://localhost:8090/actuator/info

Sonar集成

增加如下plugin配置:

<plugins>
    <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>sonar-maven-plugin</artifactId>
        <version>3.7.0.1746</version>
    </plugin>
    <plugin>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>0.8.5</version>
        <configuration>
            <destFile>${project.build.directory}/jacoco.exec</destFile>
            <dataFile>${project.build.directory}/jacoco.exec</dataFile>
        </configuration>
        <executions>
            <execution>
                <goals>
                    <goal>prepare-agent</goal>
                </goals>
            </execution>
        </executions>
    </plugin>
</plugins>

先调用jacoco-maven-plugin生成测试报告,然后调用sonar-maven-plugin生成Sonar报告,命令如下:

mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent test
mvn sonar:sonar

CI集成

Jenkins支持pipeline后大大简化了任务配置,将定义pipeline的Jenkinsfile文件保存在SCM中,项目成员更新代码即可修改CI流程,而不必再登录到Jenkins。以下是简单的Jenkinsfile示例:

node {
    checkout scm
    stage('Test') {
        bat 'mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent test'
    }
    stage('Sonar') {
        bat 'mvn sonar:sonar'
    }
    stage('Package') {
        bat 'mvn clean package -Dmaven.test.skip=true'
    }
}

Jenkinsfile文件一般放在项目根目录下(文件命名为Jenkinsfile)。Pipeline支持声明式和Groovy两种语法,声明式更简单,Groovy更灵活。例子使用的是Groovy语法,适用于windows环境(linux将bat改为sh),详细的介绍请查看Pipeline Syntax

创建Pipeline任务

  • 新建任务,选择Pipeline(流水线)类型
  • Pipeline Definition选择“Pipeline script from SCM”,配置SCM,填写Pipeline路径

集成Spring Security与JWT

JWT

JSON Web Token (JWT) 是一个开放标准 (RFC 7519) ,定义了一种紧凑、自包含、安全地传输JSON 对象信息的方式。此信息经数字签名,因此是可验证、可信的。JWT可以使用密钥或公钥/私钥对签名。

JWT由三部分Base64编码的字符串组成,各部分以点分隔:

  • Header 包含token类型与算法
  • Payload 包含三种Claim: registered、public、private。
    registered 包含一些预定义的claim:iss (issuer)、 sub (subject)、aud (audience)、exp (expiration time)、nbf(Not Before)、iat (Issued At)、jti(JWT ID)
    public 可以随意定义,但为避免冲突,应使用IANA JSON Web Token Registry 中定义的名称,或将其定义为包含namespace的URI以防命名冲突。
    private 非registered或public claim,各方之间共享信息而创建的定制声明。
  • Signature

比如,JWT:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImlzcyI6IklUUnVubmVyIiwiZXhwIjoxNTgzNTg4NzMxLCJpYXQiOjE1ODM1ODE1MzEsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iLCJST0xFX1VTRVIiXX0.hs9TknHEX58N1A2LRnQUhADhsvcmJMPbkDr7LIDUEh8

解码后,前两部分内容分别是:

{"typ":"JWT","alg":"HS256"}

{"sub":"admin","iss":"ITRunner","exp":1583588731,"iat":1583581531,"authorities":["ROLE_ADMIN","ROLE_USER"]}

JWT用于用户验证时,Payload至少要包含User ID和expiration time。

验证流程
Angular 9集成Spring Boot 2详解
身份验证时,用户使用其凭据成功登录后,将返回 JSON Web Token。
用户访问受保护的资源时,发送JWT,通常以Bearer模式在Authorization header中发送:

Authorization: Bearer <token>

JWT验证机制是无状态的,Server并不保存用户状态。JWT包含了必要的信息,减少了数据库查询。

创建和验证JWT Token

我们使用了Auth0 Open Source API - java-jwt

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.10.0</version>
</dependency>

JWT支持HMAC、RSA、ECDSA算法。其中HMAC使用密钥;RSA、ECDSA使用key pairs或KeyProvider,私钥用于签名,公钥用于验证。使用KeyProvider时可以在运行时更改私钥或公钥。

示例

  • 使用HS256创建Token
Algorithm algorithm = Algorithm.HMAC256("secret");
String token = JWT.create().withIssuer("auth0").sign(algorithm);
  • 使用RS256创建Token
RSAPublicKey publicKey = //Get the key instance
RSAPrivateKey privateKey = //Get the key instance
Algorithm algorithm = Algorithm.RSA256(publicKey, privateKey);
String token = JWT.create().withIssuer("auth0").sign(algorithm);
  • 使用HS256验证Token
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.eyJpc3MiOiJhdXRoMCJ9.AbIJTDMFc7yUa5MhvcP03nJPyCPzZtQcGEp-zWfOkEE";
Algorithm algorithm = Algorithm.HMAC256("secret");
JWTVerifier verifier = JWT.require(algorithm).withIssuer("auth0").build(); 
DecodedJWT jwt = verifier.verify(token);
  • 使用RS256验证Token
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.eyJpc3MiOiJhdXRoMCJ9.AbIJTDMFc7yUa5MhvcP03nJPyCPzZtQcGEp-zWfOkEE";
RSAPublicKey publicKey = //Get the key instance
RSAPrivateKey privateKey = //Get the key instance
Algorithm algorithm = Algorithm.RSA256(publicKey, privateKey);
JWTVerifier verifier = JWT.require(algorithm).withIssuer("auth0").build(); 
DecodedJWT jwt = verifier.verify(token);

JwtUtils
示例使用了HMAC算法来生成和验证token,token中保存了用户名和Authority(验证权限时不必再访问数据库),代码如下:

package org.itrunner.heroes.util;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.itrunner.heroes.config.SecurityProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
@Slf4j
public class JwtUtils {
    private static final String CLAIM_AUTHORITIES = "authorities";

    @Autowired
    private SecurityProperties securityProperties;

    public String generate(UserDetails user) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(securityProperties.getJwt().getSecret());
            return JWT.create()
                    .withIssuer(securityProperties.getJwt().getIssuer())
                    .withIssuedAt(new Date())
                    .withExpiresAt(new Date(System.currentTimeMillis() + securityProperties.getJwt().getExpiration() * 1000))
                    .withSubject(user.getUsername())
                    .withArrayClaim(CLAIM_AUTHORITIES, AuthorityUtils.getAuthorities(user))
                    .sign(algorithm);
        } catch (IllegalArgumentException e) {
            return null;
        }
    }

    public UserDetails verify(String token) {
        if (token == null) {
            throw new JWTVerificationException("token should not be null");
        }

        Algorithm algorithm = Algorithm.HMAC256(securityProperties.getJwt().getSecret());
        JWTVerifier verifier = JWT.require(algorithm).withIssuer(securityProperties.getJwt().getIssuer()).build();
        DecodedJWT jwt = verifier.verify(token);
        return new User(jwt.getSubject(), "N/A", AuthorityUtils.createGrantedAuthorities(jwt.getClaim(CLAIM_AUTHORITIES).asArray(String.class)));
    }
}

AuthorityUtil(UserDetails Authority转换工具类)

package org.itrunner.heroes.util;

import org.itrunner.heroes.domain.Authority;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public final class AuthorityUtils {

    private AuthorityUtils() {
    }

    public static List<GrantedAuthority> createGrantedAuthorities(List<Authority> authorities) {
        return authorities.stream().map(authority -> new SimpleGrantedAuthority(authority.getName().name())).collect(Collectors.toList());
    }

    public static List<GrantedAuthority> createGrantedAuthorities(String... authorities) {
        return Stream.of(authorities).map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }

    public static String[] getAuthorities(UserDetails user) {
        return user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toArray(String[]::new);
    }
}

UserDetailsService

实现Spring Security的UserDetailsService,从数据库获取用户数据,其中包括用户名、密码、权限。UserDetailsService用于用户名/密码验证,将在后面的WebSecurityConfig中使用。

package org.itrunner.heroes.service;

import org.itrunner.heroes.domain.User;
import org.itrunner.heroes.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import static org.itrunner.heroes.util.AuthorityUtil.createGrantedAuthorities;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserRepository userRepository;

    @Autowired
    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException(String.format("No user found with username '%s'.", username)));
        return create(user);
    }

    private static org.springframework.security.core.userdetails.User create(User user) {
        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), createGrantedAuthorities(user.getAuthorities()));
    }
}

JWT验证Filter

从Request Header中读取Bearer Token并验证,如验证成功则将用户信息保存在SecurityContext中,用户即可访问受限资源。每次请求结束后,SecurityContext会自动清空。

AuthenticationTokenFilter

package org.itrunner.heroes.config;

import lombok.extern.slf4j.Slf4j;
import org.itrunner.heroes.util.JwtUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
public class AuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private JwtUtils jwtUtils;

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        String authToken = request.getHeader(securityProperties.getJwt().getHeader());

        if (authToken != null && authToken.startsWith("Bearer ")) {
            authToken = authToken.substring(7);

            try {
                UserDetails user = jwtUtils.verify(authToken);

                if (SecurityContextHolder.getContext().getAuthentication() == null) {
                    logger.info("checking authentication for user " + user.getUsername());

                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user.getUsername(), "N/A", user.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (Exception e) {
                logger.error(e);
            }
        }

        chain.doFilter(request, response);
    }
}

AuthenticationEntryPoint

我们未用form、basic等验证机制,如不自定义AuthenticationEntryPoint,当未验证用户访问受限资源时,将返回403错误。下面自定义的AuthenticationEntryPoint,返回401错误,将在WebSecurityConfig中使用。

package org.itrunner.heroes.config;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

import static org.springframework.http.HttpStatus.UNAUTHORIZED;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // This is invoked when user tries to access a secured REST resource without supplying any credentials
        // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to
        response.sendError(UNAUTHORIZED.value(), UNAUTHORIZED.getReasonPhrase());
    }
}

WebSecurityConfig

在WebSecurityConfig中配置UserDetailsService、Filter、AuthenticationEntryPoint、加密算法、CORS、request权限等。

package org.itrunner.heroes.config;

import org.itrunner.heroes.config.SecurityProperties.Cors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import static org.springframework.http.HttpMethod.*;

@Configuration
@EnableWebSecurity
@SuppressWarnings("SpringJavaAutowiringInspection")
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private static final String ROLE_ADMIN = "ADMIN";

    @Value("${api.base-path}/**")
    private String apiPath;

    @Value("${management.endpoints.web.exposure.include}")
    private String[] actuatorExposures;

    private final JwtAuthenticationEntryPoint unauthorizedHandler;

    private final SecurityProperties securityProperties;

    private final UserDetailsService userDetailsService;

    @Autowired
    public WebSecurityConfig(JwtAuthenticationEntryPoint unauthorizedHandler, SecurityProperties securityProperties, @Qualifier("userDetailsServiceImpl") UserDetailsService userDetailsService) {
        this.unauthorizedHandler = unauthorizedHandler;
        this.securityProperties = securityProperties;
        this.userDetailsService = userDetailsService;
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers(securityProperties.getIgnorePaths());
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() // don't create session
                .authorizeRequests()
                .requestMatchers(EndpointRequest.to(actuatorExposures)).permitAll()
                .antMatchers(securityProperties.getAuthPath()).permitAll()
                .antMatchers(OPTIONS, "/**").permitAll()
                .antMatchers(POST, apiPath).hasRole(ROLE_ADMIN)
                .antMatchers(PUT, apiPath).hasRole(ROLE_ADMIN)
                .antMatchers(DELETE, apiPath).hasRole(ROLE_ADMIN)
                .anyRequest().authenticated().and()
                .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class) // Custom JWT based security filter
                .headers().cacheControl(); // disable page caching
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public AuthenticationTokenFilter authenticationTokenFilterBean() {
        return new AuthenticationTokenFilter();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        Cors cors = securityProperties.getCors();
        configuration.setAllowedOrigins(cors.getAllowedOrigins());
        configuration.setAllowedMethods(cors.getAllowedMethods());
        configuration.setAllowedHeaders(cors.getAllowedHeaders());
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

说明:

  • 在Spring Boot 2.0中必须覆盖authenticationManagerBean()方法,否则在@Autowired authenticationManager时会报错:Field authenticationManager required a bean of type 'org.springframework.security.authentication.AuthenticationManager' that could not be found.
  • 初始化数据中的密码是调用new BCryptPasswordEncoder().encode()方法生成的。
  • POST\PUT\DELETE请求需要"ADMIN"角色。调用hasRole()方法时应去掉前缀"ROLE_",方法会自动补充,否则请使用hasAuthority()。

Authentication Controller

AuthenticationController
验证用户名、密码,验证成功则返回Token。

package org.itrunner.heroes.controller;

import lombok.extern.slf4j.Slf4j;
import org.itrunner.heroes.dto.AuthenticationRequest;
import org.itrunner.heroes.dto.AuthenticationResponse;
import org.itrunner.heroes.util.JwtUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@RestController
@RequestMapping(value = "/api/auth", produces = MediaType.APPLICATION_JSON_VALUE)
@Slf4j
public class AuthenticationController {
    private final AuthenticationManager authenticationManager;
    private final JwtUtils jwtUtils;

    @Autowired
    public AuthenticationController(AuthenticationManager authenticationManager, JwtUtils jwtUtils) {
        this.authenticationManager = authenticationManager;
        this.jwtUtils = jwtUtils;
    }

    @PostMapping
    public AuthenticationResponse login(@RequestBody @Valid AuthenticationRequest request) {
        // Perform the security
        Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // Generate token
        String token = jwtUtils.generate((UserDetails) authentication.getPrincipal());

        // Return the token
        return new AuthenticationResponse(token);
    }

    @ExceptionHandler(AuthenticationException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public void handleAuthenticationException(AuthenticationException exception) {
        log.error(exception.getMessage(), exception);
    }
}

AuthenticationRequest

import lombok.Getter;
import lombok.Setter;

import javax.validation.constraints.NotNull;

@Getter
@Setter
public class AuthenticationRequest {
    @NotNull
    private String username;

    @NotNull
    private String password;
}

AuthenticationResponse

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AuthenticationResponse {
    private String token;
}

重启Spring Boot,用postman测试一下,输入验证URL:localhost:8080/api/auth、正确的用户名和密码,提交后会输出token。
Angular 9集成Spring Boot 2详解
此时如请求localhost:8080/api/heroes会输出401错误,将token填到Authorization header中,则可查询出hero。
Angular 9集成Spring Boot 2详解
说明:用户"admin"可以执行CRUD操作,"jason"只有查询权限。

JPA Auditing

常有这样的需求,新增、更新数据库时记录创建人、创建时间、修改人、修改时间,如手工更新这些字段比较烦琐,Spring Data的Auditing支持此功能。

使用方法:

  1. 在字段上添加注解@CreatedBy、@CreatedDate、@LastModifiedBy、@LastModifiedDate
@Column(name = "CREATED_BY", length = 50, updatable = false, nullable = false)
@CreatedBy
private String createdBy;

@Column(name = "CREATED_DATE", updatable = false, nullable = false)
@Temporal(TemporalType.TIMESTAMP)
@CreatedDate
private Date createdDate;

@Column(name = "LAST_MODIFIED_BY", length = 50)
@LastModifiedBy
private String lastModifiedBy;

@Column(name = "LAST_MODIFIED_DATE")
@Temporal(TemporalType.TIMESTAMP)
@LastModifiedDate
private Date lastModifiedDate;
  1. 在Entity上添加注解@EntityListeners(AuditingEntityListener.class)
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "HERO", uniqueConstraints = {@UniqueConstraint(name = "UK_HERO_NAME", columnNames = {"HERO_NAME"})})
public class Hero {
  ...
}
  1. 在SpringBootApplication类上添加注解@EnableJpaAuditing
@SpringBootApplication
@EnableJpaAuditing
public class HeroesApplication {
  ...
}
  1. 实现AuditorAware获取当前用户
package org.itrunner.heroes.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Optional;

@Configuration
public class SpringSecurityAuditorAware implements AuditorAware<String> {
    @Override
    public Optional<String> getCurrentAuditor() {
        return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication().getName());
    }
}

另外,Entity也可实现Auditable接口,或继承AbstractAuditable。

集成测试

Spring Boot提供@SpringBootTest注解支持集成测试。

默认,@SpringBootTest不启动server,可以使用webEnvironment属性定义运行方式:

  • MOCK(Default) : Loads a web ApplicationContext and provides a mock web environment. Embedded servers are not started when using this annotation. If a web environment is not available on your classpath, this mode transparently falls back to creating a regular non-web ApplicationContext. It can be used in conjunction with @AutoConfigureMockMvc or @AutoConfigureWebTestClient for mock-based testing of your web application.
  • RANDOM_PORT: Loads a WebServerApplicationContext and provides a real web environment. Embedded servers are started and listen on a random port.
  • DEFINED_PORT: Loads a WebServerApplicationContext and provides a real web environment. Embedded servers are started and listen on a defined port (from your application.properties) or on the default port of 8080.
  • NONE: Loads an ApplicationContext by using SpringApplication but does not provide any web environment (mock or otherwise).

MOCK环境
针对mock环境,利用MockMvc执行测试,使用@WithMockUser来模拟用户,如下:

package org.itrunner.heroes.controller;

import org.itrunner.heroes.dto.HeroDto;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.itrunner.heroes.util.JsonUtils.asJson;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest(properties = "spring.datasource.initialization-mode=never")
@AutoConfigureMockMvc
class HeroControllerTest {
    @Autowired
    private MockMvc mvc;

    @Test
    @WithMockUser(username = "admin", roles = {"ADMIN"})
    void crudSuccess() throws Exception {
        HeroDto hero = new HeroDto();
        hero.setName("Jack");

        // add hero
        mvc.perform(post("/api/heroes").content(asJson(hero)).contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()).andExpect(content().json("{'id':1, 'name':'Jack'}"));

        // update hero
        hero.setId(1L);
        hero.setName("Jacky");
        mvc.perform(put("/api/heroes").content(asJson(hero)).contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()).andExpect(content().json("{'name':'Jacky'}"));

        // find heroes by name
        mvc.perform(get("/api/heroes/?name=m").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk());

        // get hero by id
        mvc.perform(get("/api/heroes/1").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()).andExpect(content().json("{'name':'Jacky'}"));

        // delete hero successfully
        mvc.perform(delete("/api/heroes/1").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk());

        // delete hero
        mvc.perform(delete("/api/heroes/9999")).andExpect(status().is4xxClientError());
    }

    @Test
    @WithMockUser(username = "admin", roles = {"ADMIN"})
    void addHeroValidationFailed() throws Exception {
        HeroDto hero = new HeroDto();
        mvc.perform(post("/api/heroes").content(asJson(hero)).contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().is(400));
    }
}

利用mock环境测试通常比Servlet 容器更快,但MockMvc不能直接测试依赖底层Servlet容器行为的代码。

Real Environment
启动web server测试,为避免端口冲突,推荐使用RANDOM_PORT,随机选择可用端口。利用TestRestTemplate调用REST服务。

package org.itrunner.heroes;

import org.itrunner.heroes.dto.AuthenticationRequest;
import org.itrunner.heroes.dto.AuthenticationResponse;
import org.itrunner.heroes.dto.HeroDto;
import org.itrunner.heroes.exception.ErrorMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;

import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class HeroesApplicationTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @BeforeEach
    void setup() {
        AuthenticationRequest authenticationRequest = new AuthenticationRequest();
        authenticationRequest.setUsername("admin");
        authenticationRequest.setPassword("admin");
        String token = restTemplate.postForObject("/api/auth", authenticationRequest, AuthenticationResponse.class).getToken();

        restTemplate.getRestTemplate().setInterceptors(
                Collections.singletonList((request, body, execution) -> {
                    HttpHeaders headers = request.getHeaders();
                    headers.add("Authorization", "Bearer " + token);
                    headers.add("Content-Type", "application/json");
                    return execution.execute(request, body);
                }));
    }

    @Test
    void loginFailure() {
        AuthenticationRequest request = new AuthenticationRequest();
        request.setUsername("admin");
        request.setPassword("111111");
        int statusCode = restTemplate.postForEntity("/api/auth", request, HttpEntity.class).getStatusCodeValue();
        assertThat(statusCode).isEqualTo(403);
    }

    @Test
    void crudSuccess() {
        HeroDto hero = new HeroDto();
        hero.setName("Jack");

        // add hero
        hero = restTemplate.postForObject("/api/heroes", hero, HeroDto.class);
        assertThat(hero.getId()).isNotNull();

        // update hero
        hero.setName("Jacky");
        HttpEntity<HeroDto> requestEntity = new HttpEntity<>(hero);
        hero = restTemplate.exchange("/api/heroes", HttpMethod.PUT, requestEntity, HeroDto.class).getBody();
        assertThat(hero.getName()).isEqualTo("Jacky");

        // find heroes by name
        Map<String, String> urlVariables = new HashMap<>();
        urlVariables.put("name", "m");
        List<HeroDto> heroes = restTemplate.getForObject("/api/heroes/?name={name}", List.class, urlVariables);
        assertThat(heroes.size()).isEqualTo(5);

        // get hero by id
        hero = restTemplate.getForObject("/api/heroes/" + hero.getId(), HeroDto.class);
        assertThat(hero.getName()).isEqualTo("Jacky");

        // delete hero successfully
        ResponseEntity<String> response = restTemplate.exchange("/api/heroes/" + hero.getId(), HttpMethod.DELETE, null, String.class);
        assertThat(response.getStatusCodeValue()).isEqualTo(200);

        // delete hero
        response = restTemplate.exchange("/api/heroes/9999", HttpMethod.DELETE, null, String.class);
        assertThat(response.getStatusCodeValue()).isEqualTo(400);
    }

    @Test
    void addHeroValidationFailed() {
        HeroDto hero = new HeroDto();
        ResponseEntity<ErrorMessage> responseEntity = restTemplate.postForEntity("/api/heroes", hero, ErrorMessage.class);
        assertThat(responseEntity.getStatusCodeValue()).isEqualTo(400);
        assertThat(responseEntity.getBody().getError()).isEqualTo("MethodArgumentNotValidException");
    }
}

远程服务
运行测试时,有时必须mock某些组件,比如远程服务,或模拟真实环境中很难发生的失败情况。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.*;
import org.springframework.boot.test.context.*;
import org.springframework.boot.test.mock.mockito.*;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.BDDMockito.*;

@SpringBootTest
class MyTests {

    @MockBean
    private RemoteService remoteService;

    @Autowired
    private Reverser reverser;

    @Test
    void exampleTest() {
        // RemoteService has been injected into the reverser bean
        given(this.remoteService.someCall()).willReturn("mock");
        String reverse = reverser.reverseSomeCall();
        assertThat(reverse).isEqualTo("kcom");
    }

}

集成Swagger

Swagger是实现OpenAPI Specification (OAS)的开发工具。OAS定义了标准、语言无关、人机可读的RESTful API接口规范。文档生成工具可以根据OpenAPI 定义来显示 API,代码生成工具可以生成各种语言的服务端或客户端代码。

我们使用的Springfox Swagger是支持与Spring Boot集成的Swagger工具,可以生成文档,支持Swagger UI测试。

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.9.2</version>
</dependency>
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.9.2</version>
</dependency>

启用Swagger

SwaggerConfig
启用Swagger非常简单,仅需编写一个类:

package org.itrunner.heroes.config;

import com.fasterxml.classmate.TypeResolver;
import org.itrunner.heroes.exception.ErrorMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.ResponseEntity;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.*;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spi.service.contexts.SecurityContext;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

import java.time.LocalDate;
import java.util.List;

import static com.google.common.collect.Lists.newArrayList;

@EnableSwagger2
@Configuration
public class SwaggerConfig {

    private final SwaggerProperties properties;

    @Autowired
    public SwaggerConfig(SwaggerProperties properties) {
        this.properties = properties;
    }

    @Bean
    public Docket petApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .apis(RequestHandlerSelectors.basePackage(properties.getBasePackage()))
                .paths(PathSelectors.any())
                .build()
                .apiInfo(apiInfo())
                .pathMapping("/")
                .directModelSubstitute(LocalDate.class, String.class)
                .genericModelSubstitutes(ResponseEntity.class)
                .additionalModels(new TypeResolver().resolve(ErrorMessage.class))
                .useDefaultResponseMessages(false)
                .securitySchemes(newArrayList(apiKey()))
                .securityContexts(newArrayList(securityContext()))
                .enableUrlTemplating(false);
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title(properties.getTitle())
                .description(properties.getDescription())
                .contact(new Contact(properties.getContact().getName(), properties.getContact().getUrl(), properties.getContact().getEmail()))
                .version(properties.getVersion())
                .build();
    }

    private ApiKey apiKey() {
        return new ApiKey("BearerToken", "Authorization", "header");
    }

    private SecurityContext securityContext() {
        return SecurityContext.builder()
                .securityReferences(defaultAuth())
                .forPaths(PathSelectors.regex(properties.getApiPath()))
                .build();
    }

    private List<SecurityReference> defaultAuth() {
        AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
        AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
        authorizationScopes[0] = authorizationScope;
        return newArrayList(new SecurityReference("BearerToken", authorizationScopes));
    }
}

Swagger URI
在WebSecurityConfig中配置忽略验证Swagger URI:

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/api-docs", "/swagger-resources/**", "/swagger-ui.html**", "/webjars/**");
    }

springfox配置
spring.resources.add-mappings设为true,api-docs路径可自定义。为方便修改swagger配置,将一些参数写到配置文件中,如下:

spring:
  resources:
    add-mappings: true

springfox:
  documentation:
    swagger:
      v2:
        path: /api-docs
      title: Api Documentation
      description: Api Documentation
      version: 1.0
      base-package: org.itrunner.heroes.controller
      api-path: /api/.*
      contact:
        name: Jason
        url: https://blog.51cto.com/7308310
        email: sjc-925@163.com

测试Swagger
Api doc: http://localhost:8080/api-docs
Angular 9集成Spring Boot 2详解
Swagger UI: http://localhost:8080/swagger-ui.html
Angular 9集成Spring Boot 2详解

分页参数

REST API分页查询方法含有org.springframework.data.domain.Pageable参数时,默认,Swagger根据Pageable接口的get/is方法生成了pageNumber、pageSize、offset、paged、unpaged、sort.sorted、sort.unsorted等参数,但Spring实现中使用的参数是page、size、sort,因此Swagger生成的参数是无效的。

为解决这个问题,我们添加解析Pageable参数的OperationBuilderPlugin:

package org.itrunner.heroes.config;

import com.fasterxml.classmate.ResolvedType;
import com.fasterxml.classmate.TypeResolver;
import com.google.common.base.Function;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.schema.ModelReference;
import springfox.documentation.schema.ResolvedTypes;
import springfox.documentation.schema.TypeNameExtractor;
import springfox.documentation.service.Parameter;
import springfox.documentation.service.ResolvedMethodParameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spi.schema.contexts.ModelContext;
import springfox.documentation.spi.service.OperationBuilderPlugin;
import springfox.documentation.spi.service.contexts.OperationContext;
import springfox.documentation.spi.service.contexts.ParameterContext;

import java.util.List;

import static com.google.common.collect.Lists.newArrayList;
import static springfox.documentation.spi.schema.contexts.ModelContext.inputParam;

@Component
@Order
public class PageableParameterReader implements OperationBuilderPlugin {
    private static final String PARAMETER_TYPE = "query";
    private final TypeNameExtractor nameExtractor;
    private final TypeResolver resolver;
    private final ResolvedType pageableType;

    @Autowired
    public PageableParameterReader(TypeNameExtractor nameExtractor, TypeResolver resolver) {
        this.nameExtractor = nameExtractor;
        this.resolver = resolver;
        this.pageableType = resolver.resolve(Pageable.class);
    }

    @Override
    public void apply(OperationContext context) {
        List<ResolvedMethodParameter> methodParameters = context.getParameters();
        List<Parameter> parameters = newArrayList();

        for (ResolvedMethodParameter methodParameter : methodParameters) {
            ResolvedType resolvedType = methodParameter.getParameterType();

            if (pageableType.equals(resolvedType)) {
                ParameterContext parameterContext = new ParameterContext(methodParameter,
                        new ParameterBuilder(),
                        context.getDocumentationContext(),
                        context.getGenericsNamingStrategy(),
                        context);
                Function<ResolvedType, ? extends ModelReference> factory = createModelRefFactory(parameterContext);

                ModelReference intModel = factory.apply(resolver.resolve(Integer.TYPE));
                ModelReference stringModel = factory.apply(resolver.resolve(List.class, String.class));

                parameters.add(new ParameterBuilder()
                        .parameterType(PARAMETER_TYPE)
                        .name("page")
                        .modelRef(intModel)
                        .description("Results page you want to retrieve (0..N)").build());
                parameters.add(new ParameterBuilder()
                        .parameterType(PARAMETER_TYPE)
                        .name("size")
                        .modelRef(intModel)
                        .description("Number of records per page").build());
                parameters.add(new ParameterBuilder()
                        .parameterType(PARAMETER_TYPE)
                        .name("sort")
                        .modelRef(stringModel)
                        .allowMultiple(true)
                        .description("Sorting criteria in the format: property(,asc|desc). "
                                + "Default sort order is ascending. "
                                + "Multiple sort criteria are supported.")
                        .build());
                context.operationBuilder().parameters(parameters);
            }
        }
    }

    @Override
    public boolean supports(DocumentationType delimiter) {
        return true;
    }

    private Function<ResolvedType, ? extends ModelReference> createModelRefFactory(ParameterContext context) {
        ModelContext modelContext = inputParam(
                context.getGroupName(),
                context.resolvedMethodParameter().getParameterType(),
                context.getDocumentationType(),
                context.getAlternateTypeProvider(),
                context.getGenericNamingStrategy(),
                context.getIgnorableParameterTypes());
        return ResolvedTypes.modelRefFactory(modelContext, nameExtractor);
    }
}

在分页方法的Pageable参数前添加@ApiIgnore,忽略默认的参数解析:

public Page<HeroDto> getHeroes(@ApiIgnore @SortDefault.SortDefaults({@SortDefault(sort = "name", direction = Sort.Direction.ASC)}) Pageable pageable) {
    return service.getAllHeroes(pageable);
}

API Doc

Swagger提供一些annotation,可为API Doc添加说明、默认值等,使文档可读性更好、方便UI测试,如下:

package org.itrunner.heroes.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.extern.slf4j.Slf4j;
import org.itrunner.heroes.dto.HeroDto;
import org.itrunner.heroes.service.HeroService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import springfox.documentation.annotations.ApiIgnore;

import javax.validation.Valid;
import java.util.List;

@RestController
@RequestMapping(value = "/api/heroes", produces = MediaType.APPLICATION_JSON_VALUE)
@Api(tags = {"Hero Controller"})
@Slf4j
public class HeroController {
    private final HeroService service;

    @Autowired
    public HeroController(HeroService service) {
        this.service = service;
    }

    @ApiOperation("Get hero by id")
    @GetMapping("/{id}")
    public HeroDto getHeroById(@ApiParam(required = true, example = "1") @PathVariable("id") Long id) {
        return service.getHeroById(id);
    }

    @ApiOperation("Get all heroes")
    @GetMapping
    public Page<HeroDto> getHeroes(@ApiIgnore @SortDefault.SortDefaults({@SortDefault(sort = "name", direction = Sort.Direction.ASC)}) Pageable pageable) {
        return service.getAllHeroes(pageable);
    }

    @ApiOperation("Search heroes by name")
    @GetMapping("/")
    public List<HeroDto> searchHeroes(@ApiParam(required = true) @RequestParam("name") String name) {
        return service.findHeroesByName(name);
    }

    @ApiOperation("Add new hero")
    @PostMapping
    public HeroDto addHero(@ApiParam(required = true) @Valid @RequestBody HeroDto hero) {
        return service.saveHero(hero);
    }

    @ApiOperation("Update hero info")
    @PutMapping
    public HeroDto updateHero(@ApiParam(required = true) @Valid @RequestBody HeroDto hero) {
        return service.saveHero(hero);
    }

    @ApiOperation("Delete hero by id")
    @DeleteMapping("/{id}")
    public void deleteHero(@ApiParam(required = true, example = "1") @PathVariable("id") Long id) {
        service.deleteHero(id);
    }

    /*@ExceptionHandler(DataAccessException.class)
    public ResponseEntity<Map<String, Object>> handleDataAccessException(DataAccessException exception) {
        log.error(exception.getMessage(), exception);
        Map<String, Object> body = new HashMap<>();
        body.put("message", exception.getMessage());
        return ResponseEntity.badRequest().body(body);
    }*/
}

API Model
API使用的model类,可以使用@ApiModel、@ApiModelProperty注解。在Swagger UI中,example是默认值,便于测试。

@Getter
@Setter
public class AuthenticationRequest {
    @ApiModelProperty(value = "username", example = "admin", required = true)
    @NotNull
    private String username;

    @ApiModelProperty(value = "password", example = "admin", required = true)
    @NotNull
    private String password;
}

Swagger UI测试

Swagger UI测试有以下优点:

  • 可直接点选要测试的API
  • 提供需要的参数和默认值,可编辑参数值
  • 只需一次认证
  • 直观的显示Request和Response信息

Angular 9集成Spring Boot 2详解

获取Token
依次点击Authentication Controller -> /api/auth -> Try it out -> (修改username和password)-> Excute,成功后会输出token。
授权
点击页面右上方的Authorize,输入Bearer token。
Angular 9集成Spring Boot 2详解

授权后即可进行其他测试。

Angular

配置开发环境

  • 安装最新稳定版Node.js
  • 检查npm版本
npm -v

更新npm:

npm i npm@latest -g
  • 安装Angular CLI
npm install -g @angular/cli@latest

解压后进入根目录,执行:

npm install
ng update
ng update @angular/cli
ng update @angular/core

如angular.json中项目名为angular.io-example,替换为angular-io-example。

NG-ZORRO

NG-ZORRO是阿里出品的企业级Angular UI组件。在示例中,我们将使用NG-ZORRO表单、表格等。

  • 安装NG-ZORRO

进入toh-pt6根目录,执行以下命令后将自动完成 ng-zorro-antd 的初始化配置,包括引入国际化文件,导入模块,引入样式文件等工作。

ng add ng-zorro-antd
? Add icon assets [ Detail: https://ng.ant.design/components/icon/en ] Yes
? Set up custom theme file [ Detail: https://ng.ant.design/docs/customize-theme/en ] Yes
? Choose your locale code: en_US
? Choose template to create project: blank

注意,默认会修改app.component.html,需要恢复。

  • 脚手架

脚手架,在Angular官网使用术语Schematic(原理图),是一个基于模板、支持复杂逻辑的代码生成器,可以创建、修改和维护任何软件项目。为满足组织的特定需求,可以借助脚手架来用预定义的模板或布局生成常用的 UI 模式或特定的组件。

NG-ZORRO官网的每个代码演示都附有模板,点击底部图标可以复制生成代码命令来快速生成代码。
Angular 9集成Spring Boot 2详解
生成登陆组件的命令如下:

ng g ng-zorro-antd:form-normal-login login
  • 定制主题

NG-ZORRO支持一定程度的样式定制,比如主色、圆角、边框、组件样式等。初始化项目时选择自定义主题即可自动配置好主题文件,修改 src/theme.less 文件内容就可以自定义主题。

...

// -------- Colors -----------
@primary-color: @blue-6;
@info-color: @blue-6;
@success-color: @green-6;
@processing-color: @blue-6;
@error-color: @red-6;
@highlight-color: @red-6;
@warning-color: @gold-6;
@normal-color: #d9d9d9;
@white: #fff;
@black: #000;

...
// Buttons
@btn-font-weight: 400;
@btn-border-radius-base: @border-radius-base;
@btn-border-radius-sm: @border-radius-base;
@btn-border-width: @border-width-base;
@btn-border-style: @border-style-base;
@btn-shadow: 0 2px 0 rgba(0, 0, 0, 0.015);
@btn-primary-shadow: 0 2px 0 rgba(0, 0, 0, 0.045);
@btn-text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12);

...
  • 全局配置

从 8.3.0版本开始,支持全局配置功能,可以通过全局配置来定义组件的默认行为,可以在运行时修改全局配置项。

NzConfig接口提供的类型定义信息能够帮助你找到所有支持全局配置项的组件和属性。另外,每个组件的文档都会指出哪些属性支持全局配置。

比如,table支持的全局配置项:

export interface TableConfig {
    nzBordered?: boolean;
    nzSize?: NzSizeMDSType;
    nzShowQuickJumper?: boolean;
    nzShowSizeChanger?: boolean;
    nzSimple?: boolean;
    nzHideOnSinglePage?: boolean;
}

在AppModule中注入NZ_CONFIG,定义全局配置:

...

const ngZorroConfig: NzConfig = {
  table: {nzSize: 'small', nzBordered: true},
};

...

@NgModule({
  ...
  providers: [
    [
      ...
      {provide: NZ_CONFIG, useValue: ngZorroConfig}
    ]
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}

Module

常用模块

NgModule 导入自 为何使用
BrowserModule @angular/platform-browser 在浏览器中运行应用时
CommonModule @angular/common 要使用 NgIf 和 NgFor 时
FormsModule @angular/forms 要构建模板驱动表单时
ReactiveFormsModule @angular/forms 要构建响应式表单时
RouterModule @angular/router 要使用路由功能,用到 RouterLink,.forRoot() 和 .forChild() 时
HttpClientModule @angular/common/http 要和服务器对话时

BrowserModule导入了CommonModule并重新导出了CommonModule,以便它所有的指令在任何导入了 BrowserModule 的模块中都可以使用。
运行在浏览器中的应用,必须在根模块AppModule中导入BrowserModule ,因为它提供了启动和运行浏览器应用的某些必须服务。BrowserModule 的provider是面向整个应用的,只能在根模块中使用。 特性模块只需要 CommonModule 中的常用指令。

Form

Angular 提供了两种不同的表单处理用户输入:响应式表单和模板驱动表单。两者都从视图中捕获用户输入事件、验证用户输入、创建表单模型、更新数据模型,并提供跟踪这些更改的途径。

响应式表单和模板驱动表单优缺点:

  • 响应式表单更健壮:可扩展性、可复用性和可测试性更强。如果表单是应用中的关键部分,建议使用响应式表单。
  • 模板驱动表单更简单,但不像响应式表单那么容易扩展。如果你有非常基本的表单需求和逻辑,请使用模板驱动表单。

Tour of Heroes使用了模板驱动表单,我们创建的登录组件使用了响应式表单。

更新Tour of Heroes

Tour of Heroes使用了“in-memory-database”,我们删除相关内容改为调用Spring Boot Rest API。

  1. 删除in-memory-data.service.ts
  2. 删除app.module.ts中的InMemoryDataService、HttpClientInMemoryWebApiModule
  3. 删除package.json中的“angular-in-memory-web-api”
  4. 配置environment

修改environment.ts、environment.prod.ts,内容如下:

environment.ts

export const environment = {
  production: false,
  apiUrl: 'http://localhost:8080'
};

environment.prod.ts

export const environment = {
  production: true,
  apiUrl: 'http://localhost:8080' // 修改为生产域名
};
  1. 修改hero.service.ts的heroesUrl,将“api/heroes”替换为"${environment.apiUrl}/api/heroes" :
import {environment} from '../environments/environment';
...
private heroesUrl = `${environment.apiUrl}/api/heroes`;
  1. 修改hero.service.ts的handleError方法
  • 发生错误时,输出REST的错误消息,
  • 未传入result参数时,返回of()(原代码有问题,比如添加重名的hero时,页面会添加空行)
private handleError<T>(operation = 'operation', result?: T) {
  return (errorResponse: any): Observable<T> => {
    console.error(errorResponse.error); // log to console instead

    this.log(`${operation} failed: ${errorResponse.error.message}`);

    // Let the app keep running by returning an empty result.
    return result ? of(result as T) : of();
  };
}
  1. 启动Angular:
ng serve
  1. 访问页面 http://localhost:4200/

因未登录获取token,此时访问会显示以下错误:
Angular 9集成Spring Boot 2详解

Authentication Service

AuthenticationService请求http://localhost:8080/api/auth 验证用户,如验证成功则解析、存储token。

import {Injectable} from '@angular/core';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {Observable, of} from 'rxjs';
import {catchError, tap} from 'rxjs/operators';
import {environment} from '../environments/environment';
import {throwError} from 'rxjs/internal/observable/throwError';

const httpOptions = {
  headers: new HttpHeaders({'Content-Type': 'application/json'})
};

@Injectable({providedIn: 'root'})
export class AuthenticationService {

  constructor(private http: HttpClient) {
  }

  login(name: string, pass: string): Observable<boolean> {
    return this.http.post<any>(`${environment.apiUrl}/api/auth`, JSON.stringify({username: name, password: pass}), httpOptions).pipe(
      tap(response => {
        if (response && response.token) {
          // login successful, store username and jwt token in local storage to keep user logged in between page refreshes
          sessionStorage.setItem('currentUser', JSON.stringify({username: name, token: response.token, tokenParsed: this.decodeToken(response.token)}));
          return of(true);
        } else {
          return of(false);
        }
      }),
      catchError((err) => {
        console.error(err);
        return of(false);
      })
    );
  }

  getCurrentUser(): any {
    const userStr = sessionStorage.getItem('currentUser');
    return userStr ? JSON.parse(userStr) : '';
  }

  getToken(): string {
    const currentUser = this.getCurrentUser();
    return currentUser ? currentUser.token : '';
  }

  getUsername(): string {
    const currentUser = this.getCurrentUser();
    return currentUser ? currentUser.username : '';
  }

  logout(): void {
    sessionStorage.removeItem('currentUser');
  }

  isLoggedIn(): boolean {
    const token: string = this.getToken();
    return token && token.length > 0;
  }

  hasRole(role: string): boolean {
    const currentUser = this.getCurrentUser();
    if (!currentUser) {
      return false;
    }
    const authorities: string[] = this.getAuthorities(currentUser.tokenParsed);
    return authorities.indexOf('ROLE_' + role) !== -1;
  }

  decodeToken(token: string): string {
    let payload: string = token.split('.')[1];

    payload = payload.replace('/-/g', '+').replace('/_/g', '/');
    switch (payload.length % 4) {
      case 0:
        break;
      case 2:
        payload += '==';
        break;
      case 3:
        payload += '=';
        break;
      default:
        throwError('Invalid token');
    }

    payload = (payload + '===').slice(0, payload.length + (payload.length % 4));

    return decodeURIComponent(escape(atob(payload)));
  }

  getAuthorities(tokenParsed: string): string[] {
    return JSON.parse(tokenParsed).authorities;
  }
}

创建登录页面

在根目录执行以下命令创建登录组件:

ng g ng-zorro-antd:form-normal-login login

生成的组件使用了响应式表单。

login.component.ts
修改login.component.ts,注入AuthenticationService、MessageService、Router。修改submitForm()方法调用AuthenticationService进行用户验证,如验证成功则跳转页面,否则显示错误信息。下面是修改后的代码:

import {Component, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {Router} from '@angular/router';
import {AuthenticationService} from '../authentication.service';
import {MessageService} from '../message.service';
import {User} from '../user';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
  user: User;
  validateForm: FormGroup;
  loading = false;

  constructor(private fb: FormBuilder, private authenticationService: AuthenticationService,
              private messageService: MessageService, private router: Router) {
  }

  submitForm(): void {
    this.user = Object.assign({}, this.validateForm.value);
    this.login();
  }

  login() {
    this.loading = false;
    this.authenticationService.login(this.user.username, this.user.password)
      .subscribe(result => {
        if (result) {
          // login successful
          this.loading = true;
          this.router.navigate(['']);
        } else {
          // login failed
          this.log('Username or password is incorrect');
        }
      });
  }

  ngOnInit(): void {
    // reset login status
    this.authenticationService.logout();

    this.validateForm = this.fb.group({
      username: [null, [Validators.required]],
      password: [null, [Validators.required]],
      remember: [true]
    });
  }

  private log(message: string) {
    this.messageService.add('Login: ' + message);
  }
}

上面使用Object.assign()方法将表单值赋予User model,User定义如下:

export interface User {
  username: string;
  password: string;
  remember: boolean;
}

注意,表单控件名要与模型字段名一致。

login.component.html
给Login按钮添加disabled属性,当表单无效时禁用按钮。

<button nz-button class="login-form-button" [nzType]="'primary'" [disabled]="!validateForm.valid">Log in</button>

添加login路由
编辑AppRoutingModule,添加login路由:

const routes: Routes = [
  {path: '', redirectTo: '/dashboard', pathMatch: 'full'},
  {path: 'login', component: LoginComponent},
  {path: 'dashboard', component: DashboardComponent},
  {path: 'detail/:id', component: HeroDetailComponent},
  {path: 'heroes', component: HeroesComponent}
];

添加login链接
编辑app.component.html,添加login链接:

<h1>{{title}}</h1>
<nav>
  <a routerLink="/login">Login</a>
  <a routerLink="/dashboard">Dashboard</a>
  <a routerLink="/heroes">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>

保护你的资源

在路由配置中添加route guard,只有登录用户导航才能继续。

有多种guard接口:

  • CanActivate 处理导航到某路由的情况
  • CanActivateChild 处理导航到某子路由的情况
  • CanDeactivate 处理从当前路由离开的情况
  • Resolve 在路由激活之前获取路由数据
  • CanLoad 处理异步导航到某特性模块的情况

这里我们仅实现CanActivate接口,代码如下:

AuthGuard

import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from '@angular/router';
import {AuthenticationService} from './authentication.service';

@Injectable({providedIn: 'root'})
export class AuthGuard implements CanActivate {

  constructor(private router: Router, private authService: AuthenticationService) {
  }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    if (this.authService.isLoggedIn()) {
      // logged in so return true
      return true;
    }

    // not logged in so redirect to login page with the return url and return false
    this.router.navigate(['/login']);
    return false;
  }
}

AuthGuard调用AuthenticationService,检查用户是否登录,如未登录则跳转到login页面。

配置CanActivate Guard
编辑app-routing.module.ts,给受保护页面添加AuthGuard:

const routes: Routes = [
  {path: '', redirectTo: '/dashboard', pathMatch: 'full'},
  {path: 'login', component: LoginComponent},
  {path: 'dashboard', component: DashboardComponent, canActivate: [AuthGuard]},
  {path: 'detail/:id', component: HeroDetailComponent, canActivate: [AuthGuard]},
  {path: 'heroes', component: HeroesComponent, canActivate: [AuthGuard]}
];

添加Bearer Token

请求需认证的REST服务时,需要在HTTP Header中添加Bearer Token,有两种添加方式:

  1. 在http请求中添加httpOptions
const httpOptions = {
  headers: new HttpHeaders({
    'Content-Type': 'application/json'}),
    'Authorization': 'Bearer ' + this.authenticationService.getToken()
};
  1. 使用HttpInterceptor拦截所有http请求自动添加token

AuthenticationInterceptor

import {Injectable} from '@angular/core';
import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
import {Observable} from 'rxjs';
import {AuthenticationService} from './authentication.service';

@Injectable()
export class AuthenticationInterceptor implements HttpInterceptor {

  constructor(private authenticationService: AuthenticationService) {
  }

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const idToken = this.authenticationService.getToken();

    if (idToken) {
      const cloned = req.clone({
        headers: req.headers.set('Authorization', 'Bearer ' + idToken)
      });

      return next.handle(cloned);
    } else {
      return next.handle(req);
    }
  }
}

注册AuthenticationInterceptor
在app.module.ts中注册HttpInterceptor:

  providers: [
    [{provide: HTTP_INTERCEPTORS, useClass: AuthenticationInterceptor, multi: true}]
  ],

权限控制

新增一个directive,用于根据用户角色显示页面元素。

HasRoleDirective

import {Directive, Input, TemplateRef, ViewContainerRef} from '@angular/core';
import {AuthenticationService} from './authentication.service';

@Directive({
  selector: '[appHasRole]'
})
export class HasRoleDirective {
  constructor(private templateRef: TemplateRef<any>, private viewContainer: ViewContainerRef, private authenticationService: AuthenticationService) {
  }

  @Input()
  set appHasRole(role: string) {
    if (this.authenticationService.hasRole(role)) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else {
      this.viewContainer.clear();
    }
  }
}

注意,要在AppModule的declarations中声明HasRoleDirective。

接下来修改heroes.component.html和hero-detail.component.html,只有"ADMIN"用户才有新增、修改、删除权限:
heroes.component.html

<h2>My Heroes</h2>

<div *appHasRole="'ADMIN'">
  <label>Hero name:
    <input #heroName />
  </label>
  <!-- (click) passes input value to add() and then clears the input -->
  <button (click)="add(heroName.value); heroName.value=''">
    add
  </button>
</div>

<ul class="heroes">
  <li *ngFor="let hero of heroes">
    <a routerLink="/detail/{{hero.id}}">
      <span class="badge">{{hero.id}}</span> {{hero.name}}
    </a>
    <button class="delete" title="delete hero" (click)="delete(hero)" *appHasRole="'ADMIN'">x</button>
  </li>
</ul>

hero-detail.component.html

<div *ngIf="hero">
  <h2>{{hero.name | uppercase}} Details</h2>
  <div><span>id: </span>{{hero.id}}</div>
  <div>
    <label>name:
      <input [(ngModel)]="hero.name" placeholder="name"/>
    </label>
  </div>
  <button (click)="goBack()">go back</button>
  <button (click)="save()" *appHasRole="'ADMIN'">save</button>
</div>

heroes组件含有测试脚本heroes.component.spec.ts,需在TestBed.configureTestingModule的declarations中添加HasRoleDirective。

JWT集成完毕,来测试一下吧!
Angular 9集成Spring Boot 2详解

定制分页组件

NG-ZORRO提供pagination组件,为统一组件风格,自定义如下:

page.components.html

<div style="float: right;">
  <nz-pagination [nzTotal]="total" [(nzPageIndex)]="pageIndex" (nzPageIndexChange)="indexChange($event)" [(nzPageSize)]="pageSize" [nzPageSizeOptions]="pageSizeOptions"
                 (nzPageSizeChange)="sizeChange($event)" nzSize="small" nzShowSizeChanger [nzShowTotal]="totalTemplate">
  </nz-pagination>
  <ng-template #totalTemplate let-total>
    Total {{total}} items
  </ng-template>
</div>

page.components.ts

import {Component, EventEmitter, Input, Output} from '@angular/core';
import {DEFAULT_PAGE_SIZE} from '../page';

@Component({
  selector: 'app-pagination',
  templateUrl: './pagination.component.html'
})

export class PaginationComponent {
  @Input()
  total: number;

  @Input()
  pageIndex: number;

  @Input()
  pageSize = DEFAULT_PAGE_SIZE;

  pageSizeOptions = [10, 20, 30, 40];

  @Output()
  pageChange: EventEmitter<any> = new EventEmitter();

  indexChange(index: number) {
    this.pageChange.emit({pageIndex: index, pageSize: this.pageSize});
  }

  sizeChange(size: number) {
    this.pageChange.emit({pageIndex: 1, pageSize: size});
  }
}

分页查询

将heroes列表替换为nz-table,演示自定义分页组件的用法、分页查询方法。

page.ts
模仿Spring,定义Page和Pageable接口,提供分页查询参数封装方法。

import {HttpParams} from '@angular/common/http';

export const DEFAULT_PAGE_SIZE = 10;
export const EMPTY_PAGE: Page<any> = {content: [], number: 0, totalElements: 0, totalPages: 0};

export interface Page<T> {
  content: T[];
  totalPages: number;
  totalElements: number;
  number: number;
}

export interface Pageable {
  page: number;
  size: number;
  sort?: { key: string; value: string };
}

export class PageRequest implements Pageable {
  page = 1;
  size = DEFAULT_PAGE_SIZE;
  sort?: { key: string; value: string };
}

export function pageParams<T>(query?: T, pageable?: Pageable): HttpParams {
  let params = new HttpParams()
    .set('page', pageable ? (pageable.page - 1).toString() : '0')
    .set('size', pageable ? pageable.size.toString() : DEFAULT_PAGE_SIZE.toString());

  if (pageable && pageable.sort) {
    params = params.set('sort', pageable.sort.value === 'ascend' ? `${pageable.sort.key},ASC` : `${pageable.sort.key},DESC`);
  }

  if (query) {
    Object.keys(query).forEach(key => {
      let value = query[key];
      if (value === '') {
        return;
      }
      if (value instanceof Date) {
        value = value.toISOString();
      }
      params = params.set(key, value);
    });
  }

  return params;
}

hero.service.ts
修改getHeroes方法,增加Pageable参数。

getHeroes(pageable: Pageable): Observable<Page<Hero>> {
  return this.http.get<Page<Hero>>(this.heroesUrl, {params: pageParams(null, pageable)})
    .pipe(
      tap(() => this.log('fetched heroes')),
      catchError(this.handleError<Page<Hero>>('getHeroes', EMPTY_PAGE))
    );
}

heroes.component.html
使用nz-table替换列表,添加app-pagination,增加排序功能。

<div class="heroes">
  <nz-table #heroesTable [nzData]="heroes" nzFrontPagination="false" nzShowPagination="false">
    <thead (nzSortChange)="sortChanged($event)" nzSingleSort>
    <tr>
      <th>No</th>
      <th nzShowSort nzSortKey="name">Name</th>
      <th>Delete</th>
    </tr>
    </thead>

    <tbody>
    <tr *ngFor="let hero of heroesTable.data; let i = index">
      <td><span class="badge">{{i + 1}}</span></td>
      <td>
        <a routerLink="/detail/{{hero.id}}">{{hero.name}}</a>
      </td>
      <td>
        <button *appHasRole="'ADMIN'" class="delete" title="delete hero" (click)="delete(hero)">x</button>
      </td>
    </tr>
    </tbody>
  </nz-table>
  <app-pagination [total]="totalItems" [pageIndex]="pageable.page" (pageChange)="pageChanged($event)"></app-pagination>
</div>

heroes.component.ts

import {Component, OnInit} from '@angular/core';

import {Hero} from '../hero';
import {HeroService} from '../hero.service';
import {Pageable, PageRequest} from '../page';

@Component({
  selector: 'app-heroes',
  templateUrl: './heroes.component.html',
  styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
  heroes: Hero[];
  pageable: Pageable = new PageRequest();

  totalItems = 0;

  constructor(private heroService: HeroService) {
  }

  ngOnInit() {
    this.getHeroes();
  }

  getHeroes(): void {
    this.heroService.getHeroes(this.pageable)
      .subscribe(page => {
        this.heroes = page.content;
        this.totalItems = page.totalElements;
      });
  }

  pageChanged(event: any): void {
    console.log('Page changed to: ' + event.pageIndex);
    this.pageable.page = event.pageIndex;
    this.pageable.size = event.pageSize;
    this.getHeroes();
  }

  sortChanged(sort: { key: string; value: string }): void {
    this.pageable.sort = sort;
    this.pageable.page = 1;
    this.getHeroes();
  }

  add(name: string): void {
    name = name.trim();
    if (!name) {
      return;
    }
    this.heroService.addHero({name} as Hero)
      .subscribe(() => {
        this.pageable.page = 1;
        this.getHeroes();
      });
  }

  delete(hero: Hero): void {
    this.heroService.deleteHero(hero).subscribe(() => {
      this.pageable.page = 1;
      this.getHeroes();
    });
  }

}

国际化

国际化与本地化
国际化是一个设计和准备应用程序的过程,使其能用于不同的语言。 本地化是一个把国际化的应用针对部分区域翻译成特定语言的过程。

使用 Angular CLI 进行本地化的第一步是将 @angular/localize 包添加到项目中。这将在项目中安装这个包,并初始化项目以使用 Angular 的本地化功能。

ng add @angular/localize

Angular国际化主要涉及两个方面:管道和模板。

I18 管道
管道DatePipe、DecimalPipe、PercentPipe和CurrencyPipe都根据 LOCALE_ID来格式化数据。

默认,Angular只包含en-US的本地化数据。可以在angular.json的“configurations”中指定i18nLocale参数:

"configurations": {
  ...          
  "zh": {
     ...
     "i18nLocale": "zh"
         ...
   }
}

当使用ng serve、ng build的--configuration参数时,Angular CLI 会自动导入相应的本地化数据。

也可以在app.module.ts中注册:

import {registerLocaleData} from '@angular/common';
import zh from '@angular/common/locales/zh';

registerLocaleData(zh);

组件模板国际化

  1. 使用 i18n 属性标记要国际化的文本

以登录页面为为例:

<form nz-form [formGroup]="validateForm" class="login-form" (ngSubmit)="submitForm()">
  <nz-form-item>
    <nz-form-control i18n-nzErrorTip="@@usernameErrorTip" nzErrorTip="Please input your username!">
      <nz-input-group nzPrefixIcon="user">
        <input type="text" nz-input formControlName="username" i18n-placeholder="@@usernamePlaceholder" placeholder="Username"/>
      </nz-input-group>
    </nz-form-control>
  </nz-form-item>
  <nz-form-item>
    <nz-form-control i18n-nzErrorTip="@@passwordErrorTip" nzErrorTip="Please input your Password!">
      <nz-input-group nzPrefixIcon="lock">
        <input type="password" nz-input formControlName="password" i18n-placeholder="@@passwordPlaceholder" placeholder="Password"/>
      </nz-input-group>
    </nz-form-control>
  </nz-form-item>
  <nz-form-item>
    <nz-form-text i18n="@@loginTip">(Username: admin, Password: admin)</nz-form-text>
  </nz-form-item>
  <nz-form-item>
    <nz-form-control>
      <label nz-checkbox formControlName="remember">
        <span i18n="@@remember">Remember me</span>
      </label>
      <a class="login-form-forgot" class="login-form-forgot" i18n="@@forgotPassword">Forgot password</a>
      <button nz-button class="login-form-button" [nzType]="'primary'" [disabled]="!validateForm.valid" i18n="@@login">Log in</button>
      <ng-container i18n="@@or">Or </ng-container>
      <a i18n="@@register">register now!</a>
    </nz-form-control>
  </nz-form-item>
</form>

说明:

  • Angular的 i18n 提取工具会为模板中每个带有 i18n 属性的元素生成一个翻译单元(translation unit)条目,并保存到一个文件中。默认,为每个翻译单元指定一个唯一的 id:
<trans-unit id="ba0cc104d3d69bf669f97b8d96a4c5d8d9559aa3" datatype="html">

使用@@可以自定义id,这样避免了重新提取时id的变化,相同文本可以共用一个translation unit,让维护变得更简单。

  • 要把一个属性标记为需要国际化的,使用一个形如i18n-x的属性,其中的 x 是要国际化的属性的名字
  • 要翻译一段纯文本,又不希望创建一个新的 DOM 元素,可以把这段文本包到一个ng-container元素中
<h2>{{hero.name | uppercase}} <ng-container i18n="@@detail">Details</ng-container></h2>
  1. 使用Angular CLI的xi18n命令创建翻译文件
ng xi18n --output-path src/locale

xi18n支持三种文件格式xlf (XLIFF 1.2,默认)、xlf2(XLIFF 2)和xmb,可以使用 --i18nFormat 选项指定:

ng xi18n  --i18n-format=xlf

文件名默认为messages.xlf,可以使用--out-file指定:

ng xi18n --out-file source.xlf
  1. 翻译源文本

复制messages.xlf文件,命名为messages.zh.xlf,放到locale目录下,文件内容如下:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="login" datatype="html">
        <source>Login</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/login/login.component.html</context>
          <context context-type="linenumber">1</context>
        </context-group>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/login/login.component.html</context>
          <context context-type="linenumber">23</context>
        </context-group>
      </trans-unit>
            ...
    </body>
  </file>
</xliff>

在每个source标记下创建target标记,其中填写翻译后的内容:

<source>Login</source>
<target>登录</target>
  1. 合并已经翻译的文件

在angular.json文件中配置"i18n"信息:

"build": {
  ...
  "configurations": {
    ...
    "production-zh": {
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.prod.ts"
        }
      ],
      "optimization": true,
      "outputHashing": "all",
      "sourceMap": false,
      "extractCss": true,
      "namedChunks": false,
      "aot": true,
      "extractLicenses": true,
      "vendorChunk": false,
      "buildOptimizer": true,
      "outputPath": "dist/zh/",
      "baseHref": "/zh/",
      "i18nFile": "src/locale/messages.zh.xlf",
      "i18nFormat": "xlf",
      "i18nLocale": "zh",
      "i18nMissingTranslation": "error"
    },
    "zh": {
      "aot": true,
      "i18nFile": "src/locale/messages.zh.xlf",
      "i18nFormat": "xlf",
      "i18nLocale": "zh",
      "i18nMissingTranslation": "error"
    }
  }
},
"serve": {
  ...
  "configurations": {
    ...
    "zh": {
      "browserTarget": "angular-io-example:build:zh"
    }
  }
}

开发、编译时分别执行如下命令:

ng serve --configuration=zh
ng build --configuration=production-zh

多语言环境部署与切换
本例支持中、英两种语言,编译后目录分别为en、zh。为同时支持两种语言,需将两者都部署到服务器中,切换目录即可切换语言。

实现语言切换功能,修改AppComponent如下:
app.component.html:

<div nz-row>
  <div nz-col nzSpan="6"><h1>{{title}}</h1></div>
  <div nz-col nzSpan="2">{{currentDate | date}}</div>
  <div nz-col nzSpan="4">
    <nz-radio-group [(ngModel)]="selectedLanguage" (ngModelChange)="switchLanguage()" [nzButtonStyle]="'solid'">
      <label nz-radio-button [nzValue]="language.code" *ngFor="let language of supportLanguages">{{ language.label }}</label>
    </nz-radio-group>
  </div>
</div>
<nav>
  <a routerLink="/login" i18n="@@login">Login</a>
  <a routerLink="/dashboard" i18n="@@dashboard">Dashboard</a>
  <a routerLink="/heroes" i18n="@@heroes">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>

app.component.ts:

import {Component, Inject, LOCALE_ID} from '@angular/core';
import {en_US, NzI18nService, zh_CN} from 'ng-zorro-antd';
import {Title} from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'Tour of Heroes';
  selectedLanguage: string;
  currentDate: Date = new Date();

  supportLanguages = [
    {code: 'en', label: 'English'},
    {code: 'zh', label: '中文'}
  ];

  constructor(@Inject(LOCALE_ID) private localeId: string, private i18n: NzI18nService, private titleService: Title) {
    if (localeId === 'en-US') {
      this.selectedLanguage = 'en';
      this.title = 'Tour of Heroes';
      this.i18n.setLocale(en_US);
    } else {
      this.selectedLanguage = 'zh';
      this.title = '英雄之旅';
      this.i18n.setLocale(zh_CN);
    }
    this.titleService.setTitle(this.title);
  }

  switchLanguage() {
    window.location.href = `/${this.selectedLanguage}`;
  }
}

中文界面:
Angular 9集成Spring Boot 2详解

单元测试

单元测试使用Jasmine测试框架和Karma测试运行器。

单元测试的配置文件有karma.conf.js和test.ts。默认,测试文件扩展名为.spec.ts,使用Chrome浏览器进行测试。使用CLI创建component、service等时会自动创建测试文件。

运行单元测试:

ng test

在控制台和浏览器会输出测试结果:
Angular 9集成Spring Boot 2详解
浏览器显示总测试数、失败数,在顶部,每个点或叉对应一个测试用例,点表示成功,叉表示失败,鼠标移到点或叉上会显示测试信息。点击测试结果中的某一行,可重新运行某个或某组(测试套件)测试。代码修改后会重新运行测试。

运行单元测试时可生成代码覆盖率报告,报告保存在项目根目录下的coverage文件夹内:

ng test --watch=false --code-coverage

如想每次测试都生成报告,可修改CLI配置文件angular.json:

"test": {
  "options": {
    "codeCoverage": true
  }
}

可以设定测试覆盖率指标,编辑配置文件karma.conf.js,增加如下内容:

coverageIstanbulReporter: {
  reports: [ 'html', 'lcovonly' ],
  fixWebpackSourcePaths: true,
  thresholds: {
    statements: 80,
    lines: 80,
    branches: 80,
    functions: 80
  }
}

测试报告中达到标准的背景为绿色:
Angular 9集成Spring Boot 2详解

集成测试

集成测试使用Jasmine测试框架和Protractor end-to-end测试框架。

项目根目录e2e文件夹,其中包含集成测试配置protractor.conf.js和测试代码。测试文件扩展名必须为.e2e-spec.ts,默认使用Chrome浏览器。

修改app.e2e-spec.ts,添加login测试,完整代码请查看github,部分内容如下:

...
const targetHero = {id: 5, name: 'Magneta'};

...

  function getPageElts() {
    const navElements = element.all(by.css('app-root nav a'));

    return {
      navElts: navElements,

      appLoginHref: navElements.get(0),
      appLogin: element(by.css('app-root app-login')),
      loginTitle: element(by.css('app-root app-login > h2')),

      appDashboardHref: navElements.get(1),
      appDashboard: element(by.css('app-root app-dashboard')),
      topHeroes: element.all(by.css('app-root app-dashboard > div h4')),

      appHeroesHref: navElements.get(2),
      appHeroes: element(by.css('app-root app-heroes')),
      allHeroes: element.all(by.css('app-root app-heroes li')),
      selectedHeroSubview: element(by.css('app-root app-heroes > div:last-child')),

      heroDetail: element(by.css('app-root app-hero-detail > div')),

      searchBox: element(by.css('#search-box')),
      searchResults: element.all(by.css('.search-result li'))
    };
  }

...

  describe('Login tests', () => {
    beforeAll(() => browser.get(''));

    it('Title should be Login', () => {
      const page = getPageElts();
      expect(page.loginTitle.getText()).toEqual('Login');
    });

    it('can login', () => {
      element(by.css('#username')).sendKeys('admin');
      element(by.css('#password')).sendKeys('admin');
      element(by.buttonText('Login')).click();
    });

    it('has dashboard as the active view', () => {
      const page = getPageElts();
      expect(page.appDashboard.isPresent()).toBeTruthy();
    });
  });

运行集成测试:

ng e2e

测试结果:

Tutorial part 6

    Initial page
      √ has title 'Tour of Heroes'
      √ has h1 'Tour of Heroes'
      √ has views Login,Dashboard,Heroes
      √ has login as the active view

    Login tests
      √ Title should be Login
      √ can login
      √ has dashboard as the active view

    Dashboard tests
      √ has top heroes
      √ selects and routes to Magneta details
      √ updates hero name (MagnetaX) in details view
      √ cancels and shows Magneta in Dashboard
      √ selects and routes to Magneta details
      √ updates hero name (MagnetaX) in details view
      √ saves and shows MagnetaX in Dashboard

    Heroes tests
      √ can switch to Heroes view
      √ can route to hero details
      √ shows MagnetaX in Heroes list
      √ deletes MagnetaX from Heroes list
      √ adds back Magneta
      √ displays correctly styled buttons

    Progressive hero search
      √ searches for 'Ma'
      √ continues search with 'g'
      √ continues search with 'e' and gets Magneta
      √ navigates to Magneta details view

Executed 24 of 24 specs SUCCESS in 23 secs.

说明:

  1. 以上测试代码,后台启动后,仅第一次能成功运行。
  2. 浏览器驱动位于node_modules\protractor\node_modules\webdriver-manager\selenium目录下
  3. 为提高运行速度,不必每次运行测试都更新驱动
ng e2e --webdriver-update=false

CI集成

在CI环境中运行测试不必使用浏览器界面,因此需修改浏览器配置,启用no-sandbox(headless)模式。
karma.conf.js增加如下配置:

browsers: ['Chrome'],
customLaunchers: {
  ChromeHeadlessCI: {
    base: 'ChromeHeadless',
    flags: ['--no-sandbox']
  }
},

在e2e根目录下创建一名为protractor-ci.conf.js的新文件,内容如下:

const config = require('./protractor.conf').config;

config.capabilities = {
  browserName: 'chrome',
  chromeOptions: {
    args: ['--headless', '--no-sandbox']
  }
};

exports.config = config;

注意: windows系统要增加参数--disable-gpu

测试命令如下:

ng test --watch=false --progress=false --browsers=ChromeHeadlessCI
ng e2e --protractor-config=e2e\protractor-ci.conf.js

覆盖率报告目录下的文件lcov.info可与Sonar集成,在Sonar管理界面配置LCOV Files路径,即可在Sonar中查看测试情况:
Angular 9集成Spring Boot 2详解
与Jenkins集成同样使用Jenkinsfile,示例如下:

node {
    checkout scm
    stage('install') {
      bat 'npm install'
    }
    stage('test') {
      bat 'ng test --watch=false --progress=false --code-coverage --browsers=ChromeHeadlessCI'
      bat 'ng e2e --protractor-config=e2e\protractor-ci.conf.js'
    }
    stage('sonar-scanner') {
      bat 'sonar-scanner -Dsonar.projectKey=heroes-web -Dsonar.sources=src -Dsonar.typescript.lcov.reportPaths=coverage\lcov.info 
            -Dsonar.host.url=http://127.0.0.1:9000/sonar -Dsonar.login=1596abae7b68927b1cecd276d1b5149e86375cb2'
    }
    stage('build') {
      bat 'ng build --prod --base-href=/heroes/'
    }
}

说明:

  1. Sonar需安装SonarTS插件
  2. Jenkins服务器需安装Node.js、Angular CLI、sonar-scanner和Chrome。

部署

Spring Boot

运行以下命令打包:

mvn clean package -Dmaven.test.skip=true -Pprod

简易方式

将heroes-api-1.0.0.jar拷贝到目标机器,直接运行jar:

nohup java -jar heroes-api-1.0.0.jar &

Docker部署

Dockerfile:

FROM openjdk:8-jdk-slim

WORKDIR app
ARG APPJAR=target/heroes-api-1.0.0.jar
COPY ${APPJAR} app.jar

ENTRYPOINT ["java","-jar","app.jar"]

构建image:

docker build --build-arg APPJAR=path/to/heroes-api-1.0.0.jar -t heroes-api .

运行container:

docker run -d -p 8080:8080 --restart always --name heroes-api heroes-api

Angular

执行以下命令编译:

ng build --prod
ng build --configuration=production-zh

简易方式
以部署到Apache Server为例,将dist目录下的文件拷贝到Apache的html目录下,在httpd.conf文件中添加如下内容:

RewriteEngine  on
RewriteRule ^/$ /en/index.html

# If an existing asset or directory is requested go to it as it is
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} -f [OR]
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} -d
RewriteRule ^ - [L]

# If the requested resource doesn't exist, use index.html
RewriteRule ^/zh /zh/index.html
RewriteRule ^/en /en/index.html

Docker部署

Dockerfile:

FROM httpd:2.4

ARG DISTPATH=./dist/
ARG CONFFILE=./heroes-httpd.conf
COPY ${DISTPATH} /usr/local/apache2/htdocs/
COPY ${CONFFILE} /usr/local/apache2/conf/httpd.conf

获取httpd.conf:

docker run --rm httpd:2.4 cat /usr/local/apache2/conf/httpd.conf > heroes-httpd.conf

修改heroes-httpd.conf,然后构建image:

docker build -t heroes-web .

运行container:

docker run -d -p 80:80 --restart always --name heroes-web heroes-web

附录

如何配置审计日志

增加一个appender,配置一个单独的日志文件;再增加一个logger,注意要配置additivity="false",这样写audit日志时不会写到其他层次的日志中。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProfile name="dev">
        <property name="LOG_FILE" value="heroes.log"/>
        <property name="AUDIT_FILE" value="audit.log"/>
    </springProfile>
    <springProfile name="prod">
        <property name="LOG_FILE" value="/var/log/heroes.log"/>
        <property name="AUDIT_FILE" value="/var/log/audit.log"/>
    </springProfile>

    <include resource="org/springframework/boot/logging/logback/base.xml"/>

    <logger name="root" level="WARN"/>

    <appender name="AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %5p --- %m%n</pattern>
        </encoder>
        <file>${AUDIT_FILE}</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
            <fileNamePattern>${AUDIT_FILE}.%i</fileNamePattern>
        </rollingPolicy>
        <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
            <MaxFileSize>10MB</MaxFileSize>
        </triggeringPolicy>
    </appender>

    <logger name="audit" level="info" additivity="false">
        <appender-ref ref="AUDIT"/>
    </logger>

    <springProfile name="dev">
        <logger name="root" level="INFO"/>
    </springProfile>
    <springProfile name="prod">
        <logger name="root" level="INFO"/>
    </springProfile>
</configuration>

调用:

private static final Logger logger = LoggerFactory.getLogger("audit");

自动重启

开发Angular时,运行ng serve,代码改变后会自动重新编译。Spring Boot有这样的功能么?可以增加spring-boot-devtools实现:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <optional>true</optional>
</dependency>

参考文档

Angular
Spring Boot
JWT Libraries
JSON Web Tokens (JWT) in Auth0
Springfox Swagger
Postman
Angular Security - Authentication With JSON Web Tokens (JWT): The Complete Guide
Integrating Angular 2 with Spring Boot, JWT, and CORS, Part 1
Integrating Angular 2 with Spring Boot, JWT, and CORS, Part 2
Spring Boot REST – request validation
The logback manual
测试框架-Jasmine
Lombok 介绍
Project Lombok