跳至主要内容

Build a Reactive application with Angular 5 and Spring Boot 2.0

I have created a post to describe Reactive programming supports in Spring 5 and its subprojects, all codes of this article are updated the latest Spring 5 RELEASE, check spring-reactive-sample under my Github account.
In this post, I will create a simple blog system, including:
  • A user can sign in and sign out.
  • An authenticated user can create a post.
  • An authenticated user can update a post.
  • Only the user who has ADMIN role can delete a post.
  • All users(including anonymous users) can view post list and post details.
  • An authenticated user can add his comments to a certain post.
The backend will be built with the latest Spring 5 reactive stack, including:
  • Spring Boot 2.0, at the moment the latest version is 2.0.0.M7
  • Spring Data MongoDB supports reactive operations for MongoDB
  • Spring Session adds reactive support for WebSession
  • Spring Security 5 aligns with Spring 5 reactive stack
The frontend is an Angular based SPA and it will be generated by Angular CLI.
The source code is hosted on Github, check here.

Backend APIs

Prerequisites

Make sure you have already installed the following software.

Generate the project skeleton

The quickest approach to start a Spring Boot based is using the Spring initializr.
Open browser, and navigate to http://start.spring.io.
start a project
Choose the following stack:
  • Use the default Maven as building tool, and Java as programming language
  • Set Spring Boot version to 2.0.0.M7
  • In the search dependencies box, search reactive, select Reactive Web, Reactive MongoDB in the search results, and then add Security, Session, Lombok in the same way etc.
Hint Generate Project button or press ALT+ENTER keys to download the generated codes.
Extract files into your local system, and import it into your favorite IDE.

Produces RESTful APIs

Let's start with cooking the Post APIs, the expected APIs are listed below.
URI request response description
/posts GET [{id:'1', title:'title'}, {id:'2', title:'title 2'}] Get all posts
/posts POST {title:'title',content:'content'} {id:'1', title:'title',content:'content'} Create a new post
/posts/{id} GET {id:'1', title:'title',content:'content'} Get a post by id
/posts/{id} PUT {title:'title',content:'content'} {id:'1', title:'title',content:'content'} Update specific post by id
/posts/{id} DELETE no content Delete a post by id
Create a Post POJO class. Add Spring Data MongoDB specific @Document annotation on this class to indicate it is a MongoDB document.
@Document
@Data
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
class Post implements Serializable {

    @Id
    private String id;

    @NotBlank
    private String title;

    @NotBlank
    private String content;
}
To remove getters and setters, toString, equals, hashCode methods from Post and make the source code looks clearly, let's use Lombok specific annotations to generate these required facilities at compile time via annotation processor tooling support.
Create a PostRepository interface for Post document. Make sure it is extended from ReactiveMongoRepository, which is the reactive variant of MongoRepository interface and it is ready for reactive operations.
interface PostRepository extends ReactiveMongoRepository<Post, String> {
}
Let's create a PostController class to expose RESTful APIs.
@RestController()
@RequestMapping(value = "/posts")
class PostController {

    private final PostRepository posts;

    public PostController(PostRepository posts) {
        this.posts = posts;
    }

    @GetMapping("")
    public Flux<Post> all() {
        return this.posts.findAll();
    }

    @PostMapping("")
    public Mono<Post> create(@RequestBody Post post) {
        return this.posts.save(post);
    }

    @GetMapping("/{id}")
    public Mono<Post> get(@PathVariable("id") String id) {
        return this.posts.findById(id);
    }

    @PutMapping("/{id}")
    public Mono<Post> update(@PathVariable("id") String id, @RequestBody Post post) {
        return this.posts.findById(id)
                .map(p -> {
                    p.setTitle(post.getTitle());
                    p.setContent(post.getContent());

                    return p;
                })
                .flatMap(p -> this.posts.save(p));
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(NO_CONTENT)
    public Mono<Void> delete(@PathVariable("id") String id) {
        return this.posts.deleteById(id);
    }

}
It is very similar with traditional Servlet based @RestController, the difference is here we use Reactor specific Mono and Flux as return result type.
NOTE: Spring 5 also provides functional programming experience, check spring-reactive-sample for more details.
Create a CommandLineRunner component to insert some dummy data when the application is started.
@Component
@Slf4j
class DataInitializer implements CommandLineRunner {

    private final PostRepository posts;

    public DataInitializer(PostRepository posts) {
        this.posts = posts;
    }

    @Override
    public void run(String[] args) {
        log.info("start data initialization  ...");
        this.posts
                .deleteAll()
                .thenMany(
                        Flux
                                .just("Post one", "Post two")
                                .flatMap(
                                        title -> this.posts.save(Post.builder().title(title).content("content of " + title).build())
                                )
                )
                .log()
                .subscribe(
                        null,
                        null,
                        () -> log.info("done initialization...")
                );

    }

}
Now we are almost ready to start up the application. But before this you have to make sure there is a running MongoDB instance.
I have prepared docker-compose.yml in the root folder, utilize it, we can bootstrap a MongoDB instance quickly in Docker container.
Open your terminal, and switch to the root folder of this project, run the following command to start a MongoDB service.
docker-compose up mongodb
NOTE: You can also install a MongoDB server in your local system instead.
Now try to execute mvn spring-boot:run to start up the application.
Or click Run action in the project context menu in your IDE.
When it is started, open another terminal window, use curl or httpie to have a test.
curl http://localhost:8080/posts
You should see some result like this.
curl http://localhost:8080/posts
[{"id":"5a584469a5f5c7261cb548e2","title":"Post two","content":"content of Post two"},{"id":"5a584469a5f5c7261cb548e1","title":"Post one","content":"content of Post one"}]
Great! it works.
Next let's add access control rules to protect the Post APIs.

Protect APIs

Like traditional Servlet based MVC, when spring-security-web is existed in the classpath of a webflux application, Spring Boot will configure security automatically.
To customize security configuration, create a standalone @Configuration class.
@Configuration
class SecurityConfig {

    @Bean
    SecurityWebFilterChain springWebFilterChain(ServerHttpSecurity http) throws Exception {

        //@formatter:off
        return http
                    .csrf().disable()
                .and()
                    .authorizeExchange()
                    .pathMatchers(HttpMethod.GET, "/posts/**").permitAll()
                    .pathMatchers(HttpMethod.DELETE, "/posts/**").hasRole("ADMIN")
                    .pathMatchers("/posts/**").authenticated()
                    .pathMatchers("/user").authenticated()
                    .pathMatchers("/users/{user}/**").access(this::currentUserMatchesPath)
                    .anyExchange().permitAll()
                .and()
                    .build();
        //@formatter:on
    }

    private Mono<AuthorizationDecision> currentUserMatchesPath(Mono<Authentication> authentication, AuthorizationContext context) {
        return authentication
            .map(a -> context.getVariables().get("user").equals(a.getName()))
            .map(AuthorizationDecision::new);
    }

    @Bean
    public ReactiveUserDetailsService userDetailsService(UserRepository users) {
        return (username) -> users.findByUsername(username)
            .map(u -> User.withUsername(u.getUsername())
                .password(u.getPassword())
                .authorities(u.getRoles().toArray(new String[0]))
                .accountExpired(!u.isActive())
                .credentialsExpired(!u.isActive())
                .disabled(!u.isActive())
                .accountLocked(!u.isActive())
                .build()
            );
    }
}
In this configuration, we defined a SecurityWebFilterChain bean to change default security path matching rules as expected. And we have to declare a ReactiveUserDetailsService bean to customize the UserDetailsService, eg. fetching users from our MongoDB.
Create the required User document and UserRepository interface.
@Data
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document
class User {

    @Id
    private String id;
    private String username;

    @JsonIgnore
    private String password;

    @Email
    private String email;

    @Builder.Default()
    private boolean active = true;

    @Builder.Default()
    private List<String> roles = new ArrayList<>();

}
In UserRepository, add a new findByUsername method to query user by username, it returns Mono<User>.
public interface UserRepository extends ReactiveMongoRepository<User, String> {

    Mono<User> findByUsername(String username);
}
Now let's handle authentication.
In the real world, in order to protect REST APIs, token based authentication is mostly used.
Spring Session provides a simple strategy to expose the session id in http response headers and check validation of session id in http request headers.
Currently, Spring Session provides reactive supports for Redis and MongoDB. In this project, we use MongoDB as an example.
Add spring-session-data-mongodb into the project classpath.
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-mongodb</artifactId>
</dependency>
To force HTTP BASIC authentication to manage sessions by MongoDB, add the following configuration in SecurityConfig.
http
  .csrf().disable()
  .httpBasic().securityContextRepository(new WebSessionServerSecurityContextRepository())
.and()
Declare WebSessionIdResolver bean to use HTTP header to resolve session id instead of cookie.
@Configuration
@EnableMongoWebSession
class SessionConfig {

    @Bean
    public WebSessionIdResolver webSessionIdResolver() {
        HeaderWebSessionIdResolver resolver = new HeaderWebSessionIdResolver();
        resolver.setHeaderName("X-AUTH-TOKEN");
        return resolver;
    }
}
Expose current user by REST APIs.
@GetMapping("/user")
public Mono<Map> current(@AuthenticationPrincipal Mono<Principal> principal) {
    return principal
            .map( user -> {
                Map<String, Object> map = new HashMap<>();
                map.put("name", user.getName());
                map.put("roles", AuthorityUtils.authorityListToSet(((Authentication) user)
                        .getAuthorities()));
                return map;
            });
}
When the user is authenticated, the user info can be fetched from an injected Principal.
Add the initial users in the DataInitializer bean.
this.users
    .deleteAll()
    .thenMany(
        Flux
            .just("user", "admin")
            .flatMap(
                username -> {
                    List<String> roles = "user".equals(username)
                        ? Arrays.asList("ROLE_USER")
                        : Arrays.asList("ROLE_USER", "ROLE_ADMIN");

                    User user = User.builder()
                        .roles(roles)
                        .username(username)
                        .password(passwordEncoder.encode("password"))
                        .email(username + "@example.com")
                        .build();
                    return this.users.save(user);
                }
            )
    )
    .log()
    .subscribe(
        null,
        null,
        () -> log.info("done users initialization...")
    );
Restart the application and have a try.
curl -v http://localhost:8080/auth/user -u user:password
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
* Server auth using Basic with user 'user'
> GET /auth/user HTTP/1.1
> Host: localhost:8080
> Authorization: Basic dXNlcjpwYXNzd29yZA==
> User-Agent: curl/7.57.0
> Accept: */*
>
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: application/json;charset=UTF-8
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XSS-Protection: 1 ; mode=block
< X-AUTH-TOKEN: 39af0166-0f5f-4a1a-a955-21340b0b31b1
<
{"roles":["ROLE_USER"],"name":"user"}* Connection #0 to host localhost left intact
As you see, there is a X-AUTH-TOKEN header in the response headers when the authentication is successful.
Try to add X-AUTH-TOKEN to HTTP request headers and access the protected APIs.
curl -v http://localhost:8080/auth/user  -H "X-AUTH-TOKEN: 39af0166-0f5f-4a1a-a955-21340b0b31b1"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /auth/user HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.57.0
> Accept: */*
> X-AUTH-TOKEN: 39af0166-0f5f-4a1a-a955-21340b0b31b1
>
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: application/json;charset=UTF-8
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XSS-Protection: 1 ; mode=block
<
{"roles":["ROLE_USER"],"name":"user"}* Connection #0 to host localhost left intact
Yeah, it works as expected. You can try more example on other protected path, such as creating and updating posts.

Exception Handling

For traditional Servlet stack, we can use @ExceptionHandler to handle the exceptions and convert them into HTTP friendly messages.
In a webflux based application, we can declare a WebExceptionHanlder bean to archive this purpose.
For example, if there is no posts found by id, throw a PostNotFoundException, and finally convert it to a 404 error to HTTP client.
Declare PostNotFoundExcpetion as a RuntimeException.
public class PostNotFoundException extends RuntimeException {
    public PostNotFoundException(String id) {
        super("Post:" + id +" is not found.");
    }
}
Throw an PostNotFoundException when it is not found, eg. the get method in PostController.
@GetMapping("/{id}")
public Mono<Post> get(@PathVariable("id") String id) {
    return this.posts.findById(id).switchIfEmpty(Mono.error(new PostNotFoundException(id)));
}
Declare a WebExceptionHanlder bean to handle PostNotFoundException.
@Component
@Order(-2)
@Slf4j
public class RestExceptionHandler implements WebExceptionHandler {

private ObjectMapper objectMapper;

    public RestExceptionHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
      if (ex instanceof PostNotFoundException) {
            exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);

            // marks the response as complete and forbids writing to it
            return exchange.getResponse().setComplete();
        }
        return Mono.error(ex);
    }
}    
For the bean validations, we can convert the WebExchangeBindException to a UnprocessableEntity error to client, please see the complete codes of RestExceptionHandler for more details.
In the backend codes, I have added some features mentioned at the beginning of this post, such as comment endpoints, and also tried to add pagination, and data auditing feature(when it is ready).
Next let's try to build a simple Angular frontend application to shake hands with the backend APIs.

Client

Prerequisites

Make sure you have already installed the following software.
  • The latest NodeJS, I used NodeJS 9.4.0 at the moment.
  • Your favorite code editors, VS Code, Atom Editor, Intellij WebStorm etc.
Install Angular CLI globally.
npm install -g @angular/cli
Make sure it is installed correctly.
> ng -v                                                              
                                                                     
    _                      _                 ____ _     ___          
   / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|         
  / △ \ | '_ \ / _` | | | | |/ _` | '__|  | |   | |    | |          
 / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |          
/_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|         
               |___/                                                 
                                                                     
Angular CLI: 1.6.3                                                   
Node: 9.4.0                                                          
OS: win32 x64                                                        
Angular:                                                             

Prepare client project skeleton

Open your terminal, execute the following command to generate an Angular project.
ng new client
Install Angular Material, Angular FlexLayout from Angular team.
npm install --save @angular/material @angular/cdk  @angular/flex-layout
Some Material modules depend on @angular/animations. Import BrowserAnimationsModule in AppModule.
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';

@NgModule({
  ...
  imports: [BrowserAnimationsModule],
  ...
})
export class AppModule { }
Open polyfills.ts, uncomment the following line,
import 'web-animations-js';
Then install web-animations polyfill.
npm install --save web-animations-js
To enable gesture in Angular Material, install hammerjs.
npm install --save hammerjs
Import it in polyfills.ts.
import 'hammerjs';

Generate the project structure

Follow the official Angular Style Guide, use ng command to generate expected modules and components. We will enrich them later.
ng g module home --routing=true
ng g module auth --routing=true
ng g module post --routing=true
ng g module user --routing=true
ng g module core
ng g module shared

ng g c home --module home
ng g c post/post-list --module post
ng g c post/post-form --module post
ng g c post/new-post --module post
ng g c post/edit-post --module post
ng g c post/post-details --module post
ng g c user/profile --module user
ng g c auth/signin --module auth
To simplify the coding work, I port my former Angular 2.x work to this project, more about the Angular development steps, check the wiki pages.
Angular 4.x introduced new HttpClientModule(located in @angular/common/http) instead of the original @angular/http module. I updated the original codes to use HttpClientModule to interact with REST APIs in this project.

Interact REST APIs with HttpClientModule

Let's have a look at the refreshed PostService. Here we use new HttpClient to replace the legacy Http, the usage of HttpClient is similar with Http, the most difference is its methods return an Observable by default.
const apiUrl = environment.baseApiUrl + '/posts';

@Injectable()
export class PostService {

  constructor(private http: HttpClient) { }

  getPosts(term?: any): Observable<any> {
    const params: HttpParams = new HttpParams();
    Object.keys(term).map(key => {
      if (term[key]) { params.set(key, term[key]); }
    });
    return this.http.get(`${apiUrl}`, { params });
  }

  getPost(id: string): Observable<any> {
    return this.http.get(`${apiUrl}/${id}`);
  }

  savePost(data: Post) {
    console.log('saving post:' + data);
    return this.http.post(`${apiUrl}`, data);
  }

  updatePost(id: string, data: Post) {
    console.log('updating post:' + data);
    return this.http.put(`${apiUrl}/${id}`, data);
  }

  deletePost(id: string) {
    console.log('delete post by id:' + id);
    return this.http.delete(`${apiUrl}/${id}`);
  }

  saveComment(id: string, data: Comment) {
    return this.http.post(`${apiUrl}/${id}/comments`, data);
  }

  getCommentsOfPost(id: string): Observable<any> {
    return this.http.get(`${apiUrl}/${id}/comments`);
  }

}
Another awesome feature of the HttpClientModule is it added the long-awaited HttpInterceptor officially.
We do not need @covalent/http to get interceptor support now.
const TOKEN_HEADER_KEY = 'X-AUTH-TOKEN';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  constructor(private token: TokenStorage, private router: Router) { }

  intercept(req: HttpRequest<any>, next: HttpHandler):
    Observable<HttpSentEvent | HttpHeaderResponse | HttpProgressEvent | HttpResponse<any> | HttpUserEvent<any>> {

    if (this.token.get()) {
      console.log('set token in header ::' + this.token.get());
      req.headers.set(TOKEN_HEADER_KEY, this.token.get());
    }

    return next.handle(req).do(
      (event: HttpEvent<any>) => {
        if (event instanceof HttpResponse) {
          const token = event.headers.get(TOKEN_HEADER_KEY);
          if (token) {
            console.log('saving token ::' + token);
            this.token.save(token);
          }
        }
      },
      (err: any) => {
        if (err instanceof HttpErrorResponse) {
          console.log(err);
          console.log('req url :: ' + req.url);
          if (!req.url.endsWith('/auth/user') && err.status === 401) {
            this.router.navigate(['', 'auth', 'signin']);
          }
        }
      }
    );
  }

}
Compare the HTTP interceptor provided in @covalent/http, the official HttpInterceptor is more flexible, and it adopts the middleware concept.

Source codes

Check the complete source codes from my github.
发表评论

此博客中的热门博文

JPA 2.1: Attribute Converter

JPA 2.1: Attribute Converter If you are using Hibernate, and want a customized type is supported in your Entity class, you could have to write a custom Hibernate Type. JPA 2.1 brings a new feature named attribute converter, which can help you convert your custom class type to JPA supported type. Create an Entity Reuse thePostentity class as example. @Entity @Table(name="POSTS") public class Post implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name="ID") private Long id; @Column(name="TITLE") private String title; @Column(name="BODY") private String body; @Temporal(javax.persistence.TemporalType.DATE) @Column(name="CREATED") private Date created; @Column(name="TAGS") private List<String> tags=new ArrayList<>(); } Create an attribute converter In this example…

Auditing with Hibernate Envers

Auditing with Hibernate Envers The approaches provided in JPA lifecyle hook and Spring Data auditing only track the creation and last modification info of an Entity, but all the modification history are not tracked. Hibernate Envers fills the blank table. Since Hibernate 3.5, Envers is part of Hibernate core project. Configuration Configure Hibernate Envers in your project is very simple, just need to addhibernate-enversas project dependency. <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-envers</artifactId> </dependency> Done. No need extra Event listeners configuration as the early version. Basic Usage Hibernate Envers provides a simple@Auditedannotation, you can place it on an Entity class or property of an Entity. @Audited private String description; If@Auditedannotation is placed on a property, this property can be tracked.

Spring Data Mongo: bridge MongoDB and Spring

Spring Data Mongo: bridge MongoDB and SpringMongoDBis one of the most popular NoSQL products, Spring Data Mongo(Maven archetype id is spring-data-mongodb) tries to provides a simple approach to access MongoDB. Configuration Add the following code fragments in your Spring configuration. <!-- Mongo config --> <mongo:db-factory id="mongoDbFactory" host="localhost" port="27017" dbname="conference-db" /> <bean id="mongoTemplate" class="org.springframework.data.mongodb.core.MongoTemplate"> <constructor-arg ref="mongoDbFactory" /> </bean> <mongo:repositories base-package="com.hantsylabs.example.spring.mongo" /> Firstly, declare aDbFactorywhich is responsible for connecting to the MongoDB server. Then, register theMongoTemplateobject which envelop the data operations on MongoDB. Lastly addmongo:repositorieswith an essentialbase-packageattribute, Spring will discovery your d…