Google Code Prettify

2018年12月7日 星期五

Spring Batch: FixedLengthTokenizer

在 2014 年我寫的一篇「剖析固定長度欄位的訊息字串」說明怎麼方便的剖析一個固定欄位長度的檔案,這一篇打算改用 Spring Batch,在往下看之前,建議回頭看一下這兩篇:
  1. 剖析固定長度欄位的訊息字串
  2. Spring Batch: getting started
在說明 Spring Batch 怎麼處理固定長度欄位檔案前,先看一下 Spring Batch 怎麼處理 csv 檔。如下是 csv 檔的內容:
Buterin,24,Anglo-Saxon,Canada
中本聰,47,大和民族,波士頓
只有兩筆資料 … 程式如下: (Person 等相關類別請參考 Spring Batch: getting started)
FlatFileItemReader<Person> itemReader = new FlatFileItemReader<Person>();
itemReader.setResource(new ClassPathResource("Person.csv"));
 
DefaultLineMapper<Person> lineMapper = new DefaultLineMapper<Person>();
lineMapper.setLineTokenizer(new DelimitedLineTokenizer(){{
    setNames(new String[] { "name", "age", "nation", "address" });
}});
lineMapper.setFieldSetMapper(new PersonFieldSetMapper());
itemReader.setLineMapper(lineMapper);
  
itemReader.open(new ExecutionContext());
  
Person person = null;
while ((person = itemReader.read()) != null) {
    System.out.println(person.toString());
}
說明如下:
  • FlatFileItemReader: 這個類別可用來讀取文字檔,當然,csv 檔是文字檔的一種,也用來讀取 csv 檔。它主要依賴兩類別 -- Resource 及 LineMapper,前者為 spring 提供的基礎類別,可以存取檔案或網路資源,這裡使用的 ClassPathResource 會到 classpath 目錄下讀取指定的檔案。
  • DefaultLineMapper: spring batch 定義了 LineMapper 介面,並實作多個類別,這些類別是用來將 String 轉換成相對應的 Object,DefaultLineMapper 可用來處理有分隔符號或固定長度欄位的字串。
  • DelimitedLineTokenizer: 當要處理的字串為有分隔符號的,就用這個類別,這裡有使用 setNames 傳入欄位名稱,這是方便在 PersonFieldSetMapper  (前一篇) 中使用欄位名稱存取各欄位的值,沒有設定欄位名稱,可以用 index,從 0 開始。
現在改成處理固定長度欄位檔案,檔案不再用逗點分隔欄位,改成如下:
Buterin   24Anglo-Saxon       Canada    
中本聰    47大和民族          波士頓    
程式碼幾乎不用改,差別只有一個,就是將 tokenizer 改成 FixedLengthTokenizer !! 程式如下。
FlatFileItemReader itemReader = new FlatFileItemReader();
itemReader.setResource(new ClassPathResource("Person.txt"));
  
DefaultLineMapper lineMapper = new DefaultLineMapper();
FixedLengthTokenizer tokenizer = new FixedLengthTokenizer();
tokenizer.setNames(new String[] { "name", "age", "nation", "address" });
  
Range range1 = new Range(1, 10);
Range range2 = new Range(11, 12);
Range range3 = new Range(13, 30);
Range range4 = new Range(31, 40);
tokenizer.setColumns(new Range[] { range1, range2, range3, range4 });
  
lineMapper.setLineTokenizer(tokenizer);
lineMapper.setFieldSetMapper(new PersonFieldSetMapper());
itemReader.setLineMapper(lineMapper);
  
itemReader.open(new ExecutionContext());
  
Person person = null;
while ((person = itemReader.read()) != null) {
    System.out.println(person.toString());
}
如上,除了 tokenizer 改成 FixedLengthTokenizer,要設定每個欄位的開始位置、結束位置,開始位置從 1 開始。執行的結果是,讀第一行時沒問題,第二行就出現如下 exception 了!
org.springframework.batch.item.file.FlatFileParseException: Parsing error at line: 2 in resource=[class path resource [Person.txt]], input=[中本聰    47大和民族          波士頓    ]
…
Caused by: org.springframework.batch.item.file.transform.IncorrectLineLengthException: Line is shorter than max range 40
因為 Java 預設的編碼為 UTF-8,我的檔案編碼是 MS950,在切 token 時,spring batch 會檢查字串長度,發現長度不足最長的 40,就拋出 exception 了。這時候可以改寫 FixedLengthTokenizer,這裡寫了一個命名為 ZhFixedLengthTokenizer 的類別。
@Slf4j
public class ZhFixedLengthTokenizer extends FixedLengthTokenizer {
    private Range[] ranges;
    private int maxRange = 0;
    boolean open = false;
 
    public void setColumns(Range[] columns) {
        this.ranges = columns;
    }
 
    @Override
    public List<String> doTokenize(String line) {
        List<String> tokens = new ArrayList<String>(ranges.length);
        String token;

        try {
            byte[] b = line.getBytes("MS950");
            int lineLength = b.length;

            for (int i = 0; i < ranges.length; i++) {
                int startPos = ranges[i].getMin() - 1;
                int endPos = ranges[i].getMax();

                if (lineLength >= endPos) {
                    token = getZhString(b, startPos, endPos);
                }
                else if (lineLength >= startPos) {
                    token = getZhString(b, startPos, lineLength);
                }
                else {
                    token = "";
                }

                tokens.add(token);
            }
        } catch (UnsupportedEncodingException e) {
            log.error(e.getMessage(), e);
        }

        return tokens;
    }

    private String getZhString(byte[] b, int startPos, int endPos) throws UnsupportedEncodingException {
        String token;
        byte[] subB = Arrays.copyOfRange(b, startPos, endPos);
        token = new String(subB, "MS950");
        return token;
    }
}

這個類別繼承了 FixedLengthTokenizer,然後覆寫其中的 doTokenize,把切 token 時的字串編碼改為 MS950,這樣就可以得到正確結果了! 這些程式碼基本上是從原本的 FixedLengthTokenizer 裡 copy 過來改寫的,然後把 tokenizer 改用這個類別就可以了,如下:
FixedLengthTokenizer tokenizer = new ZhFixedLengthTokenizer();

2018年7月24日 星期二

Redis 的七種資料型別

Redis 以 key、value 的方式儲存資料,但是,儲存的資料並非只有字串、數字這些簡單的物件。Redis 共支援七種資料型別 --- String、List、Set、Hash、Zset (sorted set)、HLL (HyperLogLog)、Geo,這裡會以指令操作的方式說明,但是,要真正的了解 Redis 怎麼使用它們,要自行參考「Redis 命令參考」多玩才行,因為我只使用到少部份的指令。
  • String
這是最基本的資料型別,通常就是 key、value,value 為一個字串,但是除了字串外,也可以是整數或浮點數。

get、set 是最基本的指令,把資料放入 Redis 或取出,這裡 get 是讀出來,不會移除,資料仍在 Redis 裡,要真的刪除要用 del,如下,一次可以刪多個,刪除後用 get 讀,如果該 key 值沒有資料,會傳回 nil。
  • List
雙向的鏈,所以可以由左或右 push 進 Redis 或 pop 出來,也可以從中間插入。
如上,從左邊依序放入 10、20、30,那麼由左到右看就會是 30 20 10,上面用 LRANGE 讀出,由 0 到 -1 表示全部讀出來,為什麼? 因為 List 的指標由左到右是 0、1、2 …,由右到左是 -1、-2、-3 …,所以由 0 到 -1 就表示全部。
除了依序 push,也可以從中間插入,LINSERT 就是提供這樣的功能,LINDEX 則是可以指定要讀那一個位置的元素。移除元素可以用 LPOP、RPOP、LREM 等命令。
  • Set
List 是有順序的,而且同一元素可以有兩個或多個,Set 是無序的,每個元素只能有一個。
如上,用 SADD 加入五個元素,用 SMEMBERS 顯示出所有元素,順序與加入時不同。
Set 間還可以做聯集,如上,mySet、urSet 用 SUNION 聯集得到相加的結果。
  • Hash
Hash (雜湊) 本來就是 key、value 結構,在 Redis 的 key、value 結構中又會怎麼表示呢?

如上所示,在指令 HMSET 後面的元素依序為 redis_key、hash_key1、hash_value1、hash_key2、hash_value2,所以 google、yahoo 為 Hash 的 key,www.google.com、www.yahoo.com 是它們的值。
這裡用 HKEYS 把所有的 key 顯示出來,也用 HVALS 把所有的 value 顯示出來。
  • ZSet
ZSet 是有序的集合,和 Set 一樣,元素不可重複,為了有順序,所以每個元素會有個權重 (score),順序就依權重排列。
用 ZRANK 可以顯示出某個元素在權重由小到大排列下,排在第幾位 (由 0 開始計算),用 ZREVRANK 則是反過來,權重由大到小排列下的排序。
  • HyperLogLog (HLL)
HLL 只有三個指令 - PFADD、PFCOUNT、PFMERGE,分別用來新增元素、計算個數、合併元素。HLL 和上面五種資料型別不同之處在於,它並非用來儲存資料,而是用在計算個數! 而且只適用在特殊場景! 當要計算的個數很大,可能是數千萬或更多,而且可容許誤差,例如網站的訪客數,那麼就可以使用 HLL。使用 HLL 有什麼好處? 好處在於,不管元素個數有多麼多,HLL 都只會固定的佔用 12KB 記憶體,最大可計算的個數為 2 的 64 次方。
如上,將 a b c d e f g 共 7 個元素加入 myHLL 中,用 PFCOUNT 計算得到 7,再把 a b c 加入 myHLL,再計算一次,仍是 7,因為 HLL 就像 SET 一樣,只會計算不重複的元素。接著將 1 2 3 4 5 共 5 個元素加入 urHLL,用 PFCOUNT 計算得 5,然後用 PFMERGE 合併 myHLL、urHLL 到 ourHLL,再用 PFCOUNT 計算 ourHLL 裡有幾個元素,得到 12。個數很小時不會有誤差,個數非常大時,會有不大於 1% 的誤差。
  • Geo
這個型別是用來記錄及計算經緯度、兩地的距離等,真的很特別。
我查了一下 Google Map,找出淡水和內湖的西堤的經緯度,分別為 (121.3750261, 25.1690807) 及 (121.5690305, 25.0792924),所以將它們加入 restaurants 這個變數,接著可以用 GEOPOS 取出,如果要算兩個餐廳的距離,就用 GEODIST,算出來的結果是 21.9413 公里。

2018年7月23日 星期一

Spring Data Redis

原文在「Introduction to Spring Data Redis」,我只是節錄並加上自己的心得,詳細仍要看原文。我的 Redis 不是安裝在本機,是安裝在另一台電腦 (CentOS 7) 上,Redis 預設是不提供遠端連線,所以要先修改 redis.conf 裡的設定,要修改的有兩個 - bindprotected-mode

bind 127.0.0.1 前加上 # 變註解就行了,也就是不限制那個 IP 都可以連進來。
protected-mode 改為 no 取消保護模式。
  • build.gradle
dependencies {
  compile('org.springframework.boot:spring-boot-starter-data-redis')
  compile('org.springframework.boot:spring-boot-starter-web')
  compileOnly('org.projectlombok:lombok')
  testCompile('org.springframework.boot:spring-boot-starter-test')
 
  compile group: 'redis.clients', name: 'jedis', version: '2.9.0'
}
紅色是這次的主題需要用到的。
  • JedisConfig.java
@Configuration
public class JedisConfig {
  @Bean
  public RedisTemplate<String, Object> redisTemplate() {
     RedisTemplate<String, Object> template = new RedisTemplate<>();
     template.setConnectionFactory(jedisConnectionFactory());
     
     return template;
  }
 
  @Bean
  public JedisConnectionFactory jedisConnectionFactory() {
     JedisConnectionFactory jedisConFactory = new JedisConnectionFactory();
     jedisConFactory.setHostName("192.168.70.233");
     jedisConFactory.setPort(6379);
     return jedisConFactory;
  }
}
定義這兩個 Bean,後續會用到的 CrudRepository 即是透過這它們連上 Redis 的。
  • Student.java
@RedisHash("Student")
@Data
public class Student implements Serializable {
   
    public enum Gender { 
        MALE, FEMALE
    }
 
    private String id;
    private String name;
    private Gender gender;
    private int grade;

    public Student() { }
    
    public Student(String id, String name, Gender gender, int grade) {
      this.id = id;
      this.name = name;
      this.gender = gender;
      this.grade = grade;
    }
}
這是後面我們要存到 Redis 上的物件,是屬於 Redis 中的 Hash 型別物件。
  • StudentRepository.java
@Repository
public interface StudentRepository extends CrudRepository<Student, String> {
 
}
熟悉 spring data 的人對 CrueRepository 一定不陌生,看來它不只能用在關聯式資料庫,也能用在 NoSQL 的資料庫。
  • DemoTest.java
@RunWith(SpringRunner.class)
@SpringBootTest
public class DemoTest {
 
  @Autowired
  private StudentRepository studentRepository;

  @Test
  public void test() {
    Student student = new Student("Eng2015001", "John Doe", Student.Gender.MALE, 1);
    studentRepository.save(student);
  
    Student retrievedStudent = studentRepository.findById("Eng2015001").get();
    System.out.println(retrievedStudent.toString());
  }
}

簡單的單元測試程式,將 id 為 Eng2015001 的 Student 物件放入 Redis,接著我們可以用「Redis Desktop Manager」這個小工具連到 Redis 後看到內容,如下。


2018年7月18日 星期三

Spring Security: 資料庫認證、授權

繼續前一篇 (Spring Security: getting started),這裡要將原本帳密寫在記憶體,改成帳密記錄在資料庫,也就是說,使用者登入後,系統應該到資料庫中取出帳密來比對認證。
  • Database
create table users(
  username varchar(50) not null primary key,
  password varchar(100) not null,
  enabled boolean not null
);
create table authorities (
  username varchar(50) not null,
  authority varchar(50) not null,
  constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username,authority);


insert into users(username,password,enabled)
 values('admin','$2a$10$hbxecwitQQ.dDT4JOFzQAulNySFwEpaFLw38jda6Td.Y/cOiRzDFu',true);
insert into authorities(username,authority) 
 values('admin','ROLE_ADMIN');
先在資料厙中建立上述兩個 table,並 insert 進資料,使用者只有一個 admin,密碼是加密過的,未加密前的密碼為 admin@123,加密的方式如下:
String encoded=new BCryptPasswordEncoder().encode("admin@123");
System.out.println(encoded);
  • build.gradle
buildscript {
   ext {
     springBootVersion = '2.0.3.RELEASE'
   }
   repositories {
       mavenCentral()
       jcenter()
       maven { url "https://repo.spring.io/libs-release" }
       maven { url "http://maven.springframework.org/milestone" }
       maven { url "http://repo.maven.apache.org/maven2" }
       maven { url "http://repo1.maven.org/maven2/" }
       maven { url "http://amateras.sourceforge.jp/mvn/" }
   }
   dependencies {
       classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
   }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'idv.steven.mysecurity'
version = '0.0.1'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
    jcenter()
    maven { url "https://repo.spring.io/libs-release" }
    maven { url "http://maven.springframework.org/milestone" }
    maven { url "http://repo.maven.apache.org/maven2" }
    maven { url "http://repo1.maven.org/maven2/" }
    maven { url "http://amateras.sourceforge.jp/mvn/" }
}

dependencies {
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile('org.springframework.boot:spring-boot-starter-security')
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compileOnly('org.projectlombok:lombok')
 
    testCompile("junit:junit")
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.security:spring-security-test')
 
    compile group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '2.2.5'
}
我使用的是 MariaDB,所以要載入 MariaDB 的 JDBC driver,同時也要載入 spring jdbc 相關的 jar 檔,所以加入上面紅色的兩行。
  • application.properties
spring.datasource.url=jdbc:mariadb://localhost:3306/demo
spring.datasource.username=steven
spring.datasource.password=p@ssw0rd
在 application.properties 設定好 url、username、password,spring boot 就會幫我們建立好資料庫相關的連線。
  • WebSecurityConfig.java
import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
 @Autowired
 private DataSource dataSource;
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/", "/home").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .permitAll()
                .and()
            .logout()
                .permitAll();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication().dataSource(dataSource)
            .usersByUsernameQuery("select username, password, enabled"
                + " from users where username=?")
            .authoritiesByUsernameQuery("select username, authority "
                + "from authorities where username=?")
            .passwordEncoder(new BCryptPasswordEncoder());
    }
}
前一篇為了方便將帳密放在記憶體,這裡改成紅色的方式,到資料庫中查詢,在繼承 WebSecurityConfigurerAdapter 的類別中,覆寫 configure(AuthenticationManagerBuilder auth) method,AuthenticationManagerBuilder 是個 builder pattern 的類別,設定好驗證帳密的規則後,當 spring security 由 spring container 取得 UserDetailsService object 時,會由 builder 產生一個新的 object。

注意一下上面的兩個 sql,第一個 select username, password, enabled from users where username = ? 用來認證,第二個 select username, authority from authorities where username = ? 則是查出使用者被授予那些角色? 以這個例子來說,查出來會是 ROLE_ADMIN,spring security 會忽略 ROLE_ 只認定使用者擁有 ADMIN 這個角色。

Spring Security: getting started

這是官網上的文章節錄,說明怎麼開始用 spring security 控制 web 的權限,一個很簡單的 hello 程式,第一頁是首頁,如下:
按了 here 後,會出現登入頁,如下:
輸入帳號密碼,為求簡單,程式裡先 hard code 為 user / password,輸入後按【Sign In】登入就出現 Hello 畫面,如下:
現在開始寫程式 ...

  • 程式結構

如上,在 eclipse 中以「Spring Starter Project」建立一個 spring boot 檔案,會得到如上的檔案結構,當然,裡面的程式是下面要開始慢慢加進去的。
  • build.gradle
buildscript {
    ext {
        springBootVersion = '2.0.3.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'idv.steven.mysecurity'
version = '0.0.1'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}


dependencies {
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile('org.springframework.boot:spring-boot-starter-security')
    compile('org.springframework.boot:spring-boot-starter-web')
    compileOnly('org.projectlombok:lombok')
 
    testCompile("junit:junit")
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.security:spring-security-test')
}
在 build.gradle 寫入如上內容,因為這個程式除了會用到 spring security 外,會以 web 的方式寫程式,所以也引入 web 相關 jar 檔。
  • home.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Spring Security Example</title>
    </head>
    <body>
        <h1>Welcome!</h1>

        <p>Click <a th:href="@{/hello}">here</a> to see a greeting.</p>
    </body>
</html>
  • login.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Spring Security Example </title>
    </head>
    <body>
        <div th:if="${param.error}">
            Invalid username and password.
        </div>
        <div th:if="${param.logout}">
            You have been logged out.
        </div>
        <form th:action="@{/login}" method="post">
            <div><label> User Name : <input type="text" name="username"/> </label></div>
            <div><label> Password: <input type="password" name="password"/> </label></div>
            <div><input type="submit" value="Sign In"/></div>
        </form>
    </body>
</html>
  • hello.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Hello World!</title>
    </head>
    <body>
        <h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
        <form th:action="@{/logout}" method="post">
            <input type="submit" value="Sign Out"/>
        </form>
    </body>
</html>
  • MvcConfig.java
package idv.steven.mysecurity;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/home").setViewName("home");
        registry.addViewController("/").setViewName("home");
        registry.addViewController("/hello").setViewName("hello");
        registry.addViewController("/login").setViewName("login");
    }

}
WebMvcConifgurer 是 spring MVC 的一個類別,繼承它之後覆寫 addViewControllers method,可以注冊一些網址與網頁的對應。
  • WebSecurityConfig.java
package idv.steven.mysecurity;

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.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/", "/home").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .permitAll()
                .and()
            .logout()
                .permitAll();
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        UserDetails user =
             User.withDefaultPasswordEncoder()
                .username("user")
                .password("password")
                .roles("USER")
                .build();

        return new InMemoryUserDetailsManager(user);
    }
}
要啟用 spring security,要在一個有 @Configuration 的類別前加上 @EnableWebSecurity,要改變預設的安全設定,則繼承 WebSecurityConfigurerAdapter,再覆寫相關的 method。這裡覆寫了 configure method 變更相關權限,從程式就很容易看得出來是什麼意思 (英文就寫的很清楚了),formLogin() 表示要使用 Form-Based login 的方式登入,登入頁為 /login,所有人都可以開啟這一個網頁。

spring security 在驗證帳密時,會由 spring container 中取得 UserDetailService 的 object 來驗證,上面的程式產生一個 UserDetailsService 的 Bean,把帳密放在記憶體裡,只有一個使用者,名稱為 user,密碼為 password,角色是 USER。一般的系統不會把帳密放記憶體,通常是放在 LDAP 或資料庫,下一篇會說明當放在資料庫時怎麼做。
  • MySecurityApplication.java
package idv.steven.mysecurity;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MySecurityApplication {

    public static void main(String[] args) {
        SpringApplication.run(MySecurityApplication.class, args);
    }
}
這個類別應該沒有懸念,spring boot 都是由標有 @SpringBootApplication 的 main 所在類別開始的。

2018年6月9日 星期六

Spring Cloud Eureka: getting started

Netflix 有提供了一個稱為 Eureka 的 open source,可以作為服務註冊之用,spring cloud 將它做了封裝,這裡來看一下怎麼使用。

我總共會寫三支程式,分別為 ...
  1. myEureka: eureka server,註冊服務的地方。
  2. myEureka-Client: 提供服務的程式,會到 eureka server 註冊,供其它程式使用。
  3. myConsumer: 想要使用服務的程式,也會到 eureka server 註冊,註冊後即可使用 server 上的服務。
  • myEureka
    • build.gradle
buildscript {
  ext {
    springBootVersion = '2.0.2.RELEASE'
  }
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
  }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'idv.steven.eureka'
version = '1.0'
sourceCompatibility = 1.8

repositories {
  mavenCentral()
  maven { url "https://repo.spring.io/milestone" }
}


ext {
  springCloudVersion = 'Finchley.RC2'
}

configurations.all {
  exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}

dependencies {
  def log4j2Version = '2.7'
 
  compile('org.springframework.boot:spring-boot-starter')
  compile('org.springframework.cloud:spring-cloud-starter-netflix-eureka-server')
  {
    exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-netflix-eureka-client'
  }
  compile('org.springframework.boot:spring-boot-starter-tomcat')
  compileOnly('org.projectlombok:lombok')
  testCompile('org.springframework.boot:spring-boot-starter-test')
 
  // Log4j2
  compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: "${log4j2Version}"
  compile group: 'org.apache.logging.log4j', name: 'log4j-api', version: "${log4j2Version}"
  compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: "${log4j2Version}"
    
  compile group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.0'
}

dependencyManagement {
  imports {
    mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
  }
}
使用 gradle 下載所有需要的 jar 檔,特別注意紅色的部份,是有關 spring cloud 及 eureka。
    • MyEurekaApplication.java
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@EnableEurekaServer
@SpringBootApplication
public class MyEurekaApplication {

  public static void main(String[] args) {
    new SpringApplicationBuilder(MyEurekaApplication.class).web(true).run(args);
  }
}
只要加入 @EnableEurekaServer 即可啟動一個 Eureka Server,當然,還是要有一些設定,如下面的 application.properties、application-peer1.properties、application-peer2.properties,為什麼會有三個設定檔? 因為我們會啟用兩個 Eureka Server,並讓兩個 server 相互註冊,這樣可以相互備援及負載平衡。兩個 server 分別放在hostname 為 peer1 及 peer2 的伺服器上,因為我是在一台電腦上測試,所以我在 hosts 中加入如下內容:
127.0.0.1 peer1
127.0.0.1 peer2
    • application.properties
spring.applicaion.name=eureka-server
server.port=1111

eureka.instance.hostname=localhost
eureka.client.register-with-eureka=false
eureak.client.fetch-registry=false
eureka.client.serviceUrl.defaultZone=http://${eureka.instance.hostname}:${server.port}/eureka
注意紅色的部份,eureka.client.register-with-eureka=false 表示不向註冊中心註冊自己,eureak.client.fetch-registry=false 表示不需要檢索服務。
    • application-peer1.properties
eureka.instance.hostname=peer1
server.port=1111

eureka.client.serviceUrl.defaultZone=http://peer2:1112/eureka/
peer1 上的 Eureka Server 啟動時,服務會佔用 1111 port,並且會向 peer2 註冊。
    • application-peer2.properties
eureka.instance.hostname=peer2
server.port=1112

eureka.client.serviceUrl.defaultZone=http://peer1:1111/eureka/
peer2 上的 Eureka Server 啟動時,服務會佔用 1112 port,並且會向 peer1 註冊。因為我實際上只有一台電腦,port 才會設不一樣,如果是真的在兩台電腦,port 最好設成一樣。程式都寫好之後,用 gradle build 指令產生 jar 檔。
    • 啟動 server
java -jar myEureka-1.0.jar --spring.profiles.active=peer1
java -jar myEureka-1.0.jar --spring.profiles.active=peer2
開兩個命令列視窗,進入 jar 檔所在目錄,第一個視窗執行第一條指令,第二個視窗執行第二條指令,這樣就可以分別啟動 peer1 及 peer2 的 server 了,如果對於 application.properties 檔的設定不熟,可以參考「application.properties@spring boot」。
    • 進入管理介面觀察系統狀況
開啟瀏覽器,輸入 http://localhost:1111/ 進入 peer1 的管理介面,可以看到如下,peer2 已經註冊到 peer1 來了。
再開啟一個新的頁面,輸入 http://localhost:1112/ 進入 peer2 的管理介面,會看到如下,peer1 註冊到 peer2 了。
  • myEureka-Client
    • build.gradle
buildscript {
  ext {
    springBootVersion = '2.0.2.RELEASE'
  }
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
  }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'idv.steven.eureka'
version = '1.0'
sourceCompatibility = 1.8

repositories {
  mavenCentral()
  maven { url "https://repo.spring.io/milestone" }
}


ext {
  springCloudVersion = 'Finchley.RC2'
}

configurations.all {
  exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}

dependencies {
  def log4j2Version = '2.7'

  compile('org.springframework.boot:spring-boot-starter-web')
  compile('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client')
  compileOnly('org.projectlombok:lombok')
  testCompile('org.springframework.boot:spring-boot-starter-test')
 
  // Log4j2
  compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: "${log4j2Version}"
  compile group: 'org.apache.logging.log4j', name: 'log4j-api', version: "${log4j2Version}"
  compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: "${log4j2Version}"
}

dependencyManagement {
  imports {
    mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
  }
}
特別注意紅色部份,這樣就可以載入 Eureka Client 相關的 jar 檔。
    • MyEurekaClientApplication.java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@EnableDiscoveryClient
@SpringBootApplication
public class MyEurekaClientApplication {

  public static void main(String[] args) {
    SpringApplication.run(MyEurekaClientApplication.class, args);
  }
}
加上 @EnableDiscoveryClient,這個程式就成為 Eureka Client,會將自己註冊到 server。
    • application.properties
spring.application.name=hello-service

eureka.client.serviceUrl.defaultZone=http://peer1:1111/eureka/,http://peer2:1112/eureka/
有兩台 server,就將它們都寫到 eureka.client.serviceUrl.defaultZone 後面,這樣 client 會將服務註冊到 peer1、peer2。
    • HelloController.java
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.client.serviceregistry.Registration;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;

@RestController
@Slf4j
public class HelloController {
  @Autowired
  private DiscoveryClient client;
 
  @Autowired
  private Registration registration;
 
  @RequestMapping(value ="/hello", method = RequestMethod.GET)
  public String index() {
    List<ServiceInstance> list = client.getInstances(registration.getServiceId());
    list.forEach(s -> {
      System.out.println("service id: " + s.getServiceId());
    });
  
    return "Hello";
  }
}
在這裡紅色部份是可以不用寫,這裡寫出來只是為了展示 client 端可以透過這個方式抓到所有的 service instance。
    • 進入管理介面觀察結果
現在再觀察一下 peer1、peer2 的管理介面,可以看到 client 已經註冊到兩台 server,因為在 application.properties 中,我將 client 提供的服務命名為 hello-service,所以在兩台 server 上都可以看到這個服務。

  • myConsumer
    • build.gradle
buildscript {
  ext {
    springBootVersion = '2.0.2.RELEASE'
  }
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
  }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'idv.steven.consumer'
version = '1.0'
sourceCompatibility = 1.8

repositories {
  mavenCentral()
  maven { url "https://repo.spring.io/milestone" }
}


ext {
  springCloudVersion = 'Finchley.RC2'
}

dependencies {
  compile('org.springframework.boot:spring-boot-starter-web')
  compile('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client')
  compileOnly('org.projectlombok:lombok')
  testCompile('org.springframework.boot:spring-boot-starter-test')
}

dependencyManagement {
  imports {
    mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
  }
}
載入 Eureka Client 所需的 jar 檔。
    • application.properties
spring.application.name=myconsumer
server.port=9090
eureka.client.serviceUrl.defaultZone=http://peer1:1111/eureka/,http://peer2:1112/eureka/
我只在一台電腦上執行四支程式,這裡的 port 就避開上面三支程式的即可,我設為 9090,另外,這也是一個 client 程式,所以也要將自己註冊到 server。
    • MyConsumerApplication.java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;

@EnableDiscoveryClient
@SpringBootApplication
public class MyConsumerApplication {
  @Bean
  @LoadBalanced
  public RestTemplate restTemplate() {
    return new RestTemplate();
  }

  public static void main(String[] args) {
    SpringApplication.run(MyConsumerApplication.class, args);
  }
}
與前面的 client 服務程式一樣,加上 @EnableDiscoverClient,因為這也是一支 Eureka Client 程式,只是不提供服務,而是要到 server 上去找到別的服務並執行該服務。RestTemplate 設定為 @Bean 讓 spring 控管,是為了下面的程式要使用,加上 @LoadBalanced 的話,會開啟 client 的負載平衡。
    • ConsumerController.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

@RestController
public class ConsumerController {
  @Autowired
  private RestTemplate restTemplate;
 
  @RequestMapping(value = "/myconsumer", method = RequestMethod.GET)
  public String helloConsumer() {
    return restTemplate.getForEntity("http://HELLO-SERVICE/hello", String.class).getBody();
  }
}
這支程式只是發動一個 REST request 到上面那支 client 服務去使用該服務,要注意的是,網址寫的是該服務註冊於 server 上的名稱,也就是說,使用服務不再需要知道服務所在的伺服器在那,只要將自己註冊到 Eureka Server,即可以服務的名稱存取到該服務。
    • 測試
開啟一個瀏覽器,輸入 http://localhost:9090/myconsumer 去觸發這支要使用服務的程式,讓這支程式發動一個 REST reuqest 去 HELLO-SERVICE 存取該服務,如下所示,服務傳回 Hello。

2018年5月23日 星期三

Spring Batch: getting started

雖然很多系統都有批次處理的需求,但是,spring batch 似乎並沒有很流行? 這裡看一下怎麼開始寫第一支 spring batch 的程式。


如上圖,程式會讀取一個檔案 people.csv 的資料,經過處理寫入資料庫。people.csv 的內容如下:
中本聰,47,大和民族,波士頓
很簡單的一個 csv 檔,只有一筆資料,四個欄位,分別記載著「名字」、「年齡」、「民族」、「居住地」。資料庫的 table 如下:

有五個欄位,除了第一個欄位 ID 是資料庫自行編碼的序號外,分別為 name、age、nation、address,用來記錄上面四個欄位的資料。現在開始看一下程式怎麼寫?
  • build.gradle
buildscript {
    ext {
        springBootVersion = '2.0.2.RELEASE'
    }
    repositories {
      mavenCentral()
      jcenter()
      maven { url "https://repo.spring.io/libs-release" }
      maven { url "http://maven.springframework.org/milestone" }
      maven { url "http://repo.maven.apache.org/maven2" }
      maven { url "http://repo1.maven.org/maven2/" }
      maven { url "http://amateras.sourceforge.jp/mvn/" }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'idv.steven.mybatch'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
     mavenCentral()
     jcenter()
     maven { url "https://repo.spring.io/libs-release" }
     maven { url "http://maven.springframework.org/milestone" }
     maven { url "http://repo.maven.apache.org/maven2" }
     maven { url "http://repo1.maven.org/maven2/" }
     maven { url "http://amateras.sourceforge.jp/mvn/" }
}

configurations.all {
    //sping boot 預設使用logback,先移除
    exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}

dependencies {
    def log4j2Version = '2.7'

    compile('org.springframework.boot:spring-boot-starter-batch')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.batch:spring-batch-test')
 
    compile fileTree(dir: 'libs', include: ['*.jar'])
 
    compile group: 'javax.validation', name: 'validation-api', version: '2.0.1.Final'
    compile group: 'org.hibernate', name: 'hibernate-validator', version: '5.3.6.Final'
    compile group: 'org.glassfish.web', name: 'el-impl', version: '2.2'

    compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: "${log4j2Version}"
    compile group: 'org.apache.logging.log4j', name: 'log4j-api', version: "${log4j2Version}"
    compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: "${log4j2Version}" 
}
我使用 Gradle 管理 jar 檔,所以需要如上檔案,最重要的是紅色那兩行,分別引入 spring batch 及 spring data,這樣程式可以進行批次處理及寫入資料庫了。
  • application.properties
spring.datasource.url=jdbc:oracle:thin:@192.168.51.168:1521:testdb
spring.datasource.username=testuser
spring.datasource.password=testpass
這是 spring boot 預設的設定檔,在裡面寫入如上內容,spring boot 就會幫我們做好所有資料庫相關初始化的工作。
  • spring boot application
@SpringBootApplication
public class MyBatchApplication {

    public static void main(String[] args) throws Exception {
        SpringApplication.run(MyBatchApplication.class, args);
    }
}
spring boot 程式就行這裡開始,如果還不清楚,可以參考「第一個 spring boot web 程式 (使用 embedded tomcat)」。
  • Person
@Entity
@Table(name="PERSON")
@Data
public class Person {
    @Id
    @GeneratedValue(strategy=GenerationType.SEQUENCE, generator="SEQ_PERSON")
    @SequenceGenerator(name="SEQ_PERSON", sequenceName="SEQ_PERSON", allocationSize=1)
    @Column(name="ID", nullable=false)
    private Long id;
 
    private String name;
 
    private Long age;
 
    private String nation;
 
    private String address;
}
JPA 的 entity 定義如上,對應前面的 table PERSON。
  • PersonDao
public interface PersonDao extends CrudRepository<Person, Long> {

}
有 Entity 之後,自然要寫個 DAO 來存取,如上,這是 spring data 提供的簡便方法,如果不了解,可以參考「spring data: CrudRepository 自動生成」。
  • BatchConfig
@Configuration
@EnableBatchProcessing
public class BatchConfig {

}
只要在 @Configuration 所在類別加入 @EnableBatchProcessing,spring batch 會自動設定好基本的設定,並建立起以下幾個 bean: (括號中為 bean name)
  1. JobRepository (jobRepository)
  2. JobLauncher  (jobLauncher)
  3. JobRegistry (jobRegistry)
  4. PlatformTransactionManager  (transactionManager)
  5. JobBuilderFactory (jobBuilders)
  6. StepBuilderFactory (stepBuilders)
並且會將執行過程及結果寫入底下六個 tables:
  1. BATCH_JOB_EXECUTION
  2. BATCH_JOB_EXECUTION_CONTEXT
  3. BATCH_JOB_EXECUTION_PARAMS
  4. BATCH_JOB_INSTANCE
  5. BATCH_STEP_EXECUTION
  6. BATCH_STEP_EXECUTION_CONTEXT
如果因為測試時不想寫出那麼多 log,或是基於各種考量,不希望 spring batch 記下這些 log,可以改成如下,使用預設的 spring batch 設定 (繼承 DefaultBatchConfigurer),但是在注入 DataSource 時刻意將它忽略,這樣 spring batch 就不會寫 log 到資料庫了。
@Configuration
@EnableBatchProcessing
public class BatchConfig extends DefaultBatchConfigurer {
    @Override
    public void setDataSource(DataSource dataSource) {
        // override to do not set datasource even if a datasource exist.
        // initialize will use a Map based JobRepository (instead of database)
    }
}
  • PersonJob
@Configuration
public class PersonJob {
    @Autowired
    private CsvJobListener listener;
 
    @Bean
    public Job importJob(JobBuilderFactory jobs, Step s1) {
        return jobs.get("importJob")
          .incrementer(new RunIdIncrementer())
          .flow(s1)
          .end()
          .listener(listener)
          .build();
    }
    
    @Bean
    public Step step1(
        StepBuilderFactory stepBuilderFactory, 
        ItemReader<Person> reader, 
        ItemWriter<Person> writer,
        ItemProcessor<Person,Person> processor) {

        return stepBuilderFactory
          .get("step1")
          .<Person, Person>chunk(500)
          .reader(reader)
          .processor(processor)
          .writer(writer)
          .build();
    }
    
    @Bean
    public FlatFileItemReader<Person> reader() throws Exception {
        FlatFileItemReader<Person> reader = new FlatFileItemReader<Person>();
        reader.setResource(new ClassPathResource("people.csv"));
        reader.setLineMapper(
            new DefaultLineMapper<Person>() {{
                setLineTokenizer(new DelimitedLineTokenizer() {{
                    setNames(new String[] { "name", "age", "nation", "address" });
                }});
        
                setFieldSetMapper(new PersonFieldSetMapper());
            }});

        return reader;
    }
}

這裡定義了三個 bean,第一個是定義了一個 Job; 第二個 bean 是定義 Job 中的步驟,因為這個 Job 只有一個步驟,就只有一個 Step bean,其中 500 表示每 500 筆存一次,spring batch 不會每讀一筆就存一次,這樣太沒效率了; 第三個 bean 用來讀取 CSV 檔,可以看到欄位與的 Person 類別一致,當 spring batch 讀取一行 CSV 的資料,就會產生一個 Person object 來儲存。
  • PersonItemWriter
@Component
public class PersonItemWriter implements ItemWriter {
    @Autowired
    private PersonDao daoPerson;

    @Override
    public void write(List items) throws Exception {
        items.forEach(i -> {
            daoPerson.save(i);
        }); 
    }
}
寫入資料庫,我簡單的用 spring data 提供的 DAO 寫入。
  • PersonFieldSetMapper
@Component
public class PersonFieldSetMapper implements FieldSetMapper {

    @Override
    public Person mapFieldSet(FieldSet fieldSet) throws BindException {
        Person person = new Person();
        person.setName(fieldSet.readString("name"));
        person.setAge(fieldSet.readLong("age"));
        person.setNation(fieldSet.readString("nation"));
        person.setAddress(fieldSet.readString("address"));

        return person;
    }
}
由 reader 讀入的資料在這裡轉換成 Person object。
  • PersonItemProcessor
@Component
public class PersonItemProcessor implements ItemProcessor<Person, Person> {
    @Override
    public Person process(Person item) throws ValidationException {
        if (item.getNation().equals("大和民族")) {
            item.setNation("01");
        } else {
            item.setNation("02");
        }
        return item;
    }
}
由 csv 讀入資料後,會傳到這個類別,如果要做一些處理,可以在這裡進行,要特別注意傳回值如果是  null,則 spring batch 不會呼叫 writer,這表示在這裡我們可以篩選進來的資料,不符合需求的就不寫入。要特別注意的是,spring batch 在 ItemProcessor 也會計算次數,看看 ItemReader 送來的資料筆數是否已經等於前面設定的 chunk 筆數,等於了才會送給 ItemWriter,流程會像這樣 …
  • CsvJobListener
@Component
@Slf4j
public class CsvJobListener implements JobExecutionListener {
    private long startTime;
    private long endTime;

    @Override
    public void beforeJob(JobExecution jobExecution) {
        startTime = System.currentTimeMillis();
        log.info("任務處理開始");
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        endTime = System.currentTimeMillis();
        log.info("任務處理結束");
        log.info("耗時:" + (endTime - startTime) + "ms");
    }
}
Listener 並非必要的,這裡用它來寫一些 log,好方便我們觀察。
  • 執行結果


執行後查詢一下資料庫,應該可以看到已經寫入了。

2018年3月18日 星期日

在 embedded tomcat 中使用 Tomcat 資料庫連線

在 spring boot 中,如果在 build.gradle 中包含了 web 套件,就會自動包裝了 embedded tomcat,那麼,如果程式要連線資料庫,要怎麼利用這個 embedded tomcat 中的 connection pool ? 很簡單,如下。
  • build.gradle
buildscript {
    ext {
        springBootVersion = '2.0.0.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse-wtp'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'war'

group = 'idv.steven.jndi'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

configurations {
    providedRuntime
}

dependencies {
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.apache.tomcat:tomcat-jdbc')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
 
    compile group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '2.2.1'
}
如上所示,我引入了 spring boot 2.x 版,因為我的資料庫是 maria db,所以也要把相關的 JDBC 驅動程式引入,同時,因為這裡要使用 tomcat connection pool,要引入 tomcat jdbc。spring boot 在後續程式中,會根據資料庫的 url 判斷是什麼資料庫,而載入相關驅動程式。
  • application.properties
spring.datasource.type=org.apache.tomcat.jdbc.pool.DataSource
#spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://192.168.0.103:3306/demo
spring.datasource.username=steven
spring.datasource.password=P@ssw0rd

spring.datasource.tomcat.max-active=23
spring.datasource.tomcat.max-idle=7
spring.datasource.tomcat.min-idle=2
spring.datasource.tomcat.max-wait=9997
spring.datasource.type 指定要使用的 connection pool,spring.datasource.tomcat.*,是設定 connection pool 的一些參數,我故意設定一些比較奇怪的值,是為了觀察是否真的有使用到 tomcat connection pool。spring.datasource.driver-class-name 不需設定,spring boot 從 url 中可以自行判斷。在 application.properties 設定完,不需要寫任何程式,spring boot 就幫我們建立好 tomcat connection pool。
  • 觀察
我在 eclipse 使用 debug mode 執行,觀察 EntityManager 的值,於「em > h > target Factory > h > entityManagerFactoryBean > persistenceUnitInfo > nonJtaDataSource > poolProperties」裡看到如下內容,證明確實有用到 tomcat connection pool。

2018年2月28日 星期三

Spring Boot Admin

Spring Boot 廣泛用於微服務,在這種分散式系統,監控是一件很重要的事,Spring Boot 提供了一個名為 Spring Boot Admin 的監控工具,只需要進行一些設定就可以監控,非常方便,說明如下:
  • 建立一個 spring boot admin 的 server 端程式
下面是這個程式的 build.gradle,和一般的 spring boot 沒什麼不同,主要就是加入紅色那幾行,讓 gradle 協助我們載入相關的 jar 檔。spring boot 預設使用的 log 是 logback,但是我習慣用 log4j2,所以加入綠色的那幾行,將 logback 移除並加入 log4j2。
buildscript {
 ext {
  springBootVersion = '1.5.10.RELEASE'
 }
 repositories {
  mavenCentral()
 }
 dependencies {
  classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
 }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'

group = 'idv.steven'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
 mavenCentral()
}

configurations.all {
      exclude group: 'log4j', module: 'log4j'
      exclude group: 'org.slf4j', module: 'slf4j-log4j12'
      exclude group: 'org.slf4j', module: 'log4j-over-slf4j'
      exclude group: 'ch.qos.logback', module: 'logback-core'
      exclude group: 'ch.qos.logback', module: 'logback-classic'
}

ext {
      springBootAdminVersion = '1.5.7'
      log4j2Version = '2.10.0'
}

dependencies {
      compile('de.codecentric:spring-boot-admin-starter-server')
      compile('de.codecentric:spring-boot-admin-server-ui')
 
      compile group: 'org.apache.logging.log4j', name: 'log4j-api', version: "${log4j2Version}"
      compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: "${log4j2Version}"
      compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: "${log4j2Version}"
 
      testCompile('org.springframework.boot:spring-boot-starter-test')
}

dependencyManagement {
      imports {
            mavenBom "de.codecentric:spring-boot-admin-dependencies:${springBootAdminVersion}"
      }
}
  • 設定 port
server.port=8081
embedded tomcat 預設的 port 是 8080,因為我打算用來測試的 client 程式「第一個 spring boot web 程式 (使用 embedded tomcat)」也有用到 embedded tomcat 已經佔去了 8080 port,所以在 application.properties 中加入如上內容設定使用 8081 port。
  • 執行 server
執行上述 server 程式後,打開瀏覽器,打入 http://localhost:8081,可以看到如下畫面,這樣就確定已經正常啟動了!
接下來要來看一下 client 程式怎麼修改,可以讓這個 server 進行監控。
  • 修改 client 程式的 build.gradle
buildscript {
 ext {
  springBootVersion = '1.5.10.RELEASE'
 }
 repositories {
  mavenCentral()
 }
 dependencies {
  classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
 }
}

apply plugin: 'java'
apply plugin: 'war'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'

group = 'idv.steven'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
      mavenCentral()
}

configurations.all {
      exclude group: 'log4j', module: 'log4j'
      exclude group: 'org.slf4j', module: 'slf4j-log4j12'
      exclude group: 'org.slf4j', module: 'log4j-over-slf4j'
      exclude group: 'ch.qos.logback', module: 'logback-core'
      exclude group: 'ch.qos.logback', module: 'logback-classic'
}

ext {
      springBootAdminVersion = '1.5.7'
      log4j2Version = '2.10.0'
}

dependencies {
      compile('org.springframework.boot:spring-boot-starter-data-jpa') 
      compile('org.springframework.boot:spring-boot-starter-web')
      compile('org.springframework.boot:spring-boot-starter-thymeleaf')
      compile('de.codecentric:spring-boot-admin-starter-client')
 
      compile group: 'org.apache.logging.log4j', name: 'log4j-api', version: "${log4j2Version}"
      compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: "${log4j2Version}"
      compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: "${log4j2Version}"
      
      compile group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '2.2.1'
      compile group: 'javax.inject', name: 'javax.inject', version: '1'
      compileOnly('org.projectlombok:lombok')
      testCompile('org.springframework.boot:spring-boot-starter-test')
}

dependencyManagement {
      imports {
            mavenBom "de.codecentric:spring-boot-admin-dependencies:${springBootAdminVersion}"
      }
}
紅色部份是為了導入 Spring Boot Admin client 而加入的。
  • 修改 application.properties (client)
spring.boot.admin.url=http://localhost:8081
spring.boot.admin.client.name=demo

endpoints.sensitive=false
endpoints.logfile.external-file=d:/tmp/demo.log
在 application.properties 中加入如上設定,前兩行指出 Spring Boot Admin Server 的網址及要顯示在監控畫面的程式名稱; 後兩行是為了將 client 的 log4j2 產生的 log file 送到監控畫面而設定的,這樣萬一監控時發現錯誤,可以立即看 log。
  • 執行 client
執行後,client 會自動連到 server 註冊,然後就會顯示在監控畫面,如上。如果想看更詳細的資料,可以按【Details】,將顯示如下畫面,其中第二項「Log」就是 log4j2 的 log file 內容。



2018年2月27日 星期二

docker logs

在「docker 基本指令」中了解了一些 docker 的基本操作指令,這裡來看一下怎麼觀察 docker 運行的過程產生的 logs,就以 Apache HTTP Server 來當例子好了。
  • 安裝 Apache HTTP Server
安裝的指令如下:
docker run -d -p 80:80 --name httpd httpd
-d: 在背景執行
-p: port 的映射,將 httpd service 80 port 對映到本機的 80 port。
安裝好之後以 docker images 查一下,應該可以看到如下:
  • 查看 docker logs
docker logs -f httpd
-f: 查看 log 尾部的意思,也就是說,這個指令只會顯示 log 最後的幾行,如果有新的 log 產生會顯示新的 log,內容差不多如下。
因為這是個 http server,我的電腦的 IP 是 192.168.0.200,在瀏覽器輸入 http://192.168.0.200 會看到如下畫面,表示服務確實在運行。
同時,因為瀏覽器有對 http server 進行 request,可以在剛剛的畫面看到又新增了 log。
  • log file
上面的指令讓我們可以查看 docker log,而實際上這些 log 存放在那呢? 首先進入 /var/lib/docker/containers 目錄,裡面會有正在運行的 container 的 log,目錄名稱為 container process id,http server 是 aaefcd14c3 那一個 (docker ps -a 可得知)。
進到 http server container 的 log 目錄,可以找到一個 {container id}-json.log 的檔案 (檔名很長的那個),看一下內容,如下,非常類似於前面用指令查看到的 log,但仔細看,是 json 格式。
  • Logging Driver
docker log 儲存的格式如上所示是 json 格式,這是預設,是可以變更的,先執行如下指令:
docker info | grep 'Logging Driver'
docker info 指令可以看到 docker 的一些設定,其中一項 Logging Driver 就是設定 log 儲存格式,預設如下,可以看到設定為 json-file。