Angular 9集成Spring Boot 2詳解

主要內容: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等。javascript

本文參考了Rich Freedman先生的博客"Integrating Angular 2 with Spring Boot, JWT, and CORS",使用了部分代碼(tour-of-heroes-jwt-full),博客地址請見文末參考文檔。前端基於Angular官方樣例Tour of Heroes。完整源碼請從github下載:heroes-api, heroes-webcss

說明:最新代碼使用Keycloak進行認證與受權,刪除了原JWT、用戶、權限、登陸等相關代碼,本文檔代碼保存在jwt-1.0.0 branch。html

技術堆棧

  • 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 Codejava

Java代碼中使用了lombok註解,IDE需安裝lombok插件。node

Spring Boot

建立Spring Boot App

建立Spring Boot項目最簡易的方式是使用SPRING INITIALIZR
Angular 9集成Spring Boot 2詳解
輸入Group、Artifact,選擇Dependency(Web、JPA、Security、Actuator、H二、PostgreSQL、Lombok)後,點擊Generate,會自動下載zip包。linux

解壓zip包,能夠發現Initializr工具爲咱們建立了基本目錄結構,配置了POM依賴,生成了SpringBootApplication類。繼續以前,咱們先啓動程序,看一下最初的樣子,進入根目錄執行如下命令:git

mvn spring-boot:run

訪問 http://localhost:8080/ 。由於咱們添加了Security依賴,因此會自動啓用用戶驗證。github

Angular 9集成Spring Boot 2詳解

默認用戶名爲"user",密碼顯示在console log中。web

接下來,編輯POM文件,添加java-jwt、springfox-swagger和MapStruct。咱們選用了兩個數據庫H二、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、 Log4J二、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

相關文章
相關標籤/搜索