Company Ghost Story 公司鬼故事 9

Java

BufferWriter/StringBuilder

1
2
3
4
5
private void writeLong(long v) {
StringBuilder sb = new StringBuilder(8192); // ?
sb.append(Long.toString(v));
writer.write(sb.toString());
}

每一次轉換,別浪費額外的空間 8192 的 StringBuilder。如果真的需要這種 IO 的緩衝區,可以直接宣告在 BufferWriter 給定預設大小的緩衝區。

BufferWriter/StringBuilder

1
2
3
private String writeTwo(String token0, String token1) {
writer.write(token0 + token1); // why not write separately without intermediate result.
}

既然都知道了字串串接會有中間結果,我們可以直接分開寫 writer.write(token0);writer.write(token1); 來達到相同效果。

StringBuilder Helper

1
2
3
4
5
6
7
8
9
10
private static StringBuilder append(StringBuilder sb, String... tokens) {
for (String t : tokens)
sb.append(t);
return sb;
}
append(sb, "a", "b", "c").append("d", "e");
// if performance it critical, please write all statements without sugar syntax
append(ab, "a" + "b", "c");
// ... why?

這種可變參數的寫法會額外產生一個陣列,對於效能來說會很傷。

Math Getter/Setter

1
2
3
4
5
// convert x' = a x + b
private void convert(Point p) {
p.setX(p.getX() * a);
p.setX(p.getX() + b); // why not p.setX(p.getX() * a + b);
}

別中毒於 Java 或者是 OOP 的寫法。

Lookup by Key

1
2
3
4
5
6
7
8
9
10
public T getSelected() {
for (T c : candidates) {
if (Objects.equals(c.toKey(), mSelected))
return c;
}
return null;
}
public void setSelected(T s) {
mSelected = s.toKey(); // why not store `s` directly?
}

在大多數的狀況下都可以直接存原本的物件。除非是在一些特定的序列化,或者是橫跨兩個不同 domain 的交互資料,這時候我們必須採用字串作為中介對象。

Default Value by Wrapper Class

1
2
3
4
5
6
7
8
9
10
11
private Boolean mirror = false; // why use wrapper class here?
public void setMirror(boolean m) {
mirror = m;
}
private void process() {
if (mirror != null) { // why?
// must do here
}
}

多餘的寫法。如果 wrapper class 有預設值,而且還不能被設置 null。那必然有些地方出錯。

Constant Value by Wrapper Class

1
2
3
4
5
private static final Double SIZE = 50L; // ...
public void exec() {
long v = SIZE.longValue(); // ...
}

這傢伙到底在宣告什麼?想用什麼?

Read More +

Java JNI Thread Error (EINVAL)

Keywords: JNI, Java, ProcessBuilder, Thread Error

Error message: [os,thread] Failed to start thread - pthread_create failed (EINVAL) for attributes: stacksize: 136k, guardsize: 0k, detached.

Solution: -Djdk.lang.processReaperUseDefaultStackSize=true

Description

From JNI process, it is fine to execute Java internal multi-threading, but can not use Runetime.exec or ProcessBuilder to do anything. Even through adjust java stack-related option

1
setenv _JAVA_OPTIONS "-ea -Xlog -XX:ThreadStackSize=31744 -XX:VMThreadStackSize=1024 -XX:CompilerThreadStackSize=1024"

cgroup (Linux control group) limition, and set_rlimit. Same error message will be displayed.

When you link large C/C++ program with large thread local storage (TLS), default minimum stacksize computation is not enough to create pthread in OS-level. Only to set -Djdk.lang.processReaperUseDefaultStackSize=true to apply default pthread arguments. Then, it will be working for you.

問題描述

無法透過 JNI 啟動的 JVM 運行外部程序,如 Runtime.exec 或者是 Process 都無法使用,卻可以使用內部執行緒,甚至達到了數百個執行緒。

首先,JVM 內部拆分了好幾種不同的執行緒使用策略,也有不同的資源需求判斷,去挖原始碼的時候會追到底層的 C 部分,就可以看出這些問題。但是錯誤訊息仍然不足,到了 JDK15 後,才能透過 -Xlog dump 更多的配置訊息,但有些配置訊息的翻譯仍然有錯,還是得看原始碼去得知實際情況。

追了好幾天,調適了許多不同的 stack 配置都沒有起色。自己寫的小短碼 JNI 都沒有事情,架在產品上就突然運行失敗。因此,只能去找有沒有現有的產品開發或使用上發生類似的問題,不該去找任何的教學文件。最後挖到幾篇安裝不同版本的產品失敗,據說更改 JDK 版本就能解決,然後有人分析說因為 TLS 的大小變動才導致新版本突然無法工作,才明白果然是 JNI 上的問題。

實作 JNI 介面的時候,若連接的 C++ 程式中存在大量的 thread_local 變數時,那麼在每一個執行緒中都必須給予相對應的大小進行初始化。而對於 JVM 內部會透過一些預設 stack 配置進行最小化,透過 JNI 連接的時候,便會受到影響。

透過 -Djdk.lang.processReaperUseDefaultStackSize=true 取消最小化 stack 配置,直接使用預設的構造行為,這樣就能暫時解決 JNI 啟動的 JVM 無法運行外部程序的問題。很明顯地,每一條執行緒都會開相當大的 stack size,大部分的情況下預設最多為 4 MB,而實際工作的產品中預設就要 20 MB,這個得從其關聯函數庫去分析為什麼需要這麼大的堆疊,可能是寫錯,也或者是連結太多沒有必要的庫進來。

Read More +

Company Ghost Story 公司鬼故事 8

Java

Format String

1
2
String.format("ABC%s", "D"); // const string should be written directly
String.format("%s.%s", A.class.getName(), "C");

對於可以直接計算得到的,應該直接展開。

Math

1
2
3
4
5
6
7
8
9
10
11
12
13
14
double theta = dx != 0 ? Math.atan(dy/dx) : (dy < 0 ? ... : ...);
// or
if (dx < 0) {
if (dy < 0)
...
} else (dx == 0) {
if (dy < 0)
...
}
// why not be simple?
double theta = Math.atan2(dy, dx);

數學很重要,可以減少很多分支判斷。

Variable Name

1
2
List<T> set = new LinkedList<>(); // what does `set` means?
// perhaps, I need to ask what does `list` means for you.
1
2
TextField okBtn; // why name button for text field?
enableCancelButton(okBtn); // are you sure about it?

命名盡量貼切型態,不然有時候真的會有很奇怪。

Initialize Member

1
2
3
4
5
6
7
8
9
10
11
12
class MPanel {
private JButton mButton;
public MPanel() { // consturctor
...
mButton = new JButton("A"); // what?
panel.add(mButton);
mButton = new JButton("B"); // what?
panel.add(mButton);
// finally, which result you want to express mButton?
}
}

在建構子中,每一個成員變數盡量只存放一次,採用 final 對每個成員變數檢查只初始化一次。

Lambda with Final

1
2
3
4
5
6
7
void process(...) {
String s = "default";
if (isA())
s = "A";
final String _s = s; // shut up, don't explain
blackhole(() -> map(_s));
}

Lambda 函數抓取的時候都要抓取可推斷出 final 的變數。為了 lambda 而額外宣告一個變數作為 final 的話,應該要額外宣告一個 getter 函數去抓取,不然觀感上會覺得代碼有點多餘。

Method Name

1
2
3
4
void increment() { // why is noun?
i++;
i++; // why call it twice?
}

函數盡量使用動詞開頭,名詞有點奇怪。

Context

1
2
3
4
5
6
7
void enableA() {
a = true;
b = true; // why is b here?
}
void disableA() {
a = false; // wait ... where is B?
}

當名稱對應到成員變數時,就應該一件事情對應一個操作。

Read More +

Company Ghost Story 公司鬼故事 7

Java

try-catch-exception

1
2
3
4
5
6
7
8
9
10
11
Object getSomething() {
final Exception abort = new RuntimeException();
try {
// ...
} catch (Exception e) {
if (e == abort)
// fine
else
e.printStackTrace();
}
}

每次呼叫這個函數都要先建立一個例外,然後看裡面會不會丟出來,這個其實很浪費空間和時間建立。可以考慮建立一個特殊 AbortException 來取代預先建立的例外,或者建立一個全局的靜態例外物件來使用。

Double Brace Initialization

1
2
3
4
5
6
7
8
9
10
11
12
final Set<String> set = new HashSet<> {
{
add("A");
add("B");
add("C");
} // no more
};
// why not?
set.add("A");
set.add("B");
set.add("C");

這會額外建立匿名類別。如果宣告在一般的成員方法中,還會捕抓成員變數,造成內存洩漏而無法回收的狀況。

No More Inheritance

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point extends Pair<Integer, Integer> {
Point(int x, int y) {
super(x, y);
}
@Override
public boolean equals(Object o) {
if (o instanceof Point) {
Point p = (Point) o;
return first == p.first && second == p.second; // wrong
}
return false;
}
}

別忘了,Java 中的 wrapper class 可是一個物件,光靠 == 是不能比較相同與否。雖然對於數值小於某個定值的時候,他們會在常數內存池中取得,讓你覺得一切都好像沒事。

Read More +

Company Ghost Story 公司鬼故事 6

Java

Observer vs. Inheritor

1
2
3
4
5
6
7
8
9
Tool tool = new Tool() {
@Override
protected void fireSomething(T t) {
super.fireSomething(t);
updateProcess(t);
}
}
void updateProcess(T t) {...}

當觀察者模式 (observer pattern) 的介面應優先考量 attach/addListener,不應該以繼承的方式覆寫 update/fireListener/notify 等函數。

WhatIf?

1
2
3
4
5
if (isA()) {
// something
} else if (isA() && isB()) {
// dead code
}

第二個 else-if 並不會執行到。

Equals vs. ==

1
2
3
if (a == b && a.equals(b)) {...}
if (a == b || a.equals(b)) {...}

對 Java 而言,有 Objects.equals 可以替代呼叫,又或者在 boolean equals(Object) 中包含 == 才對。

String.format

1
String.format("Move %s", fromObj, toObj);

對上參數數量是很重要的。

Mapper Function

1
2
3
4
5
6
7
8
static T a2b(String key) {
Map<String, T> mapper = new HashMap<>();
mapper.put("...", ...);
mapper.put("...", ...);
mapper.put("...", ...);
mapper.put("...", ...);
return mapper.get(key);
}

手動做 if-else 比較快的,建立 hash 可不是這麼簡單,記憶體宣告等因素需要考慮。而它也不屬於只宣告一次的靜態變數,因此並不適合這樣處理。

Comparable

1
2
3
4
5
@Override
int compareTo(T other) {
int[] c = {fa.compareTo(other.fa), fb.compareTo(other.fb) ...};
return firstNotZero(c);
}

if-else 不應該這樣被偷懶的,整個效能都爛了。

Argument vs. Method Name

1
2
3
4
5
void doExpand(boolean isExpand) {
if (!isExpand)
doCollapse();
...
}

好的命名決定品質。

Switch

1
2
3
4
5
6
7
8
static void enableAListener() {}
static void removeAListener() {}
static void addBListener() {}
static void stopBListener() {}
static void setCListener() {}
static void disableCListener() {
sListeners.remove(sAListener);
}

有一種被玩弄的感覺 …

Read More +

Company Ghost Story 公司鬼故事 5

Java

Duplicate Field

1
2
3
4
5
6
7
8
class C {
final A a;
final A b;
C(A a) {
this.a = a;
this.b = a; // why?
}
}

為什麼要做出兩個相同意義的欄位?

Strange Method Name

1
2
3
4
5
class UIComponent {
public void setDisable(boolean d);
public boolean isDisable();
public boolean isDisabled();
}

英文不好的我,一度慘死。

Strange Method Name II

1
2
3
4
5
class UIComponent {
public void setEnabled(boolean e);
public boolean isEnabled();
public boolean getEnabled();
}

拜託決定一種風格做一件事情。

Inefficient Listener

1
2
3
4
5
6
7
void init(Component c) {
c.addFocusListener(...);
c.addChangeListener(...);
c.addSelectListener(...);
c.setSelect(...); // trigger listener ...
c.setFocus(...); // trigger listener ...
}

初始化的優先順序很重要,多次觸發有可能發生,造成重複處理。

Override Method

1
2
3
4
5
6
7
8
9
@Override
public void uninstall() {
uninstallMe();
}
private uninstallMe() { // why not write into uninstall()?
// something ...
super.uninstall();
}

函數這麼簡單,為什麼 super.method 寫在原本的函數外部。出現不同於 @Override 的函數中實在少見。

Empty Listener

1
2
3
4
5
6
c.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
super.windowClosed(e);
}
});

不要註冊一堆空操作。

Override Empty

1
2
3
4
@Override
public void install() {
// please add comment here if remove parent processing
}

這不知道在寫什麼,也沒叫 super.install(),該觸發事件的都沒觸發,真的沒事嗎?應該是寫錯了吧

Read More +

Company Ghost Story 公司鬼故事 4

SVN

Commit File

1
2
3
4
5
6
7
$ svn status
.DS_Store
import.csv$
export.txt~
test_result.txt
golden.txt
? test_result.log
  • 別建立隱藏檔案格式,以小數點 . 開頭的在 Linux 可是隱藏檔案的意思
  • 別上傳 .DS_Store 那個是 Mac 平台上的隱藏檔案,並不需要
  • 別上傳 xxx.csv$那個是 Microsoft 平台上的 lock 檔案,並不需要
  • 別上傳 xxx.txt~ 那個是 emacs, nano, vim 等製造的備份檔案,並不需要
  • 別上傳測試結果,如 test_result.txt,正常來講會覆寫,傳上去結果沒產生怎麼辦,比對還會過呢。
  • 別上傳空資料夾

Commit Log

1
2
3
4
5
$ svn log golden.txt
------------------------------------------------------------------------
r32 | product_bot | 2003-01-13 00:43:13 -0600 (Mon, 13 Jan 2003) | 1 line
Update golden

拜託用自己的帳號上傳,不小心用到 product_bot 這種全自動的帳號,不曉得是誰修改的內容。

Read More +

為什麼不推薦用 Java Stream 改寫迴圈

Java 8 開始推出了 stream 介面,搭配 lambda 可以達到函數式編程 (functional language) 的能力,但是也造成非常多的效能爆炸的憾事。尤其是在我們沒有深入了解官方實作的細節時,請別輕易地轉換到 stream 介面。

Stream

Stream 出現之前,我們可以透過 Iterator 的實作來達到串流的概念,如 guava 庫所提供的 FluentIterable 就是一個替代方案。公司內部則是因為維護問題自己實作了一個自己的庫,因此沒辦法輕易地用 guava 的項目。但其中一點很明顯的不同在於 parallel 並行特性,即使是 FluentIterable 中,我也未見相關的平行操作。

在自己實作庫、人手不足的團隊配置下,直接吹捧去使用現有的函數庫,那麼問題就容易出現在於轉換上。必須考慮「何時我們該去使用」,決定好使用場景,再決定是否要改寫。公司已經升級到了 JDK 11 的,在銜接官方的 stream 實作時,依舊造成顯著的效能退化,甚至還有嚴重的運行問題,導致 dead lock 等問題。

Static Initialization Block

1
2
3
4
5
class classA {
static {
getXXX().parallelStream().forEach(e -> doSomething(e));
}
}

在 JDK 11 中,可能會無知地不小心在 static initialization block 中觸發 parallel 特性,此時會造成程式無法運行、卡住。這個問題在於 threading 之間調度時的鎖,另一部分則是 lambda 抓取變數造成的。目前在 bug JDK-8143380 回報系統上看出無意解決此問題,因為這是人為撰寫的問題。如果是呼叫大量的函數來初始化參數,很容易遇到不知道哪個函數區塊內偷偷開了平行計算。

像同事都把 stream() 都改寫成 parallelStream(),不管問題是大是小,這樣改寫有時候就造成莫名其妙的 Bug 發生,造成找了半天才發現問題。如果覺得著官方提供的項目都有公信力,而且不會做很蠢的事情,一直都可以很聰明地在平行與不平行取捨的話,那其實也不用設計 parallelStream() 給我們使用,統一一個 stream() 接口就好。

想到同事如此狂妄的想法「工程師不該考慮資源,從摩爾定律來看,硬體明年就會解決,我們要做出更好的 scalability 的寫法」,讓我整個火氣都上來,無法接受 用而不知 的習慣。在沒人力挺下,只能眼真真地看著代碼被改得亂七八糟。

Stream Cost

了解 Stream 基礎實作後,就會明白在沒有平行的狀況下,維護 stage 的狀態會是額外開銷,相較於手刻的 Iterator,很多沒有必要的狀態紀錄容易在非常簡單的迭代器中暴露。

Stream Concatentation

串流串接又是更可怕的實作技術,請參閱附錄文章 Efficient multiple-stream concatenation in Java

1
2
3
4
5
6
7
concat(concat(concat(a, b), c), d)
/ \
concat(concat(a, b), c) d
/ \
concat(a, b) c
/ \
a b

串接的實作像是二元樹。如果操作順序不當,馬上會產生一棵偏斜的二元樹,假設我們要取 a 裏頭的元素,則必須從根一路走到葉節點去。如果構造 $n$ 次操作,且每一個恰好為一個元素,蒐集所有的內容,時間複雜度為 $O(n^2)$。

因此,亂改寫遞迴構造器很容易出現問題,不如用原本的回傳值 List/Collection 把要的東西收集好,不支援惰性操作也好,舊的寫法依舊在 $O(n)$ 完成。若要支援惰性操作,使用 StreamBuilder 建立平衡的二元樹也能在 $O(n \log n)$ 最慘複雜度下完成迭代。

這個問題並不是開 parallelStream() 解決,必須從根本的複雜度去分析,

Iterator to Stream

從內建的 CollectionStream 大部分都由官方做得很好,用不著花太多的心思去思考如何實作,直接呼叫相關函數即可。如果工作需要自己的 Iterator 變成 Stream 就沒有這麼好運。必須了解什麼是 Spliterator,而 Spliterator 是怎麼實作的,會有什麼樣的介面,而它是從何支援 parallelStream 這一項特性。

假設您已經詳讀 Spliterators.java 下的所有代碼,如自動建造函數

1
static <T> Spliterator<T> spliteratorUnknownSize(Iterator<? extends T> iterator, int characteristics)

標示不確定迭代器實際運行的元素個數,好比在 stream 的 flatMap()filter() 後,無法得知下一階段的元素多寡。在這些情況下只能透過這一類函數構造。然而官方預設的行為卻造成了嚴重的效能問題,它要求建造一個 IteratorSpliterator,而為了支援平行特性,平行通常需要透過高效的拆分陣列完成工作分配,因此會呼叫 Spliterator::trySplit 不斷地拆分,從源代碼中我們可以得知在未知大小的狀況下,則建立 new Object[1024] 預處理資料結構,即使在沒有開啟 parallelStream,依舊按照相同的運行流程。

有人說在 Java 開 new Object[1024] 很快,而最慘情況迭代器就只有一個元素,甚至沒有任何的狀況下,用最蠢的實作只消耗了一個迭代器的宣告開銷,而轉換串流卻消耗了整整數千個位元組,這是個效能損耗。就算 GC 很強,也不代表它能發現這麼複雜的操作,它整整跨足了超過一個函數區塊。

工作中因為繪製處理,需要運算數億次,若每一次都宣告這麼多餘的陣列,那麼莫虛有內存損耗高達幾十 GB,GC 若要運行 10 次,畫面繪製需要多一分鐘都是有可能的,將數秒不到的計算整整拖累下去。

Lazy Evaluation

惰性計算是 stream 的主要特性之一,從另一個術語按需計算 (on-demand)。即使是官方提供的庫也有一些小 Bug 造成假的惰性計算。最常見的像是 flatMapmapfilter 混用的時候,最後使用 findFirst 操作。從代碼上,我們會預期差不多就是找到一個元素之前的所有計算,而從回報系統裡面下關鍵字去找,會發現不少多餘的計算發生。

同事老是說「我們自己的庫超慢的,用 stream 比較快。」

言下之意,只是沒有遇到官方庫的 Bug,而你看到了庫的 Bug 卻從沒打算解決。

最常見的迭代器實作,忘了 Lazy Evaluation 的寫法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyIterator<T> implements Iterator<T> {
...
@Override
public T next() {
if (!hasNext())
throw new NoSuchElementException();
T val = nextVal;
findNext(); // on-demand ?
return val;
}
@Override
public boolean hasNext() {...}
private void findNext() {
... // time complexity ?
}
}

很明顯地,透過 findNext() 我們可能只拿了第一個元素,在不需要第二個元素的狀況下,卻耗費了線性時間。

Note

還有一些問題,下一次再聊聊吧。

Reading List

Read More +

Company Ghost Story 公司鬼故事 3

Java

Setter/Getter

1
2
3
4
5
6
7
class Parser {
X getX() {}
boolean x() {}
boolean readX() {}
void parseX() {}
void X() {}
}

Java 特性預設都是透過 setter/getter 的概念來完成成員變數的存取。為了擴充使用不建議直接存取變數。

這毫無設計的函數是發生甚麼事情?名字這麼像是想害死誰啊,而且還都是不同邏輯。

1
2
3
4
5
6
class Parser {
X eX() {}
boolean aX() {}
boolean sX() {}
void pX() {}
}

不是這樣子說完,就用奇怪的前綴解決。把整個動詞完整描述出來會讓你打字很痛苦嗎?

Inner/Anonymous Class

現在我們用一個指令去搜尋建立的 class files。

1
2
3
4
5
$ find ./bin -name "*\$[0-9]*.class"
xxxx$1$1.class
xxxx$64$1.class
...
xxxx$64$64.class

居然發現了非常非常多的匿名類別,而且還是爆炸性的嵌套。這意味者當改變一個 class 檔案時,編譯會一次可能產生上百個檔案。

1
2
3
4
5
6
boolean getToken() {
return new Token {
@Override
public String toString() { return "here we are"; }
}
}

這也是常常在調適函數時,不知道為什麼會有 IDE 崩潰的情況發生。其實都是上述的寫法氾濫,連帶的內存洩漏問題也很嚴重。事實上,大部分的匿名類別都可以額外地用功能去描述類別,請重新宣告並且命名好。

Long Name Method

1
2
3
void addXXXsToAdjacentYYYsSetSoItWillNotAAABBBWhereMMM();
void setXInDatabaseAAABBBField();

這種命名函數比惡名昭彰的匈牙利還兇殘,直接把文件描述寫在函數上,這時候又願意打字了。

請找個稍微中立一點描述方法,等到哪天換了連帶功能,所有相關函數都要重新做過。

Create Context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
boolean read() {
try {
readPartA();
externalCall();
} finally {
setCacheEnabled(true);
}
}
boolean readPartA() {
setCacheEnabled(false); // why not place it in read()?
...
}
// in external.xxx
java::setCacheEnabled(false); // what are you doing?
...
return true;

建立 context 的習慣是要寫在同一個檔案中,而且盡可能寫在同一個函數內。

跳來跳去,有時候只有下文,有時候只有上文,不覺得很可怕嗎?萬一中間執行失敗,直接萬劫不復。

Create Context II

1
2
3
4
5
boolean read() {
db.setDbCheckEnabled(false);
...
// where's your setDbCheckEnabled(true);, can I trust you more?
}

直接把所有重要檢查通通關閉,卻忘了開起來,直接出逃啦。

Create Context III

1
2
3
4
5
6
7
8
9
10
boolean read() {
try {
db.setDbCheckAEnabled(false);
db.setDbCheckBEnabled(false);
...
} finally {
db.setDbCheckAEnabled(true); // setDbCheckBEnabled() first, please
db.setDbCheckBEnabled(true); // setDbCheckAEnabled() last, please
}
}

大部分都不會錯,但建議採用堆疊的順序撰寫。因為有些保護機制會造成重複工作。

Create Context IV

1
2
3
4
5
6
7
8
9
10
11
12
boolean read() {
try {
db.setDbCheckAEnabled(false);
db.setDbCheckBEnabled(false);
...
db.setDbCheckBEnabled(true); // I don't know.jpg
...
} finally {
db.setDbCheckBEnabled(true);
db.setDbCheckAEnabled(true);
}
}

我不知道.jpg。

Regression Test

Create Test Folder

1
2
3
4
5
6
$ tree Regression/FunctionA
├─testBBBB
│ └─testCCCC
│ └─testDDDD
│ └─test.sh
...

為什麼需要前綴 test?請給我一個理由。

Create Test Folder II

1
2
3
4
$ tree Regression/FunctionA
├─DoNotThrowNoSuchElementException
│ └─test.sh
...

如果是預期丟出錯誤的測試就算了,但錯誤的實例也是會變動的,這樣的資料夾名字不具有太深遠的意義。

Read More +