0%

單元測試原則

單元測試 (unit test) 表示針對一個片段的程式碼給定某個測項 input 來執行此程式片段, 並輸出 output 來比較是否符合預期的結果。

Given-When-Then 原則

此為基本寫一個單元測試的結構:

Given: 表示要測的測向 input
When: 被測的程式碼執行 context
Then: 預期出來的結果

1
2
3
4
5
6
7
8
9
10
11
12
13

def add(a, b):
return a + b


def test_add():
# given
a, b = 2, 3
# when
result = add(a, b)
# then
assert result is 5

程式模組化

程式再撰寫時,必須要把本身的邏輯給拆分(ex: function, module),才比較好導入測試。因此,要做 unit test 程式碼本身必須有一定的模組化。

測試隔離原則

程式邏輯多少會牽扯到外部的相依性呼叫 ex: (DB query, call API) 必須要把程式有牽扯到外部的相依性的邏輯盡可能的去 decouple,如此才比較好導入單元測試。真的無法避免在考慮使用 mock 或 stub 的方式來替代外部環境依賴。

一個功能,一個 Assert

寫單元測試也務必遵守單一職責原則,就是一個測試儘可能專注於一個 test case,如此測試的定義才會清楚,才不會在執行時模糊不清。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# not good
def test_module1():
assert module1.foo1() is "foo1"
assert module1.foo2() is "foo2"
assert module1.foo3() is "foo3"

# prefer
def test_module1_foo1():
assert module1.foo1() is "foo1"

def test_module1_foo2():
assert module1.foo2() is "foo2"

def test_module1_foo3():
assert module1.foo3() is "foo3"

測試獨立原則

單元測試中每個 test case 彼此間都是完全獨立的且不可相依,彼此測試間都有獨立的環境啟動以及結束,此即為 setup 以及 tear down操作。假設有三個測試啟動以及結束都會個別觸發 setup 以及 tear down三次。以下以 pytest 為範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class TestClass:
def setup_class(self):
print("setup_class called once for the class")

def teardown_class(self):
print("teardown_class called once for the class")


def setup_method(self):
print("setup_method called for every method")

def teardown_method(self):
print("teardown_method called for every method")


def test_one(self):
print("one")
assert True
print("one after")

def test_two(self):
print("two")
assert False
print("two after")

def test_three(self):
print("three")
assert True
print("three after")

執行此測試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ pytest -s test_class.py

setup_class called once for the class
setup_method called for every method
one
one after
teardown_method called for every method
setup_method called for every method
two
teardown_method called for every method
setup_method called for every method
three
three after
teardown_method called for every method
teardown_class called once for the class

reference:

  1. https://martinfowler.com/bliki/UnitTest.html
  2. https://martinfowler.com/bliki/GivenWhenThen.html
  3. https://code-maven.com/slides/python/pytest-class

最近在串 ci/cd 的時候發現了一個很細微但我卻忽略的問題,就是 bash 中單引號與雙引號的行為差異。首先看到第一個例子,遠端 ssh 在一台 VM 上下如下指令,這條指令很直覺的就會想到在遠端機器給定FOO 環境變數並且印出吧?

1
$ ssh mark.chang@35.189.166.130 "export FOO=GGININDER; echo $FOO"

但在執行的時候實則不然!FOO 怎麼是空的呢?原因在於這裏 bash 中雙引號會自動解析本來上下文的環境變數,導致這邊印出來的FOO其實是 local 這邊的上下文的環境變數,因為在 local 並沒有設定FOO 所以就是空的了。

1
$

因此要達到預期在遠端 VM 透過 ssh 指令下環境變數並印出,就必須把原來 bash 的雙引號改成用單引號,才不會遇到特殊字元$就自動解析成本來上下文的變數了。

1
$ ssh mark.chang@35.189.166.130 'export FOO=GGININDER; echo $FOO'

改成單引號後,FOO也正確的指向遠端 VM 的環境變數了。

1
$ GGININDER

這裡也延伸出另一個應用,就是如何透過 ssh 將本地的變數指派給遠端 VM 上的變數呢?這時就可以透過單引號與雙引號連用來達到。在單引號中,本地的變數可以透過在前後加入雙引號來指向。

1
2
$ export FOO=1234
ssh mark.chang@35.189.166.130 'export FOO='"$FOO"'; echo $FOO'

如此就可以把本地的變數給指派給遠端 VM 上了。

1
$ 1234

reference:

  1. https://stackoverflow.com/questions/6697753/difference-between-single-and-double-quotes-in-bash

今天遇到了在 docker 執行 crontab 環境變數消失的問題。明明在 docker 裡已經有設環境變數了,但 crontab 實際在執行時並不會考慮所有的環境變數

試了很多方法都沒有用,最後參考了一種在 crontab 執行前先將 docker 內所有設好的環境變數都寫入一個 shell script 中,如此就可以在 crontab 執行真正排程指令前執行此 shell script 載入環境變數。

1
2
3
4
5
6
7
8
FROM python:3.7-slim-buster
ENV TZ=Asia/Taipei
WORKDIR /dump
COPY . .
RUN apt-get update && apt-get install -y curl cron && \
pip install -r requirements.txt

ENTRYPOINT /dump/run-cron.sh

dockerfile 如上圖所示,其中的關鍵是在 run-cron.sh,程式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash

ENV_VARS_FILE="/root/.env.sh"

echo "Dumping env variables into ${ENV_VARS_FILE}"
printenv | sed 's/^\(.*\)$/export \1/g' > ${ENV_VARS_FILE}
chmod +x ${ENV_VARS_FILE}

echo "Applying crontab"
crontab /dump/crontab.txt

echo "Running crontab"
cron -f

裡面的第 6 行,用 printenv 印出 docker 環境變數再用 sed 指令取出每一行變數組合成 export VARIABLE_NAME=VALUE 形式在寫入 sh 檔,crontab 則以如下設定來執行。

1
0 */3 * * * . /root/.env.sh; /usr/local/bin/python /dump/dumper.py -days=7 >> /var/log/cron.log 2>&1

在真正要執行的指令之前,再加上 . /root/.env.sh; 來去確實載入所有環境變數。

ref:

  1. https://gist.github.com/athlan/b6f09977e2f5cf20840ef61ca3cda932
  2. https://www.sitepoint.com/a-comprehensive-crash-course-into-cronjobs/

最近想複習基本的資料結構,首先第一個想到還給老師就是 heap 了。所以就從 heap 開始。
這次實作的是 max heap,定義如下圖所示,max heap 為一個完整二元樹 (complete binary tree),且每個父節點皆大於等於其左右子節點,由於完整二元樹的性質, max heap 資料結構可以很方便的使用陣列來表示。

假設陣列 index i 從 1 開始,heap 用陣列來表示又可以延伸出以下節點間的性質:

有了以上的節點性質,就可以很方便的應用在以下的實作。首先介紹max heap裡面最重要的部分 max heapify,給定一個元素的 index,由 top-down 的方式遞迴檢查該節點及其左右子節點有無符合 max heap 定義,若無則調整之。程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
def max_heapify(a, i, heap_size):
left_i = 2 * i + 1
right_i = 2 * i + 2

if left_i < heap_size and a[i] < a[left_i]:
biggest = left_i
else:
biggest = i
if right_i < heap_size and a[biggest] < a[right_i]:
biggest = right_i
if biggest != i:
a[biggest], a[i] = a[i], a[biggest]
max_heapify(a, biggest, heap_size)

max heapify 設計上是針對該點以及左右子結點,依深度優先搜尋路徑調整成 max heap,如果 max heapify 的起始是根節點,依序要將整顆樹都調整成max heap,是會有其盲點的,因為這樣不會考慮整體的樹皆為 max heap,所以max heapify的起始點要從最小的父節點開始,以 bottom up 的方式由小到大來調整才可以確保整棵樹都符合max heap性質,程式碼如下所示。

1
2
3
4
5
# build max-heap
def build_max_heap(a, heap_size):
for i in range(len(a)//2-1, -1, -1):
max_heapify(a, i, heap_size)
print(f'max heap: {a}')

len(a)//2-1 表示從最後位置的父節點開始往前做 max heapify。最後介紹 max heap 應用,由小到大排列的 heap sort,程式碼如下。

1
2
3
4
5
6
7
8
9
# heap sort
def heap_sort(a):
heap_size = len(a)
build_max_heap(a, heap_size)
for i in range(len(a)-1, 0, -1):
a[0], a[i] = a[i], a[0]
heap_size -= 1
max_heapify(a, 0, heap_size)
print(f'heap sort: {a}')

原理為將 max heap tree 中的最大根節點與最後節點交換,接著縮小 heap size 範圍,呼叫 max heapify 調整,再繼續重複以上步驟直到所有陣列元素都調整過結束。
ref:

  1. https://web.stanford.edu/class/archive/cs/cs161/cs161.1168/lecture4.pdf

久聞 Haproxy 是一個非常知名的負載均衡 (Load Balancing) 軟體,最近心血來潮,想來研究在分散式 docker-swarm 的環境來用 HAproxy 模擬負載均衡。部署的內容很簡單,先起多個不直接對外的 web server 服務,然後再啟動一個 Haproxy 做反向代理 (reverse proxy) 來為這些背後的 web server 做負載均衡以及對外的窗口。

首先,我用 python 寫了一個簡單的 http server 程式來模擬真正的 web server。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import os
from http import server

class RequestHandler(server.SimpleHTTPRequestHandler):

def do_GET(self):
self.send_response(200, 'ok')
self.end_headers()
response = f'hostname: {os.uname()[1]}'
self.wfile.write(response.encode('utf-8'))
return

if __name__ == '__main__':
with server.HTTPServer(('', 8000), RequestHandler) as http_server:
print('start simple web server...')
http_server.serve_forever()

程式的內容為 client 向 server 發出請求後,會在瀏覽器頁面上印出該 server 的 hostname,如此就可以很方便的觀察之後負載均衡的行爲。

先來看看本次 docker swarm 的部署編排,docker-stack.yml如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
version: '3.7'

services:
web:
image: simple_web
deploy:
replicas: 3
endpoint_mode: dnsrr
networks:
- haproxy-practice
haproxy:
image: haproxy:2.1
depends_on:
- web
volumes:
- "./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg"
ports:
- "48686:8000"
networks:
- haproxy-practice

networks:
haproxy-practice:
driver: overlay

這裡我打算起三個 web server 以及一個 HAproxy,並將 endpoint_mode 設為 dnsrr(DNS Round Robin) 而不用預設的 vip(virtual IP),因為 docker swarm 也有自己內部的負載均衡機制叫 routing mesh,會轉傳外部的請求到其中一個作用的 container。如果是預設的 vip 最後只會回應一個虛擬的 ip,無法得知其他 container replicas 的 ip,造成的結果就是 HAproxy 只會偵測到一台 web server。因此如果要使用外部的負載均衡就必須指定 dnsrr 模式才可跳過 routing mesh 取得多個 container ip。

然後就是關鍵的 HAproxy 設定部分了,HAproxy 的設定檔為 haproxy.cfg,內容如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
global
daemon
maxconn 256

resolvers docker
nameserver dns1 127.0.0.11:53
resolve_retries 3
timeout resolve 1s
timeout retry 1s
hold other 10s
hold refused 10s
hold nx 10s
hold timeout 10s
hold valid 10s
hold obsolete 10s

defaults
timeout connect 10s
timeout client 30s
timeout server 30s
mode http

frontend haproxyPractice
bind *:8000
use_backend stat if { path -i /my-stats }
default_backend web_service

backend web_service
balance roundrobin
server-template web- 3 web:8000 check resolvers docker init-addr libc,none

backend stat
stats enable
stats uri /my-stats
stats refresh 15s
stats show-legends
stats show-node

設定檔中有很多不同的區塊,在此不一一贅述這篇文章有非常詳盡的介紹。這裡挑幾個重點區塊設定來說明,首先是 resolvers 區塊,docker 有自己內部的 dns 服務,可以允許在同一個 docker 網路中的服務用自己的服務名稱來連線,這裡使用 docker 內部的 dns 服務 127.0.0.11:53 來讓 HAproxy 自動解析真實 container ip。

再來就是 backend web_service 區塊設定,server-template 參數可以快速的建立多個 server 設定而不用個別慢慢下。web- 3中的web- 表示 server 名稱的前綴,3 表示 server 名稱的總數,因此會產生 web-1、web-2、web-3 共三個 server,剛好與前面我設定的replicas: 3 對應。如果實際的 replicas 比較少,則空下來的 server 會自動 disable 掉。

同時也要指定 resolvers docker 讓 HAproxy 知道要使用哪個 dns resolver,init-addr libc,none 則指示 HAproxy 再啟動時就執行服務發現,並且在無任何 wed server 也繼續執行。

最後到了啟動的步驟了,輸入以下指令來啟動 docker swarm 服務。

1
docker stack deploy -c=docker-stack.yml haproxy-practice

接著不停的重整瀏覽器可以觀察每次的 hostname 都會受到負載均衡而有所不同。

也可以瀏覽 /my-stats 來查看 HAproxy 的管理頁面看看個別服務的情形。

這次部署的相關程式碼也同步於 github 上。

reference:

  1. https://www.haproxy.com/blog/haproxy-on-docker-swarm-load-balancing-and-dns-service-discovery/
  2. https://www.digitalocean.com/community/tutorials/an-introduction-to-haproxy-and-load-balancing-concepts

今天遇到了一個 sql join 的語法問題,首先第一個 sql 如下:

1
2
3
SELECT c.customer_id, o.order_id, o.order_date 
FROM customers as c JOIN orders as o
ON c.customer_id=o.customer_id;

很明顯的這是一個簡單的 inner join 任務,選擇出符合條件c.customer_id=o.customer_id的共同紀錄。

然而 sql 有另一種不用明確指定使用 join 的方式也可以達到一樣的結果,第二個 sql 如下:

1
2
3
SELECT c.customer_id, o.order_id, o.order_date 
FROM customers as c, orders as o
WHERE c.customer_id=o.customer_id;

兩種語法都可以那應該本質上差不多吧?我錯了,大錯特錯。
本來一直天真的以為 FROM TABLE1, TABLE2 背後應該也是 inner join 的語法糖吧?結果我後來網路查證得到的結果是當 sql 表示成 FROM TABLE1, TABLE2 會對這兩張表進行 cross join,等同於 FROM TABLE1 CROSS JOIN TABLE2
而什麼是 cross join 呢?其實就是對這兩張表的各個紀錄做笛卡兒積 (cartesian product),如下圖所示:

所以如果 table1、table2 各有 N、M 筆記錄,那經過 cross join 後就會有 N * M 筆記錄 !
這實在是與 inner join 依據條件取出交集的行為天差地遠。以下用另一張圖表示各個 join 的關係:

可以看出 cross join 與其他 join 的行為明顯不同。與 inner join 減少選擇資料記錄相比,cross join 則是會大幅度增加資料記錄!

因此,可以想像如果不小心使用了第二個 sql 會導致不必要的資料表 cross join 的動作,在資料量大的環境時很容易就增加資料庫效能的負擔。即使最後有用 WHERE 篩選條件讓結果看起來一樣,但是前面光是 cross join 取出的記錄筆數就實在與直接下 inner join 差太多了!

而為什麼會有第二種寫法呢?我經過網路查證發現其實第二種寫法是源自資料庫標準協定 ANSI-89 的舊式寫法,那是一個還沒有 join 語法的舊協定,要表示成 inner join 只能用 FROM TABLE1, TABLE2 這種 implicit 的方式,新的協定 ANSI-92 才有 join 的寫法。這次的心得就是要好好地善用 join 以及認真的再把資料庫複習一遍!

reference:

  1. https://www.techonthenet.com/sql/joins_try_sql.php
  2. https://stackoverflow.com/questions/334201/why-isnt-sql-ansi-92-standard-better-adopted-over-ansi-89
  3. http://www.sqlcourse2.com/joins.html
  4. https://stackoverflow.com/questions/11861417/what-is-the-difference-between-cartesian-product-and-cross-join
  5. https://stackoverflow.com/questions/17946221/sql-join-and-different-types-of-joins

最近常常用到 ssh 這個指令覺得實在是太重要了,因此有必要好好整理一番來好好吸收消化。

透過 ssh 指令執行 remote command line

執行單一指令

1
ssh username@remote_host 'ls -al'

執行多列指令

1
2
3
4
ssh -t username@remote_host << 'ENDSSH'
pwd
ls
ENDSSH

參數 -t 表示 Force pseudo-tty allocation,據參考資料有些指令不佳會出錯。
'ENDSSH'...ENDSSH為執行多列指令的區塊,第一個'ENDSSH'有單引號防止 bash 將之視為變數。

reference

  1. https://stackoverflow.com/questions/216202/why-does-an-ssh-remote-command-get-fewer-environment-variables-then-when-run-man
  2. http://queirozf.com/entries/shell-scripting-examples-ssh

用 ssh 做代理

當跳板來翻牆

1
ssh -vNL bind_address:bind_port:target_address:target_port username@proxy_address

v: Verbose mode. 印出詳細的 debug 訊息
N: 不去執行遠端指令,如此可避免登入跳板
L: 關鍵的轉 port 參數 [bind_address:]bind_port:target_address:target_port
bind_address 不輸入預設就是 localhost

開後門讓外網可連入

首先要在被限制連線的區網 server 上執行以下指令來開後門。

1
$local_server ssh -vNR [proxy_address]:proxy_port:local_address:local_port username@proxy_address

r: 關鍵的參數,與L相反,將 local_address:local_port 往後導回
[proxy_address]:proxy_port

之後再到 proxy server 上執行以下指令就可以透過 proxy 登入到原本連不通的區網 server。

1
$proxy_server ssh local_server@proxy_address -p proxy_port

reference

  1. https://www.ubuntu-tw.org/modules/newbb/viewtopic.php?viewmode=compact&topic_id=17538&forum=7
  2. https://ez3c.tw/2043
  3. https://blog.rex-tsou.com/2017/10/利用-ssh-tunnel-做跳板aka.-翻牆/

ssh 金鑰登入

金鑰生成

1
ssh-keygen

預設會在.ssh 目錄下產生公鑰 id_rsa.pub以及私鑰id_rsa

複製公鑰到欲登入 server

1
ssh-copy-id -i ~/.ssh/id_rsa.pub USER@HOST

-i 為可以指定要使用哪一把公鑰,公鑰會被複製到欲登入 server的 ~/.ssh/authorized_keys 檔案中。

reference

  1. https://blog.gtwang.org/linux/linux-ssh-public-key-authentication/

最近因緣際會看到當 python 函式的預設參數為 mutable 物件時會有意想不到的行為故記錄之。考慮以下的程式,有一個帶有預測參數為list,的函式並且呼叫了兩次。一般預期的結果一定是 [3]、[4] 對吧?

1
2
3
4
5
6
def foo(el, targets=[]):
targets.append(el)
return targets

foo(3)
foo(4)

直接看執行結果,其實不然!執行第二次的時候發現上一次的 list targets 仍然存留著,導致 4 接著 append 上去。

1
2
[3]
[3,4]

為什麼會這樣呢?函式的預設參數照理應該是區域變數啊!其實這與 python 程式語言的機制有關。

因為 python 的函式也是物件,因此預設參數也算是函式物件的屬性之一,當 python 函式在定義階段時,預設參數這個屬性就被定義完成了,之後無論函式被呼叫幾次是不會再重新定義的,所以如果把預設參數定義成 mutable 物件就會發生上述的結果。

所以最好不要將預設參數直接指定為 mutable 物件以防產生不預期行為,若有需要則將程式改成以下即可避免。

1
2
3
4
5
def foo(el, targets=None):
if targets is None:
targets = []
targets.append(el)
return targets

這樣子 targets 這個 list 變數就會隨著函式每次呼叫而重新定義了。

reference

  1. https://docs.python-guide.org/writing/gotchas/
  2. http://effbot.org/zone/default-values.htm

今天與公司的資深同事討論了爬蟲突破 IP 封鎖的技術,學習了有一個叫做洋蔥網路 (TOR) 的厲害技巧,是除了一般使用付費 proxy IP 外另一種突破 IP 封鎖的方式,以下紀錄我學習的心得。

TOR 的全名為 The Onion Project,發源至美國海軍開發的一個匿名交流網路。用戶端使用這種網路不會直接與要存取的目標電腦連線,而是會在兩者間隨機挑選多個中繼站當節點,形成一條連線路徑。而且每一個中繼結點間都會層層加密,只有目的地節點可以解密看到最終的內容,如此特性有如洋蔥多層的結構一樣複雜所以稱之,示意圖如下圖所示。

而 TOR 這種複雜的隨機多重代理特性,使得其難以追蹤使用者真實的來源IP,因此成為暗網活動最好的平台。python 也剛好有支援發送 TOR 請求的套件,以下為一個簡單的 tor 請求存取程式建立步驟。

首先,參考此篇安裝範例安裝 TOR 相關套件及設定,這裡我使用 docker Ubuntu 的 container 作為安裝環境,首先安裝 TOR。

1
2
sudo apt-get update
sudo apt-get install tor

安裝完成後接著就可以試著啟動 TOR 服務了。

1
2
service tor start 
* Starting tor daemon... [ OK ]

接著可以進一步的去設定 TOR,這裏利用--hash-password設定了一組密碼以防本機被其他外部代理所存取,輸出為得到了一個 hash 碼。

1
2
tor --hash-password <enter your password here>
16:ED2893D8EC97801C60DF4A72249CBCCD8B97B3B01A15C923DC49A0E500

接著到設定檔所在位置 /etc/tor/torrc 設定密碼以及 ControlPort

1
2
3
4
5
ControlPort 9051
HashedControlPassword <your hashed passsword obtained earlier here>
CookieAuthentication 1

### This section is just for location-hidden services ###

最後重啟服務來完成設定。

1
2
pidof tor | xargs kill
service tor start

以上設定都完成後,就可以安裝 TOR 的 python wrapper torrequest 來進行程式開發了。程式為使用一般的請求以及 TOR 請求到 http://ipecho.net/plain 來觀察自己的 IP 位址有無變化。

1
2
3
4
5
6
7
8
9
10
11
import requests
from torrequest import TorRequest

res = requests.get('http://ipecho.net/plain')
print("my original ip:", res.text)

for i in range(5):
with TorRequest() as tr:
response = tr.get('http://ipecho.net/plain')
print("New ip:", response.text)
tr.reset_identity()

程式執行的結果如下所示,可以發現每次 reset TOR 連線路徑後所獲得的來源 IP 都不一樣了!不過可惜的是就是速度太慢了,本實驗每次建立 tor 連線都要花上近 10 秒左右,要實際上給爬蟲使用效果很有限,但也學到新的一招就是了。

1
2
3
4
5
6
my original ip: 1.162.217.109
New ip: 185.220.101.21
New ip: 85.202.163.127
New ip: 199.87.154.255
New ip: 178.17.166.147
New ip: 104.244.76.13

reference

  1. https://www.scrapehero.com/make-anonymous-requests-using-tor-python/
  2. https://gist.github.com/DusanMadar/8d11026b7ce0bce6a67f7dd87b999f6b
  3. https://github.com/erdiaker/torrequest

hanlp 是一個由大陸開發非常強大的中文自然語言處理工具,由 Java 開發,目前也有提供 python 的介面 pyhanlp 了。之前的任務為要使用 flask 並結合 hanlp 寫成一個 API 的 web service,果然遇到一些導入上的問題,以下來記錄我成功串接的過程。

首先,要將 hanlp 打包成 python 的 web service 用一般 python 載入模組的方法像 import pyhanlp 是行不通的,在一般的 py 執行檔是可以使用,但用在 python web 框架 flask 上服務會 crash 掉。原因好像為 pyhanlp 跟底層 Java 的溝通方式無法運用在 web 環境有關。因此第一個關鍵為要改使用可讓 python 直接溝通 java 的套件 jpype,來直接取用 hanlp 的 jar 檔。程式碼如以下所示。

1
2
3
hanlp_lib_path = "./lib/"
java_class_path = hanlp_lib_path + 'hanlp-1.6.8.jar' + ':' + hanlp_lib_path
startJVM(getDefaultJVMPath(), '-Djava.class.path=' + java_class_path, '-Xms1g', '-Xmx1g')

可以看到前面指定了 jar 檔的位置並加入給 JVM 執行,這裡我在同一個專案目錄下的 lib 資料夾結構如下。

1
2
3
4
5
lib/
data/
hanlp-1.6.8.jar
hanlp.properties
hanlp.properties.in

data 就是辭典的部分,共 600 多 MB,hanlp-1.6.8.jar 就是 hanlp jar 檔本體了,而hanlp.properties 用來配置辭典的路徑,這裡要在hanlp.properties中最前面第一行加上 root=./lib/表示作用的根目錄。

再來第二重要點就是由於 API 呼叫為多執行緒的環境,因此也必須在 java class 執行之前加上以下程式來讓 JVM 支援多執行緒,確保 API 服務不會掛掉。

1
2
if not jpype.isThreadAttachedToJVM():
jpype.attachThreadToJVM()

完整的程式碼如下所示,這裡我把 hanlp 呼叫寫成了一個類別,這樣就可以在 flask 或 Django 等 API 框架直接 import 我這個 hanlp 類別而不會報錯了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import logging
import jpype
from jpype import startJVM, JClass, getDefaultJVMPath

logger = logging.getLogger(__name__)
hanlp_lib_path = "./lib/"
java_class_path = hanlp_lib_path + 'hanlp-1.6.8.jar' + ':' + hanlp_lib_path
startJVM(getDefaultJVMPath(), '-Djava.class.path=' + java_class_path, '-Xms1g', '-Xmx1g')

class NerService:
def __init__(self):
self.raw_text = None

def set_raw_text(self, raw_text):
self.raw_text = "".join(raw_text.split())
logger.info(self.raw_text)

def perform_ner(self):
# 啟動支援 thread
if not jpype.isThreadAttachedToJVM():
jpype.attachThreadToJVM()
PerceptronLexicalAnalyzer = JClass('com.hankcs.hanlp.model.perceptron.PerceptronLexicalAnalyzer')
analyzer = PerceptronLexicalAnalyzer()
s = str(analyzer.analyze(self.raw_text))
self.hanlp_result_str = s
analyze_list = s.split(" ")
return analyze_list

reference
https://www.jianshu.com/p/d7e7cc747e56