主要內容: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-web 。css
說明:最新代碼使用Keycloak進行認證與受權,刪除了原JWT、用戶、權限、登陸等相關代碼,本文檔代碼保存在jwt-1.0.0 branch。html
MapStruct前端
測試工具: Postman
代碼質量檢查: Sonar
CI: Jenkins
推薦IDE: IntelliJ IDEA、WebStorm/Visual Studio Codejava
Java代碼中使用了lombok註解,IDE需安裝lombok插件。node
建立Spring Boot項目最簡易的方式是使用SPRING INITIALIZR
輸入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
默認用戶名爲"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>
默認,Spring Boot從下列位置加載 application.properties 或 application.yml 配置文件,優先級從高到低依次是:
爲適應不一樣的環境,可配置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: charset: utf-8 image: location: classpath:banner.jpg location: classpath:banner.txt resources: add-mappings: true
Spring Boot啓動時會在控制檯輸出Banner信息,支持文本和圖片。圖片支持gif、jpg、png等格式,會轉換成ASCII碼輸出。
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);
說明:
"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用於展現層與服務層之間的數據傳輸。
@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); } }
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的使用。使用多個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); } }
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); } }
在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,則會顯示以下信息:
HeroController中沒有處理異常的代碼,如數據操做失敗會返回什麼結果呢?例如,添加了重複的記錄,會顯示以下信息:
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); } }
更通用的方法是使用全局異常處理機制,建立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; } }
再次測試,輸出結果以下:
說明:
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)
在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用來監控和管理應用,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
增長以下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
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任務
JSON Web Token (JWT) 是一個開放標準 (RFC 7519) ,定義了一種緊湊、自包含、安全地傳輸JSON 對象信息的方式。此信息經數字簽名,所以是可驗證、可信的。JWT可使用密鑰或公鑰/私鑰對簽名。
JWT由三部分Base64編碼的字符串組成,各部分以點分隔:
好比,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。
驗證流程
身份驗證時,用戶使用其憑據成功登陸後,將返回 JSON Web Token。
用戶訪問受保護的資源時,發送JWT,一般以Bearer模式在Authorization header中發送:
Authorization: Bearer <token>
JWT驗證機制是無狀態的,Server並不保存用戶狀態。JWT包含了必要的信息,減小了數據庫查詢。
咱們使用了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時能夠在運行時更改私鑰或公鑰。
示例
Algorithm algorithm = Algorithm.HMAC256("secret"); String token = JWT.create().withIssuer("auth0").sign(algorithm);
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);
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.eyJpc3MiOiJhdXRoMCJ9.AbIJTDMFc7yUa5MhvcP03nJPyCPzZtQcGEp-zWfOkEE"; Algorithm algorithm = Algorithm.HMAC256("secret"); JWTVerifier verifier = JWT.require(algorithm).withIssuer("auth0").build(); DecodedJWT jwt = verifier.verify(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); } }
實現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())); } }
從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); } }
咱們未用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中配置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; } }
說明:
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。
此時如請求localhost:8080/api/heroes會輸出401錯誤,將token填到Authorization header中,則可查詢出hero。
說明:用戶"admin"能夠執行CRUD操做,"jason"只有查詢權限。
常有這樣的需求,新增、更新數據庫時記錄建立人、建立時間、修改人、修改時間,如手工更新這些字段比較煩瑣,Spring Data的Auditing支持此功能。
使用方法:
@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;
@Entity @EntityListeners(AuditingEntityListener.class) @Table(name = "HERO", uniqueConstraints = {@UniqueConstraint(name = "UK_HERO_NAME", columnNames = {"HERO_NAME"})}) public class Hero { ... }
@SpringBootApplication @EnableJpaAuditing public class HeroesApplication { ... }
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環境
針對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是實現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>
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
Swagger UI: http://localhost:8080/swagger-ui.html
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); }
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測試有如下優勢:
獲取Token
依次點擊Authentication Controller -> /api/auth -> Try it out -> (修改username和password)-> Excute,成功後會輸出token。
受權
點擊頁面右上方的Authorize,輸入Bearer token。
受權後便可進行其餘測試。
npm -v
更新npm:
npm i npm@latest -g
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是阿里出品的企業級Angular UI組件。在示例中,咱們將使用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官網的每一個代碼演示都附有模板,點擊底部圖標能夠複製生成代碼命令來快速生成代碼。
生成登錄組件的命令以下:
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 { }
經常使用模塊
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 中的經常使用指令。
Angular 提供了兩種不一樣的表單處理用戶輸入:響應式表單和模板驅動表單。二者都從視圖中捕獲用戶輸入事件、驗證用戶輸入、建立表單模型、更新數據模型,並提供跟蹤這些更改的途徑。
響應式表單和模板驅動表單優缺點:
Tour of Heroes使用了模板驅動表單,咱們建立的登陸組件使用了響應式表單。
Tour of Heroes使用了「in-memory-database」,咱們刪除相關內容改成調用Spring Boot Rest API。
修改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' // 修改成生產域名 };
${environment.apiUrl}/api/heroes
" :import {environment} from '../environments/environment'; ... private heroesUrl = `${environment.apiUrl}/api/heroes`;
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(); }; }
ng serve
因未登陸獲取token,此時訪問會顯示如下錯誤:
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接口,代碼以下:
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]} ];
請求需認證的REST服務時,須要在HTTP Header中添加Bearer Token,有兩種添加方式:
const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json'}), 'Authorization': 'Bearer ' + this.authenticationService.getToken() };
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集成完畢,來測試一下吧!
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);
組件模板國際化
以登陸頁面爲爲例:
<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>
說明:
<trans-unit id="ba0cc104d3d69bf669f97b8d96a4c5d8d9559aa3" datatype="html">
使用@@能夠自定義id,這樣避免了從新提取時id的變化,相同文本能夠共用一個translation unit,讓維護變得更簡單。
<h2>{{hero.name | uppercase}} <ng-container i18n="@@detail">Details</ng-container></h2>
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
複製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>
在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}`; } }
中文界面:
單元測試使用Jasmine測試框架和Karma測試運行器。
單元測試的配置文件有karma.conf.js和test.ts。默認,測試文件擴展名爲.spec.ts,使用Chrome瀏覽器進行測試。使用CLI建立component、service等時會自動建立測試文件。
運行單元測試:
ng test
在控制檯和瀏覽器會輸出測試結果:
瀏覽器顯示總測試數、失敗數,在頂部,每一個點或叉對應一個測試用例,點表示成功,叉表示失敗,鼠標移到點或叉上會顯示測試信息。點擊測試結果中的某一行,可從新運行某個或某組(測試套件)測試。代碼修改後會從新運行測試。
運行單元測試時可生成代碼覆蓋率報告,報告保存在項目根目錄下的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 } }
測試報告中達到標準的背景爲綠色:
集成測試使用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.
說明:
ng e2e --webdriver-update=false
在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中查看測試狀況:
與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/' } }
說明:
運行如下命令打包:
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
執行如下命令編譯:
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