Spring boot ตอนที่ 3 – กรณีศึกษา MyFacebook web (2)

spring-boot-logo

มาถึงส่วนที่ยากที่สุดของการใช้ spring framework แล้วครับ นั่นคือใช้ spring security ทำ register และ login ถ้าใครผ่านจุดนี้ได้ถือว่าอยู่ร่วมกับ spring framework ได้อย่างไม่มีปัญหาแล้วครับ เรียกได้ว่าถ้าไม่ชอบก็คือเกลียด spring ไปเลย ถ้าท่านใหนผ่านบทนี้ได้ผมขอแสดงความยินดีด้วยครับล่วงหน้าครับ ในบทนี้ใช้เครื่องมือดังนี้

  • Spring Data JPA ในส่วนของการเชื่อมต่อกับฐานข้อมูล
  • Spring security เพื่อตรวจสอบ login

updated
รวมลิงก์ของทุกตอนครับ

Register Page

  1. ผมต้องการแทรกรูปในหน้าเว็บ ผมเลยไปก้อปรูป meme ที่ชื่อ challenge accepted มา
    acceptedจากนั้นไปสร้างโฟลเดอร์ที่เก็บรูปไว้ที่ src/main/resources/statis/img
    7-img
  2. สร้างไฟล์ index.jsp แล้วจัดหน้าเว็บตามใจชอบเพื่อให้มีหน้าจอลงทะเบียนประมาณนี้
    <%@ page language="java" contentType="text/html; charset=UTF-8"
        pageEncoding="UTF-8"%>
    <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
    <%@taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
     
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta name="viewport"
        content="width=device-width, initial-scale=1, maximum-scale=1" />
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
     
    <title>Index</title>
    <link rel="stylesheet" type="text/css"
        href='<c:url value="/css/bootstrap.min.css" />' />
    </head>
    <body>
     
        <%@include file="nav.jsp" %>
     
     
        <div class="container">
            <div class="row">
                <div class="col-md-5 text-center">
                    <h2>สวัสดีชาวโลก</h2>
                    <img src='<c:url value="/img/accepted.jpg"  />' />
                </div>
                <div class="col-md-5">
                    <form:form cssClass="form" action="/register" method="POST"
                        modelAttribute="registerForm">
     
                        <div class="form-group">
                            <form:label path="name">Name <form:errors
                                    cssClass="text-danger" path="name"></form:errors>
                            </form:label>
                            <form:input cssClass="form-control" path="name" />
                        </div>
     
                        <div class="form-group">
                            <form:label path="email">Email <form:errors
                                    cssClass="text-danger" path="email"></form:errors>
                            </form:label>
                            <form:input cssClass="form-control" path="email" />
                        </div>
     
                        <div class="form-group">
                            <form:label path="password">Password <form:errors
                                    cssClass="text-danger" path="password"></form:errors>
                            </form:label>
                            <form:password cssClass="form-control" path="password" />
                        </div>
     
                        <div class="form-group">
                            <form:label path="password">Re-Password  <form:errors
                                    cssClass="text-danger" path="rePassword"></form:errors>
                            </form:label>
                            <form:password cssClass="form-control" path="rePassword" />
                        </div>
     
                        <div class="text-center">
                            <input type="submit" class="btn btn-success" value="Register"
                                style="width: 80px;" />
                            <input type="reset" class="btn btn-default" value="Clear"
                                style="width: 80px;" />
                        </div>
                    </form:form>
                </div>
            </div>
        </div>
     
        <footer class="text-center" style="padding-top: 50px;">
            MagicalCyber &copy; 2016 </footer>
    </body>
    </html>
    
    

    ไฟล์ nav.jsp ไว้เก็บส่วนที่เป็น navigation เพื่อที่จะได้นำไปใช้กับหน้าอื่นๆ เช่นหน้า login

    <%@ page language="java" contentType="text/html; charset=UTF-8"
        pageEncoding="UTF-8"%>
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta name="viewport"
        content="width=device-width, initial-scale=1, maximum-scale=1" />
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Insert title here</title>
    </head>
    <body>
        <nav class="navbar navbar-default navbar-static-top">
            <div class="container-fluid">
     
                <!-- Brand and toggle get grouped for better mobile display -->
                <div class="navbar-header">
                    <button type="button" class="navbar-toggle collapsed"
                        data-toggle="collapse" data-target="#bs-example-navbar-collapse-1"
                        aria-expanded="false">
                        <span class="sr-only">Toggle navigation</span> <span
                            class="icon-bar"></span> <span class="icon-bar"></span> <span
                            class="icon-bar"></span>
                    </button>
                    <a class="navbar-brand" href="#">My Facebook</a>
                </div>
     
     
                <!-- Collect the nav links, forms, and other content for toggling -->
                <div class="collapse navbar-collapse"
                    id="bs-example-navbar-collapse-1">
                    <ul class="nav navbar-nav">
                        <li class="active">
                            <a href='<c:url value="/" />'>Home <span class="sr-only">(current)</span></a>
                        </li>
                        <li>
                            <a href='<c:url value="/login" />'>Login</a>
                        </li>
     
                    </ul>
                </div>
     
            </div>
        </nav>
    </body>
    </html>
    
    

    แก้หน้า login.jsp เพื่อใช้ nav ดังนี้

    <%@ page language="java" contentType="text/html; charset=UTF-8"
        pageEncoding="UTF-8"%>
    <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
    <%@taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
     
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta name="viewport"
        content="width=device-width, initial-scale=1, maximum-scale=1" />
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
     
    <title>Login</title>
    <link rel="stylesheet" type="text/css"
        href='<c:url value="/css/bootstrap.min.css" />' />
    </head>
    <body>
     
        <%@include file="nav.jsp"%>
     
        <div class="container">
     
     
     
            <div class="row">
     
                <c:if test="${not empty SPRING_SECURITY_LAST_EXCEPTION}">
                    <div class="col-md-6 col-md-offset-3 text-danger">
                        <c:out value="${SPRING_SECURITY_LAST_EXCEPTION.message}" />
                    </div>
                </c:if>
     
                <div class="col-md-6 col-md-offset-3">
                    <form:form cssClass="form" action="/login" method="POST">
                        <div class="form-group">
                            <label for="username">Username</label>
                            <input type="text" class="form-control" id="username"
                                name="username" />
                        </div>
     
                        <div class="form-group">
                            <label for="password">Password</label>
                            <input type="password" class="form-control" id="password"
                                name="password" />
                        </div>
     
                        <div>
                            <input type="submit" class="btn btn-success" value="Login" />
                        </div>
                    </form:form>
                </div>
            </div>
        </div>
     
    </body>
    </html>
    
    
  3. สร้างคลาส IndexController แล้วให้วิ่งไปเข้ากับหน้าแรกดังนี้
    package com.magicalcyber.myfacebook.web.controller;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    
    @Controller
    public class IndexController {
    
    	@GetMapping("/")
    	String index() {
    		return "index";
    	}
    }
    
    
  4. แก้ SecurityConfig ให้เข้าถึงรูป หน้าลงทะเบียน และเข้าหน้าแรกได้ดังนี้
    package com.magicalcyber.myfacebook;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    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;
    
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    	@Override
    	public void configure(WebSecurity web) throws Exception {
    		web.ignoring().antMatchers("/css/**", "/img/**");
    	}
    
    	@Override
    	protected void configure(HttpSecurity http) throws Exception {
    		http.authorizeRequests().antMatchers("/login", "/", "/register").permitAll().anyRequest().authenticated().and().formLogin()
    				.loginPage("/login").defaultSuccessUrl("hello");
    	}
    
    	@Override
    	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    		auth.inMemoryAuthentication().withUser("admin").password("password").roles("ADMIN");
    	}
    
    }
    
    

Validation

การตรวจสอบความถูกต้องของข้อมูลแบ่งได้เป็นสองข้อคือ

  1. Basic Validation เป็นการตรวจสอบข้อมูลเบื้องต้นอย่างเช่น ไม่ได้กรอกค่าเข้ามา กรอกไม่ตรงกับความยาวที่กำหนด หรือกรอกตัวหนังสือใส่ตัวเลข อีเมล์ผิด เป็นต้น ถ้าสังเกตุจะพบว่าเป็นการตรวจสอบข้อมูลรายตัวที่ไม่ได้เกี่ยวข้องกับข้อมูลอื่นๆ ถ้าคนเคยเขียนเว็บมาก่อนแล้วเคยทำด้วย javascript ก็น่าจะเข้าใจ แต่ทว่าการพึ่ง javascript ก็ยังไม่ปลอดภัย เนื่องจากสมัยนี้มันข้ามการตรวจสอบของ javascript ได้ง่าย เราจึงต้องไปทำที่ server ด้วย
  2. Complex Validation ผมเรียกว่าเป็นการตรวจสอบท่ายากที่มากขึ้นว่าแบบ basic นั่นคือเริ่มไประรานชาวบ้านแล้ว เช่น password กับ re-password ต้องตรงกัน หรืออีเมล์นี้เคยถูกลงทะเบียนไปแล้วหรือยังซึ่งต้องไปตรวจสอบในฐานข้อมูลของเรา เป็นต้น

Basic Validation

ขั้นแรกในการลงทะเบียนคือเราต้องตรวจสอบว่าผู้ใช้งานทำการกรอกข้อมูลครบถ้วนและถูกต้องหรือเเปล่า หากข้อมูลไม่ถูกต้องก็ให้แสดงข้อความที่หน้าจอเพื่อแจ้งผู้ใช้งาน ซึ่ง Spring framework เตรียมไว้ให้สองทางคือ Validator เอาไว้ตรวจสอบระดับ business logic เช่น email นี้ถูกนำไปใช้ลงทะเบียนหรือยัง และ JSR303 – Bean Validation สำหรับการ validate ข้อมูลทั่วไปเช่น กรอกค่าหรือไม่ ความยาวเกินกว่ากำหนดหรือเปล่า ซึ่งผมเลือกใช้ทั้งสองอย่างเลย เพราะทำงานคนละอย่างกัน

  1. สร้าง bean เพื่อรับค่าจาก form ลงทะเบียนดังนี้
    package com.magicalcyber.myfacebook.web.form;
    
    import org.hibernate.validator.constraints.Email;
    import org.hibernate.validator.constraints.Length;
    
    public class RegisterForm {
    
    	@Length(min = 5, max = 32)
    	@Email
    	private String email;
    
    	@Length(min = 5, max = 32)
    	private String name;
    
    	@Length(min = 5, max = 32)
    	private String password;
    
    	@Length(min = 5, max = 32)
    	private String rePassword;
    
    	public String getEmail() {
    		return email;
    	}
    
    	public void setEmail(String email) {
    		this.email = email;
    	}
    
    	public String getPassword() {
    		return password;
    	}
    
    	public void setPassword(String password) {
    		this.password = password;
    	}
    
    	public String getRePassword() {
    		return rePassword;
    	}
    
    	public void setRePassword(String rePassword) {
    		this.rePassword = rePassword;
    	}
    
    	public String getName() {
    		return name;
    	}
    
    	public void setName(String name) {
    		this.name = name;
    	}
    
    	@Override
    	public String toString() {
    		return "RegisterForm [email=" + email + ", name=" + name + ", password=" + password + ", rePassword="
    				+ rePassword + "]";
    	}
    
    }
    
    

    โค้ด @Length คือ Bean Validator ครับ เพื่อบอกว่าตัวแปรนี้มีค่า length ของ input เท่าไหร่ และห้ามว่าง

  2. แก้ IndexController ให้ส่งค่า RegisterForm มาดังนี้

    
    package com.magicalcyber.myfacebook.web.controller;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    
    import com.magicalcyber.myfacebook.web.form.RegisterForm;
    
    @Controller
    public class IndexController {
    
    	@GetMapping("/")
    	String index(Model model) {
    		model.addAttribute("registerForm", new RegisterForm());
    		return "index";
    	}
    }
    
    

    แก้คลาส RegisterController ให้รับค่า parameter เป็น RegisterForm และผมได้ให้แสดงค่า form ที่กรอกเข้ามาดังนี้

    package com.magicalcyber.myfacebook.web.controller;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.PostMapping;
    
    import com.magicalcyber.myfacebook.web.form.RegisterForm;
    
    @Controller
    public class RegisterController {
    
    	private static final Logger log = LoggerFactory.getLogger(RegisterController.class);
    
    	@PostMapping("/register")
    	String register(RegisterForm registerForm) {
    		log.info(registerForm.toString());
    		return "index";
    	}
    }
    
    

    เมื่อ start spring boot แล้วเข้าหน้าแรก พอกรอกค่าให้ครบแล้วกด register ควรจะมี log ขึ้นมาประมาณนี้

    2-formbean

    แก้หน้า index.jsp ให้ form รองรับค่าจาก model โดยแก้ input ให้เป็นของ spring form ดังนี้

    <%@ page language="java" contentType="text/html; charset=UTF-8"
        pageEncoding="UTF-8"%>
    <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
    <%@taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
     
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta name="viewport"
        content="width=device-width, initial-scale=1, maximum-scale=1" />
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
     
    <title>MyFacebook</title>
    <link rel="stylesheet" type="text/css"
        href='<c:url value="/css/bootstrap.min.css" />' />
    </head>
    <body>
     
        <%@include file="nav.jsp" %>
     
     
        <div class="container">
            <div class="row">
                <div class="col-md-5 text-center">
                    <h2>สวัสดีชาวโลก</h2>
                    <img src='<c:url value="/img/accepted.jpg"  />' />
                </div>
                <div class="col-md-5">
                    <form:form cssClass="form" action="/register" method="POST"
                        modelAttribute="registerForm">
     
                        <div class="form-group">
                            <form:label path="name">Name <form:errors
                                    cssClass="text-danger" path="name"></form:errors>
                            </form:label>
                            <form:input cssClass="form-control" path="name" />
                        </div>
     
                        <div class="form-group">
                            <form:label path="email">Email <form:errors
                                    cssClass="text-danger" path="email"></form:errors>
                            </form:label>
                            <form:input cssClass="form-control" path="email" />
                        </div>
     
                        <div class="form-group">
                            <form:label path="password">Password <form:errors
                                    cssClass="text-danger" path="password"></form:errors>
                            </form:label>
                            <form:password cssClass="form-control" path="password" />
                        </div>
     
                        <div class="form-group">
                            <form:label path="password">Re-Password  <form:errors
                                    cssClass="text-danger" path="rePassword"></form:errors>
                            </form:label>
                            <form:password cssClass="form-control" path="rePassword" />
                        </div>
     
                        <div class="text-center">
                            <input type="submit" class="btn btn-success" value="Register"
                                style="width: 80px;" />
                            <input type="reset" class="btn btn-default" value="Clear"
                                style="width: 80px;" />
                        </div>
                    </form:form>
                </div>
            </div>
        </div>
     
        <footer class="text-center" style="padding-top: 50px;">
            MagicalCyber &copy; 2016 </footer>
    </body>
    </html>
    
    

    และแก้ RegisterController ดังนี้

    package com.magicalcyber.myfacebook.web.controller;
    
    import javax.validation.Valid;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.validation.BindingResult;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.web.bind.annotation.PostMapping;
    
    import com.magicalcyber.myfacebook.web.form.RegisterForm;
    
    @Controller
    public class RegisterController {
    
    	private static final Logger log = LoggerFactory.getLogger(RegisterController.class);
    
    	@PostMapping("/register")
    	String register(@Valid @ModelAttribute("registerForm") RegisterForm registerForm, BindingResult result,
    			Model model) {
    		log.info(registerForm.toString());
    		return "index";
    	}
    }
    
    

    เพิ่ม @Valid @ModelAttribute เพื่อเป็นการบอกว่าให้ทำการตรวจสอบ model นี้ว่าถูกต้องหรือไม่ จากนั้น start spring boot แล้วลอง register ใหม่ ก็จะนำค่าแล้ว และถ้าไม่กรอกค่าอะไรเลย ก็จะมีข้อความขึ้นมาดังรูป โดยเป็น default ของ Bean Validator เอง สามารถแก้ไขได้ แต่ผมคงไม่พอพูดในที่นี้ครับ

    3-basicvalidation

Complex Validation

หลังจากที่เราตรวจสอบเบื้องต้นว่าห้ามว่างแล้ว คราวนี้เราจะมาตรวจสอบอีกสองอย่าง

  1. password และ re-password จะต้องเป็นค่าเดียวกัน
  2. email จะต้องไม่เคยถูกลงทะเบียนมาก่อน

นั่นหมายความว่าเราจะต้องเตรียมฐานข้อมูลและพร้อมที่จะเชื่อมต่อไว้แล้ว ซึ่งด้วยความสามารถ Spring Data JPA ทำให้เรื่องนี้เป็นเรื่องง่ายมาก

    1. เปิดไฟล์ application.properties จากนั้นเพิ่มส่วนของ database และ JPA ดังนี้


      # ===============================
      # SPRING CONFIG
      # ===============================
      security.basic.enabled=false
      security.user.name=admin
      security.user.password=password

      spring.mvc.view.prefix=/WEB-INF/views/
      spring.mvc.view.suffix=.jsp

      # ===============================
      # DATABASE
      # ===============================
      spring.datasource.url = jdbc:mysql://localhost:3306/myfacebook?useSSL=false
      spring.datasource.username = root
      spring.datasource.password = root

      # ===============================
      # JPA / HIBERNATE
      # ===============================
      spring.jpa.show-sql = true
      spring.jpa.hibernate.ddl-auto = update
      spring.jpa.hibernate.naming.strategy = org.hibernate.cfg.ImprovedNamingStrategy
      spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5Dialect

    2. สร้าง model ขึ้นมาสองตัวคือ User และ Role
      package com.magicalcyber.myfacebook.model;
      
      import java.sql.Timestamp;
      
      import javax.persistence.Column;
      import javax.persistence.Entity;
      import javax.persistence.GeneratedValue;
      import javax.persistence.Id;
      import javax.persistence.OneToOne;
      import javax.persistence.Table;
      import javax.validation.constraints.Size;
      
      @Entity
      @Table(name = "user")
      public class User {
      
      	@Id
      	@GeneratedValue
      	private Long id;
      
      	@Column
      	@Size(min = 5, max = 64)
      	private String email;
      
      	@Column
      	@Size(min = 5, max = 32)
      	private String name;
      
      	@Column
      	@Size(min = 5, max = 64)
      	private String password;
      
      	@Column
      	private Timestamp createdDate;
      
      	@OneToOne
      	private Role role;
      
      	public Role getRole() {
      		return role;
      	}
      
      	public void setRole(Role role) {
      		this.role = role;
      	}
      
      	public Long getId() {
      		return id;
      	}
      
      	public void setId(Long id) {
      		this.id = id;
      	}
      
      	public String getEmail() {
      		return email;
      	}
      
      	public void setEmail(String email) {
      		this.email = email;
      	}
      
      	public String getPassword() {
      		return password;
      	}
      
      	public void setPassword(String password) {
      		this.password = password;
      	}
      
      	public Timestamp getCreatedDate() {
      		return createdDate;
      	}
      
      	public void setCreatedDate(Timestamp createdDate) {
      		this.createdDate = createdDate;
      	}
      
      	public String getName() {
      		return name;
      	}
      
      	public void setName(String name) {
      		this.name = name;
      	}
      
      }
      
      
      package com.magicalcyber.myfacebook.model;
      
      import javax.persistence.Entity;
      import javax.persistence.Id;
      import javax.persistence.Table;
      import javax.validation.constraints.Size;
      
      @Entity
      @Table(name = "role")
      public class Role {
      	@Id
      	@Size(min = 5, max = 32)
      	private String name;
      
      	public String getName() {
      		return name;
      	}
      
      	public void setName(String name) {
      		this.name = name;
      	}
      
      }
      
      
    3. สร้าง Repository มาอีกสองอันเพื่อรองรับ model ข้อที่แล้ว
      package com.magicalcyber.myfacebook.repository;
       
      import org.springframework.data.jpa.repository.JpaRepository;
       
      import com.magicalcyber.myfacebook.model.Role;
       
      public interface RoleRepository extends JpaRepository<Role, Long> {
       
      }
      
      
      package com.magicalcyber.myfacebook.repository;
       
      import org.springframework.data.jpa.repository.JpaRepository;
       
      import com.magicalcyber.myfacebook.model.User;
       
      public interface UserRepository extends JpaRepository<User, Long> {
          User findByEmail(String email);
      }
      
      

      Spring JPA มี Property expressions ถ้าจะ query ฟิลด์ใหนก็ใช้ findBy[ชื่อฟิลด์] ได้เลย

    4. สร้างคลาส RegisterValidator ดังนี้
      package com.magicalcyber.myfacebook.web.validator;
      
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.stereotype.Component;
      import org.springframework.validation.Errors;
      import org.springframework.validation.Validator;
      
      import com.magicalcyber.myfacebook.model.User;
      import com.magicalcyber.myfacebook.repository.UserRepository;
      import com.magicalcyber.myfacebook.web.form.RegisterForm;
      
      @Component
      public class RegisterValidator implements Validator {
      
      	@Autowired
      	private UserRepository userRepository;
      
      	@Override
      	public boolean supports(Class<?> clazz) {
      		return RegisterForm.class.equals(clazz);
      	}
      
      	@Override
      	public void validate(Object target, Errors errors) {
      		RegisterForm form = (RegisterForm) target;
      
      		// Check Password and Re-Password must match.
      		if (!form.getPassword().equals(form.getRePassword())) {
      			errors.rejectValue("password", null, "Password not same as Re-Password");
      			errors.rejectValue("rePassword", null, "Re-Password not same as Password");
      		}
      
      		// Check Email is not registered.
      		User user = userRepository.findByEmail(form.getEmail());
      		if (user != null) {
      			errors.rejectValue("email", null, "This email was registered");
      		}
      	}
      
      }
      
      

      เนื่องจาก Validator สามารถ reuse ได้หลาย controller จึงมีการกำหนดให้มีการ implement เมธอด supports เพื่อบอกว่าสามารถใช้ Validator นี้กับ Bean ใหนได้บ้าง ในเมธอด validate ก็จะเป็น logic ที่เราต้องการใช้ validate ซึ่งก็มี re-password ตรงกับ password หรือไม่ และมีคนใช้ email นี้ลงทะเบียนไว้หรือยัง

    5. แก้ไข RegisterController เพื่อเรียกใช้คลาส Validator ดังนี้
      package com.magicalcyber.myfacebook.web.controller;
      
      import javax.validation.Valid;
      
      import org.slf4j.Logger;
      import org.slf4j.LoggerFactory;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.stereotype.Controller;
      import org.springframework.ui.Model;
      import org.springframework.validation.BindingResult;
      import org.springframework.web.bind.annotation.ModelAttribute;
      import org.springframework.web.bind.annotation.PostMapping;
      
      import com.magicalcyber.myfacebook.web.form.RegisterForm;
      import org.springframework.validation.Validator;
      @Controller
      public class RegisterController {
      
      	private static final Logger log = LoggerFactory.getLogger(RegisterController.class);
      
      	@Autowired
      	private Validator registerValidator;
      
      	@PostMapping("/register")
      	String register(@Valid @ModelAttribute("registerForm") RegisterForm registerForm, Model model,
      			BindingResult result) {
      		log.info(registerForm.toString());
      
      		registerValidator.validate(registerForm, result);
      		return "index";
      
      	}
      }
      
      

      จากนั้น start spring boot แล้วทดลอง register ก็จะพบหน้าจอประมาณนี้
      5-complexvalidation1

      โดยผมได้ insert อีเมล์ mm@mm.com ไปก่อนหน้านี้แล้วเพื่อทดสอบกรณี email ซ้ำ

    Register and Login

    1. คราวนี้เราจะทำการบันทึกข้อมูลจริงๆ แล้ว เริ่มโดยการสร้าง enum ชื่อ UserRole เพื่อเก็บ role ที่ใช้ในระบบ
      package com.magicalcyber.myfacebook.constant;
      
      public enum UserRole {
      	ROLE_USER;
      }
      
      
    2. เพิ่ม role เข้าไปใน database ดังนี้
      insert into role values('ROLE_USER')
      
    3. สร้าง UserService และ UserServiceImpl ดังนี้
      package com.magicalcyber.myfacebook.service;
      
      import com.magicalcyber.myfacebook.constant.UserRole;
      import com.magicalcyber.myfacebook.model.User;
      import com.magicalcyber.myfacebook.web.form.RegisterForm;
      
      public interface UserService {
      	void create(RegisterForm form, UserRole userRole);
      
      	User findByEmail(String email);
      }
      
      
      package com.magicalcyber.myfacebook.service;
      
      import java.sql.Timestamp;
      
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.security.crypto.password.PasswordEncoder;
      import org.springframework.stereotype.Service;
      
      import com.magicalcyber.myfacebook.constant.UserRole;
      import com.magicalcyber.myfacebook.model.User;
      import com.magicalcyber.myfacebook.repository.RoleRepository;
      import com.magicalcyber.myfacebook.repository.UserRepository;
      import com.magicalcyber.myfacebook.web.form.RegisterForm;
      
      @Service
      public class UserServiceImpl implements UserService {
      	@Autowired
      	private UserRepository userRepository;
      
      	@Autowired
      	private RoleRepository roleRepository;
      
      	@Autowired
      	private PasswordEncoder passwordEncoder;
      
      	@Override
      	public void create(RegisterForm form, UserRole userRole) {
      		User user = new User();
      		user.setName(form.getName());
      		user.setEmail(form.getEmail());
      		user.setPassword(passwordEncoder.encode(form.getPassword()));
      		user.setRole(roleRepository.findOne(userRole.name()));
      		user.setCreatedDate(new Timestamp(System.currentTimeMillis()));
      		userRepository.save(user);
      	}
      
      	@Override
      	public User findByEmail(String email) {
      		return userRepository.findByEmail(email);
      	}
      
      }
      
      
    4. แก้ไขคลาส SecurityConfig เพื่อรองรับการ register และ login
      package com.magicalcyber.myfacebook;
      
      import org.springframework.context.annotation.Bean;
      import org.springframework.context.annotation.Configuration;
      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.core.userdetails.UserDetailsService;
      import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
      import org.springframework.security.crypto.password.PasswordEncoder;
      
      import com.magicalcyber.myfacebook.service.UserDetailsServiceImpl;
      
      @Configuration
      @EnableWebSecurity
      public class SecurityConfig extends WebSecurityConfigurerAdapter {
      
      	@Override
      	public void configure(WebSecurity web) throws Exception {
      		web.ignoring().antMatchers("/css/**", "/img/**");
      	}
      
      	@Override
      	protected void configure(HttpSecurity http) throws Exception {
      		http.authorizeRequests().antMatchers("/login", "/", "/register").permitAll().anyRequest().authenticated().and()
      				.formLogin().loginPage("/login").defaultSuccessUrl("/hello")
      				.and().logout().logoutUrl("/logout").logoutSuccessUrl("/");
      	}
      
      	@Override
      	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
      		auth.userDetailsService(userDetailService()).passwordEncoder(passwordEncoder());
      	}
      
      	@Bean
      	public PasswordEncoder passwordEncoder() {
      		return new BCryptPasswordEncoder();
      	}
      
      	@Bean
      	public UserDetailsService userDetailService() {
      		return new UserDetailsServiceImpl();
      	}
      
      }
      
      
    5. สร้างคลาส CustomUserDetail เพื่อให้ spring security ใช้เก็บข้อมูลคนที่กำลัง login อยู่
      package com.magicalcyber.myfacebook.model;
       
      import java.util.Collection;
       
      import org.springframework.security.core.GrantedAuthority;
       
      public class CustomUserDetail extends org.springframework.security.core.userdetails.User {
       
          private static final long serialVersionUID = 1L;
       
          private String fullname;
       
          public CustomUserDetail(String username, String password, boolean enabled, boolean accountNonExpired,
                  boolean credentialsNonExpired, boolean accountNonLocked,
                  Collection<? extends GrantedAuthority> authorities) {
              super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
          }
       
          public CustomUserDetail(String username, String password, Collection<? extends GrantedAuthority> authorities,
                  String fullname) {
              super(username, password, authorities);
              this.fullname = fullname;
          }
       
          public String getFullname() {
              return fullname;
          }
       
          public void setFullname(String fullname) {
              this.fullname = fullname;
          }
       
      }
      
      
    6. สร้างคลาส UserDetailsServiceImpl เพื่อใช้ spring security ใช้ login
      package com.magicalcyber.myfacebook.service;
       
      import java.util.HashSet;
      import java.util.Set;
       
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.security.core.GrantedAuthority;
      import org.springframework.security.core.authority.SimpleGrantedAuthority;
      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 org.springframework.transaction.annotation.Transactional;
       
      import com.magicalcyber.myfacebook.model.CustomUserDetail;
      import com.magicalcyber.myfacebook.model.User;
      import com.magicalcyber.myfacebook.repository.UserRepository;
       
      @Service("userDetailService")
      public class UserDetailsServiceImpl implements UserDetailsService {
       
          @Autowired
          private UserRepository userRepository;
       
          @Override
          @Transactional(readOnly = true)
          public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
              System.out.println("email: " + email);
              User user = userRepository.findByEmail(email);
       
              if (user == null) {
                  throw new UsernameNotFoundException(email);
              }
       
              Set<GrantedAuthority> grantedAuthorities = new HashSet<>();
              grantedAuthorities.add(new SimpleGrantedAuthority(user.getRole().getName()));
       
              return new CustomUserDetail(email, user.getPassword(), grantedAuthorities, user.getName());
          }
       
      }
      
      
      
    7. แก้หน้า hello.jsp เพื่อแสดงชื่อคนที่กำลัง login และมีปุ่ม logout อยู่ดังนี้
      <%@ page language="java" contentType="text/html; charset=UTF-8"
          pageEncoding="UTF-8"%>
       
      <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
      <%@taglib prefix="sec"
          uri="http://www.springframework.org/security/tags"%>
      <%@taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
       
       
      <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
      <html>
      <head>
      <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
      <title>Insert title here</title>
      </head>
      <body>
          Hello
          <sec:authorize access="isAuthenticated()">
              <sec:authentication var="user" property="principal" />
              <c:out value="${ user.fullname }"></c:out>
       
              <form:form servletRelativeAction="/logout" method="post">
                  <input type="submit" value="Logout" />
              </form:form>
       
          </sec:authorize>
      </body>
      </html>
      
      
    8. เพิ่ม library นี้เข้าไปใน pom.xml เพื่อให้หน้า hello.jsp รู้จักกับ spring security tag
      <dependency>
                  <groupId>org.springframework.security</groupId>
                  <artifactId>spring-security-taglibs</artifactId>
              </dependency>
      
      
    9. แก้ RegisterController ให้เรียก userService เพื่อทำการบันทึกข้อมูล
      package com.magicalcyber.myfacebook.web.controller;
      
      import java.util.List;
      
      import javax.validation.Valid;
      
      import org.slf4j.Logger;
      import org.slf4j.LoggerFactory;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.stereotype.Controller;
      import org.springframework.ui.Model;
      import org.springframework.validation.BindingResult;
      import org.springframework.validation.ObjectError;
      import org.springframework.validation.Validator;
      import org.springframework.web.bind.annotation.ModelAttribute;
      import org.springframework.web.bind.annotation.PostMapping;
      
      import com.magicalcyber.myfacebook.constant.UserRole;
      import com.magicalcyber.myfacebook.service.UserService;
      import com.magicalcyber.myfacebook.web.form.RegisterForm;
      
      @Controller
      public class RegisterController {
      
      	private static final Logger log = LoggerFactory.getLogger(RegisterController.class);
      
      	@Autowired
      	private Validator registerValidator;
      	
      	@Autowired
      	private UserService userService;
      
      	@PostMapping("/register")
      	String register(@Valid @ModelAttribute("registerForm") RegisterForm registerForm,
      			BindingResult result, Model model) {
      		log.info(registerForm.toString());
      
      		registerValidator.validate(registerForm, result);
      		if (result.hasErrors()) {
      			List<ObjectError> allErrors = result.getAllErrors();
      			for (ObjectError objectError : allErrors) {
      				log.error("\t*** " + objectError);
      			}
      			return "index";
      		} else {
      			userService.create(registerForm, UserRole.ROLE_USER);
      			return "redirect:login";
      		}
      
      	}
      }
      
      
    10. start spring boot แล้ว register ให้เรียบร้อย จากนั้นไปหน้า login กรอกข้อมูลให้ถูกต้องก็จะพบกับหน้า Hello ที่มีชื่อเราแล้ว
      6-hello

    Tests

    เนื่องจากเราต้องทดสอบอีเมล์ว่ามีการลงทะเบียนไว้หรือยัง เราจึงต้องมี database จำลองข้อมูลการลงทะเบียน แต่ทว่าหากเราจะไปใช้ mysql ที่เราลงไว้ก็ดูไม่ดี เนื่องจากมันเอาไว้ใช้ตอน dev ไม่ใช่ตอน test หากจำได้ตอนบทแรกตอนสร้างโปรเจค เราได้เลือก hsqldb มาด้วย ตัวนี้แหละพระเอกของเรา มันคือ database ขนาดเล็กที่สามารถรันบน memory ได้ เราจะใช้ตัวนี้ในการทดสอบระบบกัน

    1. สร้างโฟลเดอร์เพื่อเก็บ config database โดยคลิกขวาที่โปรเจคแล้วเลือก new -> source folder แล้วระบุ path ว่า src/test/resources
      10-test-resourcesจากนั้นสร้างไฟล์ชื่อ application.properties เก็บค่า config ตามนี้

      # ===============================
      # SPRING CONFIG
      # ===============================
      security.basic.enabled=false
      security.user.name=admin
      security.user.password=password

      spring.mvc.view.prefix=/WEB-INF/views/
      spring.mvc.view.suffix=.jsp

      # ===============================
      # DATABASE
      # ===============================
      spring.datasource.url = jdbc:hsqldb:mem:myfacebook
      spring.datasource.username = sa
      #spring.datasource.password =

      # ===============================
      # JPA / HIBERNATE
      # ===============================
      spring.jpa.show-sql = true
      spring.jpa.hibernate.ddl-auto = update
      spring.jpa.hibernate.naming.strategy = org.hibernate.cfg.ImprovedNamingStrategy
      spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.HSQLDialect

      และเราต้องเตรียม sql script ที่เราเคย insert role ไปก่อนหน้านี้ไว้ที่ resources นี้ด้วย โดยผมตั้งชื่อว่า before.sql

      insert into role values('ROLE_USER')
    2. สร้างคลาส RegisterValidatorTest แล้วอยากทดสอบเคสอะไรก็ใส่ไป อย่าลืมเก็บคลาสไว้ใน src/test/java นะครับ
      package com.magicalcyber.myfacebook.web.validator;
      
      import static org.junit.Assert.assertFalse;
      import static org.junit.Assert.assertTrue;
      
      import javax.transaction.Transactional;
      
      import org.junit.Test;
      import org.junit.runner.RunWith;
      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.test.context.jdbc.Sql;
      import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
      import org.springframework.test.context.junit4.SpringRunner;
      import org.springframework.validation.BeanPropertyBindingResult;
      import org.springframework.validation.Errors;
      import org.springframework.validation.Validator;
      
      import com.magicalcyber.myfacebook.constant.UserRole;
      import com.magicalcyber.myfacebook.service.UserService;
      import com.magicalcyber.myfacebook.web.form.RegisterForm;
      
      @RunWith(SpringRunner.class)
      @SpringBootTest
      @AutoConfigureMockMvc
      @Sql(executionPhase = ExecutionPhase.BEFORE_TEST_METHOD, scripts = { "classpath:before.sql" })
      @Transactional
      public class RegisterValidatorTest {
      
      	private static final String EMAIL = "mm@gmail.com";
      
      	@Autowired
      	private Validator registerValidator;
      
      	@Autowired
      	private UserService userService;
      
      	@Test
      	public void validatePassword_notSameAsRePassword_mustError() {
      		RegisterForm form = new RegisterForm();
      		form.setPassword("ABC");
      		form.setRePassword("CDE");
      
      		Errors errors = new BeanPropertyBindingResult(form, "registerForm");
      		registerValidator.validate(form, errors);
      		assertTrue(errors.hasErrors());
      	}
      
      	@Test
      	public void validatePassword_sameAsRePassword_mustPassed() {
      		RegisterForm form = new RegisterForm();
      		form.setPassword("ABC");
      		form.setRePassword("ABC");
      
      		Errors errors = new BeanPropertyBindingResult(form, "registerForm");
      		registerValidator.validate(form, errors);
      		assertFalse(errors.hasErrors());
      	}
      
      	@Test
      	public void validateEmail_notExist_mustPassed() {
      		RegisterForm form = new RegisterForm();
      		form.setPassword("ABC");
      		form.setRePassword("ABC");
      		form.setEmail(EMAIL);
      
      		Errors errors = new BeanPropertyBindingResult(form, "registerForm");
      		registerValidator.validate(form, errors);
      		assertFalse(errors.hasErrors());
      	}
      
      	@Test
      	public void validateEmail_exist_mustError() {
      
      		// Create first user that registered email
      		RegisterForm form = new RegisterForm();
      		form.setPassword("ABC");
      		form.setRePassword("ABC");
      		form.setEmail(EMAIL);
      		userService.create(form, UserRole.ROLE_USER);
      
      		// validate
      		Errors errors = new BeanPropertyBindingResult(form, "registerForm");
      		registerValidator.validate(form, errors);
      		assertTrue(errors.hasErrors());
      	}
      }
      
      

      สิ่งที่ต่างไปจาก Unit test ปกติคือมีการเรียก sql script เพื่อทำการ insert role ให้เราอัตโนมัติด้วย @Sql และมี @Transactional เพื่อทำการ rollback ในทุกเคส สมมุติว่าหากมีการ insert user แล้วในเคสต่อไปเราก็ยัง insert ซ้ำ มันจะต้องว่า duplicate ดังนั้นเราจึงต้อง rollback เสมอในทุกเคส

      และเมื่อทำการ run ก็จะพบสีเขียวงดงาม
      11-unit-test

    ส่วนการทดสอบ Register นั้นทำได้ง่ายมากครับ เราสามารถ mock request ได้เลย
    สร้างคลาส RegisterTests ดังนี้

    package com.magicalcyber.myfacebook;
    
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
    
    import javax.transaction.Transactional;
    
    import org.junit.Before;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    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.http.MediaType;
    import org.springframework.test.context.junit4.SpringRunner;
    import org.springframework.test.web.servlet.MockMvc;
    import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
    import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
    import org.springframework.test.web.servlet.setup.MockMvcBuilders;
    import org.springframework.web.context.WebApplicationContext;
    
    @RunWith(SpringRunner.class)
    @SpringBootTest(webEnvironment = WebEnvironment.MOCK)
    @Transactional
    public class RegisterTests {
    
    	private static final String PASSWORD = "12345";
    
    	private static final String NAME = "magicalcyber";
    
    	private static final String EMAIL = "mm@gmail.com";
    
    	@Autowired
    	private WebApplicationContext wac;
    
    	private MockMvc mockMvc;
    
    	@Before
    	public void setup() {
    		mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
    	}
    
    	@Test
    	public void testRegister_emailNotRegistered_mustSuccess() throws Exception {
    
    		MockHttpServletRequestBuilder req = MockMvcRequestBuilders.post("/register")
    			.param("name", NAME)
    			.param("email", EMAIL)
    			.param("password", PASSWORD)
    			.param("rePassword", PASSWORD)
    			.contentType(MediaType.APPLICATION_FORM_URLENCODED);
    
    		mockMvc.perform(req)
    			.andExpect(status().is3xxRedirection())
    			.andExpect(redirectedUrl("login"));
    
    	}
    
    	@Test
    	public void testRegister_emailRegistered_mustErrorRegisteredEmail() throws Exception {
    
    		// 1 - create registered email
    		testRegister_emailNotRegistered_mustSuccess();
    
    		// 2 - use same email to register
    		MockHttpServletRequestBuilder req = MockMvcRequestBuilders.post("/register")
    			.param("name", NAME)
    			.param("email", EMAIL)
    			.param("password", PASSWORD)
    			.param("rePassword", PASSWORD)
    			.contentType(MediaType.APPLICATION_FORM_URLENCODED);
    
    		// 3 - validate errors field must contain 'email'
    		mockMvc.perform(req)
    			.andExpect(status().isOk())
    			.andExpect(view().name("index"))
    			.andExpect(model().attributeHasFieldErrors("registerForm", "email"));
    	}
    }
    
    

    จุดสังเกตุคือ @SpringBootTest(webEnvironment = WebEnvironment.MOCK) เป็นการบอกให้ spring boot ทำการจำลอง server ให้เราครับ
    และเมื่อ run test ก็จะพบสีเขียวงดงามตามที่เขียนเคสได้

    12-integration-test

    ส่งท้าย

    ไปลบคลาส HelloControllerTest ออกด้วยนะครับ เนื่องจากเราไม่ใช้แล้ว

    Sourcecode

    https://github.com/magickiat/myfacebook.git

    Advertisements

7 thoughts on “Spring boot ตอนที่ 3 – กรณีศึกษา MyFacebook web (2)

    1. ตรงข้อใหนครับผม ผมหาไม่เจอ สงสัยผมอาจจะเบลอๆ อยู่

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s