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年10月14日 星期日

Python: ORM using Django

用 Python 寫資料庫程式,預設是以 ORM 的方式存取,這裡簡單的記錄如下:
  • 建立一個 application
  • 我是用 eclipse 開發 Python 程式,如上,我已經建立了一個命名為 myDb 的專案,Django 鼓勵在一個專案下建立多個 application,讓這些 application 方便共用資源,所以,開啟一個 console 畫面,進入專案的目錄,以我的情況來說,因為 myDb 建立在 D:\Project 目錄下,就進入 D:\Project\myDb 目錄。並於這個目錄下下以下指令:
    python manage.py startapp demo
    demo 是 application 名稱,執行上述指令後,在 D:\Project\myDb 目錄下,會建立一個名為 demo 的目錄,並產生一些預設的檔案,如下:
  • 安裝資料庫的驅動程式
  • 我用的資料庫是 MySQL,在繼續寫程式前,要先安裝 MySQL 的驅動程式,指令如下:
    pip install mysqlclient
  • 修改 settings.py
  • INSTALLED_APPS = [
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
        'demo',
    ]
    
    在 settings.py 中找到 INSTALLED_APPS,將剛剛建立的 demo application 加入,接下找到 DATABASES,如下輸入:
    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.mysql',
            'NAME': 'Demo',
            'USER': 'myuser',
            'PASSWORD': 'p@ssw0rd',
            'HOST': '192.168.0.103',
            'PORT': '3306',
            'OPTIONS': {
                'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
            },
        }
    }
  • 修改 models.py
  • # -*- encoding: utf-8 -*-
    from django.db import models
    
    class Maker(models.Model):
        name = models.CharField(max_length=10)
        country = models.CharField(max_length=10)
        
        def __str__(self):
            return self.name
    
    如上,在裡面加入一個類別 Maker,在下面的同步指令後,python 的工具會幫我們在資料庫中建立相關的 table。紅色部份是指示 python,資料庫中的字串用 utf-8 編碼。
  • 同步資料庫
  • python manage.py makemigrations
    python manage.py migrate
    
    在 D:\Project\myDb 目錄下,下上述兩個指令,python 就會在資料庫中建立相關的 table,如下:  
    這個 table 裡的欄位就如類別中定義的一樣,有 name 和 country,python 會自行加入一個自動編號的欄位當 key。
    python manage.py makemigrations 是用來建立資料庫和 Django 間的中介檔案。
    python manage.py migrate 則是同步更新資料庫的內容。
  • 測試
  • 我先在剛剛建立的 table 中新增一筆資料,如下:
    insert into demo_maker(name, country)
    values('ASUS', 'Taiwan');
    
    接著寫單元測試程式,將它查詢出來 … 修改 tests.py,如下:
    from django.test import TestCase
    
    import MySQLdb
    import unittest
    
    class DbTestCase(unittest.TestCase):
        def test_query(self):
            db = MySQLdb.connect(host="192.168.0.103", user="myuser", passwd="p@ssw0rd", db="demo")
            cursor = db.cursor()
            cursor.execute("select * from demo_maker")
            
            results = cursor.fetchall()
            for record in results:
                col0 = record[0]
                col1 = record[1]
                col2 = record[2]
                print(col0, col1, col2)
            
            db.close()
    
    很簡單的查詢後輸出到 console,看看是否正確 ... 測試的方式
    python manage.py test
    

2018年9月8日 星期六

Redis: master / slave

Redis 提供有 master / slave 機制,master 的資料會自動同步到 slave,當許多系統共用同一個 Redis 時,可以分配一些系統使用 slave,以減輕 Redis 的負擔,下面說明怎麼建立 master / slave。
  • slave 的設定檔
安裝好的 Redis,在 conf 目錄下有個 redis.conf,是預設的設定檔,複製一份成 redis-slave.conf,然後修改裡面的內容,如下:
port 6380
pidfile /var/run/redis_6380.pid
dir ./slave
slaveof 127.0.0.1 6379
在 redis-slave.conf 找到上述四個設定,將值修改成如上,說明如下:
  1. port 是 slave redis 要傾聽的 port 號,master 已經使用了預設的 6379,所以這裡改成 6380 避免衝突。
  2. 指定 pidfile,UNIX / Linux 的習慣,許多程式執行起來後會產生一個 pid 檔,如果要把這個程式停掉,可以刪除 pid 檔,程式就會停止了。
  3. dir 是運行過程中的 dump 檔要存在那個目錄。
  4. slaveof 指出這個 slave 要跟隨那一個 master ? 這裡指向 port 6379 的那一個 Redis。
  • 運行
建議開兩個 putty,一個執行 master,一個執行 slave,這樣比較好觀察,master 和 slave 的啟動指令如下: (先進入 /redis 目錄下)
bin/redis-server conf/redis.conf
bin/redis-server conf/redis-slave.conf
其實就是帶不同設定檔當參數,就會啟動不同的 instance。
  • 觀察啟動結果
在兩個 putty 分別使用 redis-cli 連線進入 redis,指令如下:
bin/redis-cli -p 6379
127.0.0.1:6379>

bin/redis-cli -p 6380
127.0.0.1:6380>
然後在裡面都下 info replication 這個指令,可以用來觀察 master / slave 運行的狀況,結果顯示如下,master 端清楚的指出自己的角色是 master,有幾個 slave 連向自己,連向自己的 slave 的 IP 和 port 等資訊,slave 端的資訊也清楚的指出自己的角色是 slave,並且連向那一個 master。
    • master
    • slave
  • 測試
在 master 建立一個 (key, value) 值,然後在 slave 用該 key 取出值,可以觀察到 master 的內容會被即時同步到 slave。
    • master
    • slave
就算 master 建立值時,slave 剛好當掉,slave 重啟時,也會再次同步,測試前先在 slave 輸入 「shutdown save」、及「quit」,關閉 slave,如下:
用 ps 觀察看看是否真的 shutdown 了!
6380 port 的都沒有了 ... 確實已經 shutdown,現在於 master 端新建立一個 (key, value) 值。
然後重啟 slave,並觀察有沒有上面的值,如下,重啟後 master 的內容真的有同步過來。
  • slave 是否可以寫入?
上面的測試都是在 master 端寫入,觀察是否有同步到 slave 端,如果我們在 slave 寫入會如何?
如上,會出現"READONLY ... "的錯誤訊息,表示無法由 slave 寫入,但 … 真的都不可以由 slave 寫入嗎?? 那只是預設值!
slave-read-only yes
只要設定檔裡上述的值改成 no,重新啟動後,slave 就可以寫入了! 只不過,這樣 master 和 slave 的內容會不同步,所以大部份的應用都是設為 read only,slave 只是用來分擔 master 的工作,讓只需要讀取內容的 ap 指向 slave。

2018年8月14日 星期二

Spring Security: 密碼加密

這年頭網站的密碼不太可能會以明碼儲存,或是以對稱加密的方式加密後儲存,這就不多說了 … spring security 5.x 提供不少非對稱加密的演算法 bcrypt、MD5、SHA-1、SHA-256 … 預設是 bcrypt。看一下底下程式,應該就可以初步了解怎麼加密、比對。
PasswordEncoder encoder1 = PasswordEncoderFactories.createDelegatingPasswordEncoder();

String encryption1 = encoder1.encode("abcdefg");
System.out.println("bcrypt1:" + encryption1);
  
String encryption2 = encoder1.encode("abcdefg");
System.out.println("bcrypt2:" + encryption2);
  
PasswordEncoder encoder2 = new BCryptPasswordEncoder();
boolean result = encoder2.matches("abcdefg", "$2a$10$aKFYCaH2bRMeIB.FBAWC6.NcsEF.yR.3FXm/ZhIULLDudtXnMQ/Ni");
System.out.println("比較結果: " + result);
輸出結果可能如下:
bcrypt1:{bcrypt}$2a$10$ZiYiw5U397Zr3ORAc/NrbutwH2p5YqzlE/Ugk34oCW6/s2LloIjWy
bcrypt2:{bcrypt}$2a$10$2UcGko8JuZ4Xze/jqv17yeAxhALhIyU/Usx4uKXHTe6D.e/vUHrq6
比較結果: true
加密後產生的字串會是 {加密演算法}加密後字串,這樣的格式,演算法這麼明明白白的公告出來不會讓黑客有機可趁? 這倒可以放心,因為這些演算法一方面都是公開的,另一方面是,黑客從產生出來的字串也多半可以猜出是用什麼演算法加密的,所以,這些演算法要經的起考驗,本來就要設計成即使知道加密的演算法,也無法得知密碼。

再來看一下程式,PasswordEncoderFactories 是 spring security 提供的工廠類別,可以產生一個 DelegatingPasswordEncoder 類別的物件,預設是用 bcrypt 演算法,如果我們想用其它演算法呢? 官網的 Docs 提供了如下的方法:
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);
這個官方的範例,簡單的就是 new 一個 DelegatingPasswordEncoder,並把要使用的加密演算法及其代碼傳入。

回過頭來看上面輸出的結果,同樣加密 "abcdefg",bcrypt1、bcrypt2 輸出的字串並不一樣,事實上每次加密都不會一樣,所以,比對密碼是否相同不能直接用 equals 比對,而要用PasswordEncoder 提供的 matches() method,第一個參數傳入明碼的密碼,第二個參數則傳入要比對的加密後的密碼字串,這樣就可以傳回 true (相同) 或 false (不同)。

2018年8月11日 星期六

install Kafka (single node)

安裝 Apache Kafka 前要先安裝 jdkzookeeper,所以先下載 jdk、zookeeper 及 kafka,我下載的三個檔案為 jdk-8u181-linux-x64.tar.gz、zookeeper-3.4.12.tar.gz 及 kafka_2.12-2.0.0.tgz,放在 /home/steven 底下 (我自己的 home 目錄),用以下指令解開:
tar zxvf jdk-8u181-linux-x64.tar.gz
tar zxvf zookeeper-3.4.12.tar.gz
tar zxvf kafka_2.12-2.0.0.tgz
然後搬到 /usr/local/bin 目錄下,這是我的個人習慣,自己安裝的軟體就放這個目錄。暫且我們稱這三個目錄為 $JAVA_HOME、$ZOOKEEPER 及 $KAFKA。要怎麼設定 JAVA_HOME,可以參考這一篇 -- 「安裝 JDK」。
  • 安裝 zookeeper
進入 $ZOOKEEPER/conf 目錄,可以看到 zoo_sample.cfg 設定檔的 sample,複製一份到 zoo.cfg,這是 zookeeper 預設的設定檔檔名。
cp zoo_sample.cfg zoo.cfg
然後回到 $ZOOKEEPER 目錄,執行以下 bin/zkServer.sh start 指令,看到如下訊息,就表示 zookeeper 啟動了。
ZooKeeper JMX enabled by default
Using config: /usr/local/bin/zookeeper-3.4.12/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED
要查看 server 的狀態,可以用 telnet localhost 2181 (2181 為預設的 port),進入 server 後下 srvr 指令,就可以顯示出 server 的狀態了。
[root@localhost ~]# telnet localhost 2181
Trying ::1...
Connected to localhost.
Escape character is '^]'.
srvr
Zookeeper version: 3.4.12-e5259e437540f349646870ea94dc2658c4e44b3b, built on 03/27/2018 03:55 GMT
Latency min/avg/max: 0/0/0
Received: 1
Sent: 0
Connections: 1
Outstanding: 0
Zxid: 0x0
Mode: standalone
Node count: 4
Connection closed by foreign host.
  • 安裝 Kafka
進入 $KAFKA 目錄,下以下的指令:
bin/kafka-server-start.sh config/server.properties
如果要啟動背景模式,指令如下:
bin/kafka-server-start.sh -daemon config/server.properties

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月21日 星期六

install Redis

我的環境是 CentOS 7.x,我的帳號是 steven,我是用 root 安裝,安裝步驟如下:
  1. 下載: 到 Redis 官網下載最新的 Stable 版本,我下載的是 4.0.10 版 (redis-4.0.10.tar.gz)。
  2. 上傳: redis-4.0.10.tar.gz 檔案到 /home/steven 目錄下。
  3. 解開檔案: tar zxvf redis-4.0.10.tar.gz
  4. cd redis-4.0.10
  5. [redis-4.0.10] mkdir /redis
  6. [redis-4.0.10] mkdir /redis/conf
  7. [redis-4.0.10] cp redis.conf /redis/conf
  8. [redis-4.0.10] cd deps
  9. [redis-4.0.10/deps] make hiredis lua jemalloc linenoise
  10. [redis-4.0.10/deps] cd ..
  11. 編譯: [redis-4.0.10] make
  12. 安裝: [redis-4.0.10] make PREFIX=/redis install
這樣 Redis 就安裝到 /redis 目錄下了,檢查一下 /redis/bin 目錄,會有以下檔案。
接下來當然要啟動看看是不是正常 …
假設目前是在 /redis 目錄下。
  1. 修改設定檔: vi conf/redis.conf,將 daemonize 的值改為 yes,這樣啟動服務後才會在背景執行。
  2. 啟動服務: bin/redis-server conf/redis.conf,可以看到如下的畫面,服務已經正常啟動。
  3. 關閉服務: bin/redis-cli shutdown

2018年7月20日 星期五

Spring Boot Angular Websocket

這篇要說明的是如何由 Angular 透過 websocket 與 spring boot 的 server 雙向溝通。我使用的版本為 Angular 5 及 spring boot 2.0.3。程式執行會如下圖,測試步驟如下:
  1. 按【Connect】連線到 server。
  2. 輸入名稱後按【Send】,將名稱送到 server。
  3. server 收到後,再將名稱回覆給 Angular。
  4. Angular 收到回覆後顯示「Greetings」表示成功。
  5. 按【Disconnect】終止與 server 的連線。

文章的來源是「Spring Boot Angular Websocket」,我直接執行裡面的程式會有問題,我修正後整理如下。
  • Angular 程式
    • install
npm install stompjs --save
npm install sockjs --save
使用這兩個函式庫可連與 server 端進行 websocket 連線。STOMP 的解釋如下 ...

What is STOMP

STOMP stands for Streaming Text Oriented Messaging Protocol. As per wiki, STOMP is a simple text-based protocol, designed for working with message-oriented middleware (MOM). It provides an interoperable wire format that allows STOMP clients to talk with any message broker supporting the protocol.
This means when we do not have STOMP as a client, the message sent lacks of information to make Spring route it to a specific message handler method. So, if we could create a mechanism that can make Spring to route to a specific message handler then probably we can make websocket connection without STOMP.
大意差不多是這樣的 ...
STOMP 即 Simple (or Streaming) Text Orientated Messaging Protocol,簡單(流)文本定向消息協議,它提供一個可互操作的連接格式,允許 STOMP 客戶端與任意 STOMP 訊息代理 (broker) 進行交互傳送訊息。STOMP 協議由於設計簡單,易於開發客戶端,因此在多種語言和多種平台上得到廣泛應用。
    • app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule, FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
下面的頁面會用到 Input,是屬於 FormsModule 裡的 component,這裡要先加入宣告。
    • app.component.html
<div id="main-content" class="container">
  <div class="row">
    <div class="col-md-6">
      <form class="form-inline">
        <div class="form-group">
          <label for="connect">WebSocket connection:</label>
          <button id="connect" class="btn btn-default" type="button" [disabled]="disabled" (click)="connect()">Connect</button>
          <button id="disconnect" class="btn btn-default" type="button" [disabled]="!disabled" (click)="disconnect()">Disconnect
          </button>
        </div>
      </form>
    </div>
    <div class="col-md-6">
      <form class="form-inline" name="test-form">
        <div class="form-group">
          <label for="name">What is your name?</label>
          <input type="text" id="name" name="name" class="form-control" placeholder="Your name here..." [(ngModel)]="name">
        </div>
        <button id="send" class="btn btn-default" type="button" (click)="sendName()">Send</button>
      </form>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12" *ngIf="showConversation">
      <table id="conversation" class="table table-striped">
        <thead>
        <tr>
          <th>Greetings</th>
        </tr>
        </thead>
        <tbody *ngFor="let greeting of greetings" >
          <tr><td> </td></tr>
        </tbody>
      </table>
    </div>
  </div>
</div>
最前面那個圖就是這個網頁顯示出來的樣子,會搜尋到這篇網誌的人應該都懂 Angular 吧? 我把重點用紅色標出,它們會對應到下面 app.component.ts 裡的相關 method 或 property。
    • app.component.ts
import { Component } from '@angular/core';
import * as Stomp from 'stompjs';
import * as SockJS from 'sockjs-client';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';

  greetings: string[] = [];
  showConversation: boolean = false;
  ws: any;
  name: string;
  disabled: boolean;

  constructor(){}

  connect() {
    let socket = new WebSocket("ws://localhost:8080/greeting");
    this.ws = Stomp.over(socket);
    let that = this;
    this.ws.connect({}, function(frame) {
      that.ws.subscribe("/errors", function(message) {
        alert("Error " + message.body);
      });
      that.ws.subscribe("/topic/reply", function(message) {
        console.log(message)
        that.showGreeting(message.body);
      });
      that.disabled = true;
    }, function(error) {
      alert("STOMP error " + error);
    });
  }

  disconnect() {
    if (this.ws != null) {
      this.ws.ws.close();
    }
    this.setConnected(false);
    console.log("Disconnected");
  }

  sendName() {
    let data = JSON.stringify({
      'name' : this.name
    })
    this.ws.send("/app/message", {}, data);
  }

  showGreeting(message) {
    this.showConversation = true;
    this.greetings.push(message)
  }

  setConnected(connected) {
    this.disabled = connected;
    this.showConversation = connected;
    this.greetings = [];
  }
}
  1. server 提供的 broker endpoint 為 /greeting,所以一開始要先從這個介面與 server 端建立連線。
  2. 傳送資料是傳送到 /app/message,回覆資料是以 call back 的方式回覆,所以要訂閱。
  3. 上面可以看到我們訂閱了兩個訊息 "/topic/reply"、"/errors",分別是正確時回覆的訊息及當發生錯誤時回覆的錯誤訊息。
  • spring boot Server 端程式
    • 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.demowebsocket'
version = '1.0'
sourceCompatibility = 1.8

repositories {
  mavenCentral()
}

dependencies {
  compile('org.springframework.boot:spring-boot-starter-security')
  compile('org.springframework.boot:spring-boot-starter-web')
  compile('org.springframework.boot:spring-boot-starter-websocket')
  compileOnly('org.projectlombok:lombok')
  testCompile('org.springframework.boot:spring-boot-starter-test')
  testCompile('org.springframework.security:spring-security-test')
 
  compile group: 'com.google.code.gson', name: 'gson', version: '2.8.5'
}
特別注意紅色那三個函式庫,我們正在寫 websocket 程式,當然要引入第二個紅色的函式庫,第一個則是因為 spring security 需要開啟一些連線的設定,Angular 端才能連的進來,第三個紅色的函式庫則是因為 client、server 間是以 JSON 格式傳遞,所以需用到。
    • SecurityConfig.java
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;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/**").permitAll();
  }
}
為了測試方便,簡單的設定為允許所有 request 不需認證。
    • WebSocketConfig.java
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

  @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic/", "/queue/");
    config.setApplicationDestinationPrefixes("/app");
  }

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/greeting").setAllowedOrigins("*");
  }
}
要使用 STOMP,需在有 @Configuration 的類別上加上 @EnableWebSocketMessageBroker (紅色),下面橘色是開發 broker 的 endpoint 為 "/greeting",並且允許所有人都可以連線進來。
    • WebSocketController.java

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Controller;

import com.google.gson.Gson;

@Controller
public class WebSocketController {

  @Autowired
  private SimpMessageSendingOperations messagingTemplate;

  @MessageMapping("/message")
  @SendTo("/topic/reply")
  public String processMessageFromClient(@Payload String message) throws Exception {
    String name = new Gson().fromJson(message, Map.class).get("name").toString();
    return name;
  }
 
  @MessageExceptionHandler
  public String handleException(Throwable exception) {
    messagingTemplate.convertAndSend("/errors", exception.getMessage());
    return exception.getMessage();
  }
}
  1. @MessagMapping("/message") 是 client 傳過來時的路徑,要注意,在前面我們有設定 prefixes 為 "app",所以實際的路徑為"/app/message"。
  2. @SendTo("...") 是回覆給 client 的路徑,client 想收到回覆訊息,要先訂閱。
  3. @MessageExceptionHandler 是發生錯誤時會執行的 method,client 端想收到錯誤訊息,要訂閱 "/errors"。
    • DemowebsocketApplication.java
@SpringBootApplication
public class DemowebsocketApplication {

  public static void main(String[] args) {
    SpringApplication.run(DemowebsocketApplication.class, args);
  }
}

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。