almost 2 years ago

Translated from "Part 2: Using JUnit 5", Mert Çalişkan, Java Magazine November/December 2016, page 20. Copyright Oracle Corporation.

Part 2: 使用 JUnit 5

整合建置工具與 IDE 執行第五版及先前版本的測試

在本文的第一部分,我提到 JUnit 5 將推出的新功能,接下來我提供更多關於框架的細節,以及與像 Maven 及 Gradle 等建置工具的整合。

本文所有的範例都基於 JUnit 5.0.0-M2,可以從專案首頁取得。

架構概要

我們從 Junit 的套件 (package) 結構開始,該結構在第一次 alpha 釋出後有做調整,JUnit 5 現在主要有 PlatformJupiterVintage 三個套件。目前的版本 M2,套件結構與模組如 Listing 1 所示 [譯註:翻譯時已經釋出 M3 了]。

Figure 1. The JUnit 5 architecture

JUnit Platform 套件是 Vintage 與 Jupiter 套件的基礎,它包含 junit-platform-engine 模組,該模組提供公開的 API 以整合第三方測試框架 (像是 Specsy,一個 JVM 語言用的 BDD 風格測試框架);junit-platform-launcher 模組提供啟動 API,讓建置工具與 IDE使用,在第五版之前,IDE 與測試程式碼都使用相同的 JUnit,新版以更加模組化的方式,提供良好的關注點分離,建置工具相關的程式與 API 分開於不同的模組。

junit-platform-runner 模組提供 API 讓 JUnit 5 的測試能在 JUnit 4 上執行;junit-platform-console 模組支援從終端機啟動 JUnit platform,能用命令列的方式執行 JUnit 4 及 JUnit 5 的測試,並傳回測試結果顯示於終端機中; junit-platform-surefire-provider 模組提供的 JUnitPlatformProvider 類別,與 Surefire (Surefire 是 Maven 測試週期中執行 JUnit 的 plug-in) 整合,可以透過 Maven 執行 JUnit 5 的測試;此外,junit-platform-gradle-plugin 模組提供與 Gradle 建置工具的整合,稍後會提到。

JUnit Vintage 套件提供能在 JUnit 5 上執行 JUnit 3 與 JUnit 4 測試的引擎,junit-vintage-engine 模組即是執行測試的引擎,JUnit 團隊支援先前的版本,鼓勵目前不管使用哪個版本的開發者升級到 JUnit 5,稍後我會描述如何執行 JUnit 4 的測試。

JUnit Jupiter 包裝新的 API 及擴充模型,還提供執行 JUnit 5 測試的引擎。junit-jupiter-apijunit-jupiter-engine 是此專案的兩個模組。如果您只宣告相依於 junitjupiter-engine 模組,就足以執行 JUnit 5 的測試,因為 junit-jupiter-apijunit-jupiter-engine 的上游模組 (可轉移相依,transitive dependency)。

配置工具以使用 JUnit 5

可以在 Maven 與 Gradle 中定義對 JUnit 5 的相依,此外,也可以直接透過終端機直接執行測試,有些 IDE 已經開始提供支援執行 JUnit 5 的測試,所以對於新框架的採用情況,看來是樂觀的。

與 Maven 的整合 在 Maven 中定義對 JUnit 5 的相依性就如 Listing 1 所示,如同先前提到的,不需要宣告相依於 junit-jupiter-api 模組,因為當我宣告相依 junit-jupiter-engine 時,它會被當成上游模組而自動抓取。

Listing 1.
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.0.0-M2</version>
    <scope>test</scope>
</dependency>

若您想停留在 JUnit 4.x,是可以加入 vintage 模式的相依性來使用 JUnit 5,如 Listing 2 所示。

Listing 2.
<dependency>
    <groupId>org.junit.vintage</groupId>
    <artifactId>junit-vintage-engine</artifactId>
    <version>4.12.0-M2</version>
    <scope>test</scope>
</dependency>

當宣告 vintage 模式時,JUnit 4.12 與 junit-platform-engine 會被當成上游模組自動抓取,方便起見,JUnit 團隊對齊 vintage 模組與目前 JUnit 最新的正式版本,寫本文時正是 4.12 版 [譯註:5.0.0 M3 仍然對齊 JUnit 4.12],在宣告完相依性後,該開始使用這些相依的模組來執行您的測試了,在 Maven 的建置週期設定中,如 Listing 3 所示,於 maven-surefire-plugin 的相依性裡加入 junit-platform-surefire-provider

Listing 3.
<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.19.1</version>
    <dependencies>
        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-surefire-provider</artifactId>
            <version>1.0.0-M2</version>
        </dependency>
    </dependencies>
</plugin>

JUnit 團隊開發 junit-platform-surefire-provider 協助透過 Surefire 的機制執行 JUnit Vintage 與 JUnit Jupiter 的測試,這目前還未支援 Surefire 進階的參數,像是 forkCountparallel,但我相信接下來的幾個改版,Surefire 會補足這落差,百分之百支援 JUnit 5。

與 Gradle 的整合 在 Gradle 中定義相依性和 Maven 類似,如 Listing 4 所示,在 Gradle 中加入 Jupiter 引擎與 API 的相依。

Listing 4.
dependencies {
    testCompile("org.junit.jupiter:+junit-jupiter-engine:5.0.0-M2")
}

若是要加入 Vintage 引擎,則如 Listing 5 所示。

Listing 5.
dependencies {
    testCompile("org.junit.vintage:+junit-vintage-engine:4.12.0-M2")
}

要讓 JUnit Gradle plug-in 加到建置中,如 Listing 6 所示,需宣告並套用到配置檔中。

Listing 6.
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'org.junit.platform:+junit-platform-gradle-plugin:1.0.0-M2'
    }
}

apply plugin: 'org.junit.platform.gradle.plugin'

Listing 7 所示,可以在 junitPlatform 指令中設定 JUnit Gradle plugin,像是我可以定義用來執行的引擎 (預設是全部開啟)、要納入或排除的標籤、設定名稱策略過濾要執行的測試類別、指定特定的報表輸出目錄,以及日誌管理員的設定。

Listing 7.
junitPlatform {
    engines {
        // include 'junit-jupiter', 'junit-vintage'

        // exclude 'custom-engine'

    }
    tags {
        // include 'fast'

        // exclude 'slow'

    }
    // includeClassNamePattern '.*Test'

    // enableStandardTestTask true

    // below is the default reports directory

    // "build/test-results/junit-platform"

    logManager 'org.apache.logging.log4j.jul.+LogManager'
}

與終端機的整合 命令列應用程式 ConsoleLauncher 讓您可以直接在終端機中執行 JUnit Platform,這程式可以像 Listing 8 般以指令啟動,建立所需 JAR 檔的類別路徑是必要的,所以確保您有正確版本的檔案。

Listing 8. [輸入 classpath 路徑需在同一行要]
java -cp
    /path/to/junit-platform-console-1.0.0-M2.jar:
    /path/to/jopt-simple-5.0.2.jar:
    /path/to/junit-platform-commons-1.0.0-M2.jar:
    /path/to/junit-platform-launcher-1.0.0-M2.jar:
    /path/to/junit-platform-engine-1.0.0-M2.jar:
    /path/to/junit-jupiter-engine-5.0.0-M2.jar:
    /path/to/junit-jupiter-api-5.0.0-M2.jar:
    /path/to/opentest4j-1.0.0-M1.jar:
    org.junit.platform.console.ConsoleLauncher -a

參數 -a 指執行所有測試,參數 -n 指定只執行類別全名 [譯註:包含 package 名稱] 滿足特定正規表示是的測試,雖然根據文件的說法,還可能變動,但仍有許多選項可以使用。

與 IDE 的整合 市面上的 Java IDE 快速地演進,為執行 JUnit 5 測試提供穩健的支援,在寫本文的時候,IntelliJ IDEA 目前的版本可以處理 JUnit 5 的測試,且以樹狀結構分別呈現 Jupiter 及 Vintage 的測試,Figure 2 顯示測試一連串 stack 操作的輸出例子,該測試類別中包含用新的 @Nested 加註的子測試類別,因此開啟建立巢狀的測試,並正確呈現在圖中。

Figure 2. Output from IntelliJ for nested tests

JUnitPlatform 的協助下,讓 JUnit 5 的測試能在 IDE 的 JUnit 4 平台上執行,Eclipse Neon 及 NetBeans 8.1 同樣支援執行 JUnit 5 的測試。

Vintage 模式提供向下相容

JUnitPlatform 類別是 JUnit 4 執行器的一個實作,有它的協助,JUnit Jupiter 的測試可以在 JUnit 4 上執行, JUnitPlatform 類別由 junitplatform-runner 模組提供,所以如 Listing 9 所示,要在 Maven 中加入對 Jupiter engine 的相依。

Listing 9.
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.0.0-M2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-runner</artifactId>
    <version>1.0.0-M2</version>
    <scope>test</scope>
</dependency>

Listing 10 提供一個簡單的測試類別實作,可以看到 import 的宣告,測試類別是以 JUnit 5 實作,但宣告的執行器,讓它可以在 JUnit 4 的平台上執行,例如 Eclipse Neon。

Listing 10.
import org.junit.jupiter.api.Test;
import org.junit.platform.runner.JUnitPlatform;
import org.junit.runner.RunWith;

import static org.junit.jupiter.api.Assertions.assertTrue;

@RunWith(JUnitPlatform.class)
class SampleTest {

    @Test
    void sampleTest() {
        assertTrue(true);
    }
}

譯註:其實 Vintage 做蠻多事的,讓我在翻譯時,一直懷疑自己是不是搞錯意思了,但到官網查閱之後,確定沒有翻錯,它可以讓 JUnit 3 和 JUnit 4 的測試在不修改的情況下,能在 JUnit 5 (也就是 Jupiter) 上執行,同時,它也可以在 JUnit 4 上執行 JUnit 5 的測試 (須加上@RunWith(JUnitPlatform.class)),讓還未支援 JUnit 5 的 IDE 能以 JUnit 4 執行測試 (但不支援某些功能)。

結論

JUnit 團隊在 JUnit 最近的釋出有相當出色表現,新的套件結構可以看出整個框架被翻新,為接下來的釋出提供一個基礎,JUnit 5 幾乎解決了前個版本的所有限制,並提供建置工具、IDE 與第三方測試框架更好的整合支援,在善用 lambda 及新的擴充模型實作下,我相信 JUnit 仍會是最受歡迎的 Java 框架。

learn more
In-depth blog on the JUnit architecture
Using JUnit 5 in IntelliJ IDEA
JUnit team on Twitter

JUnit 5 特刊系列索引
Part 1: Unit 5 初探
“A Deep Dive into JUnit 5’s Extension Model,” page 25
“Interview with Kent Beck,” page 36
“ Mutation Testing: Automate the Search for Imperfect Tests,” page 43

譯者的告白
我還記得研究所 Pattern-oriented Software Design 的後半段,就是拿 JUnit 的架構與程式作為例子,學習 design pattern 如何應用在軟體設計中,當時還是 JUnit 3 的時代,沒想到再次看 JUnit 的架構介紹已經是 JUnit 5 了。話說,每次從 PDF 複製文字時,fi 的 f 就是會不見,所以 define 會變成 deine,Surefire 會變成 Sureire,若是要被翻譯的字也就算了,但像 Surefire 這種保留下來的名字,都還要檢查 f 是否不見了。

 
almost 2 years ago

Translated from "Part 1: A First Look at JUnit 5", Mert Çalişkan, Java Magazine November/December 2016, page 14. Copyright Oracle Corporation.

Part 1: Unit 5 初探

長久期待的釋出,JUnit 5 全新的設計並有許多有用的新功能

廣為使用的 Java 單元測試框架,JUnit,在第四版釋出十年後,看到第五版的第一個 alpha 版釋出,JUnit 5 包含以模組架構重寫的程式碼、新的註釋 (annotation) 集、有利於第三方函式庫整合的可擴充模型、及能用 lambda 表示法寫驗證的能力。

JUnit 5 的前身是 JUnit Lambda 計畫,為下個世代的單元測試播下第一個想法的種子,在 2015 十月前於 Indiegogo 上透過群眾募資,籌得預期目標兩倍還多的貢獻。

經過這些年,JUnit 捕捉到單元測試框架的本質應該是什麼,但是,它的核心仍然原封不動,讓它難以進化,新的版本重寫整個產品,致力於為執行測試與產生報告提供充足與穩定的 API,用 JUnit 5 實作單元測試最低要求為 Java 8,但它可以執行用 Java 先前版本寫的測試。

本文第一個部分,我以詳細的例子描述 JUnit 5 主要的新特性,JUnit 團隊計劃在 2016 年底釋出此框架的最終版本,Milestone 2 是 JUnit 5 正式釋出前的最後幾步,這將勢必成為 Java 生態系最重要的釋出之一。在第二部分 [譯註:下一篇文章],我解釋如何用您既有的工具使用與配置 JUnit 5,及如何一起執行 JUnit 4 與 JUnit 5 的測試。

解剖 JUnit 測試

我們來看些 JUnit 5 的測試,先從 Listing 1 簡單的 JUnit 測試開始:

Listing 1.
import org.junit.jupiter.api.*;

class SimpleTest {

    @Test
    void simpleTestIsPassing() {
        org.junit.jupiter.api.Assertions.assertTrue(true);
    }
}

如上所示,乍看之下,一個簡單的 JUnit 5 測試類別,與 JUnit 4 測試類別相比,幾乎沒有不同,主要的差別是測試類別與測試函式不再需要宣告為 public,此外,@Test 以及接下來的註釋都移到名為 org.junit.jupiter.api 的新套件,需要匯入它。

利用註釋的威力

JUnit 5 提供改良過的註釋,以我的觀點,提供實作測試不可或缺的功能,這些註釋可單獨宣告,或混合使用建立客制的註釋。接下來幾節,以例子個別描述每個註釋。

@DisplayName 現在可以為測試類別或測試函式用 @DisplayName 命名,如 Listing 2 所示,描述可以包含空白與特殊字元,甚至可以有表情符號,例如 😃

Listing 2.
@DisplayName("This is my awesome test class 😃")
class SimpleNamedTest {

    @DisplayName("This is my lonely test method")
    @Test
    void simpleTestIsPassing() {
        assertTrue(true);
    }
}

@Disabled 和 JUnit 4 的 @Ignore 類似,@Disabled 可以關閉一整個測試類別或部分測試函式的執行,關閉測試的理由可以加在註釋的描述裡,如 Listing 3 所示:

Listing 3.
class DisabledTest {

    @Test
    @Disabled("test is skipped")
    void skippedTest() {
        fail("feature not implemented yet");
    }
}

@Tags@Tag 現在可以為測試類別、測試函式或兩者貼上標籤,標籤可以在執行時過濾測試,這功能與 JUnit 4 的 Categories 類似,Listing 4 是為測試類別貼上標籤的例子。

Listing 4.
@Tag("marvelous-test")
@Tags({@Tag("fantastic-test"), @Tag("awesome-test")})
class TagTest {

    @Test
    void normalTest() {
    }

    @Test
    @Tag("fast-test")
    void fastTest() {
    }
}

您可以提供標籤名稱給測試執行器,過濾出要執行或排除的測試,執行 ConsoleLauncher 的方法會在本文的第二部分 [譯註:下一篇] 描述,使用 ConsoleLauncher,可以搭配 -t 參數提供要執行的標籤名稱,或 -T 參數排除標籤名稱。

@BeforeAll@BeforeEach@AfterEach@AfterAll 這些註釋分別與 JUnit 4 的 @BeforeClass@Before@After@AfterClass 的行為完全一樣,被加註 @BeforeEach 的函式會在每個 @Test 函式前執行,而加註 @AfterEach 的函式會在每個 @Test 函式後執行。加註 @BeforeAll@AfterAll 的函式則會在執行所有 @Test 函式前與之後執行,這四個註釋將套用在加註 @Test 函式所在的類別,如果有類別繼承,同樣套用 (測試階層稍後討論),加註 @BeforeAll@AfterAll 的函式必須宣告成 static。

@Nested 測試階層 JUnit 5 支援建立測試階層,在彼此的內部建立巢狀的測試,這選項讓您能依邏輯組織測試,讓測試有相同的環境 [譯註:原文是 parent,但暫時想不到更好的中文],簡化為每個測試套用相同的初始化函式,Listing 5 是測試階層的一個例子。

Listing 5.
class NestedTest {

    private Queue<String> items;

    @BeforeEach
    void setup() {
        items = new LinkedList<>();
    }

    @Test
    void isEmpty() {
        assertTrue(items.isEmpty());
    }

    @Nested
    class WhenEmpty {

        @Test
        public void removeShouldThrowException() {
            expectThrows(NoSuchElementException.class, items::remove);
        }
    }

    @Nested
    class WhenWithOneElement {

        @Test
        void addingOneElementShouldIncreaseSize() {
            items.add("Item");
            assertEquals(items.size(), 1);
        }
    }
}

驗證與假設

JUnit 5 的 org.junit.jupiter.api.Assertions 類別包含靜態驗證函式,例如 assertEqualsassertTrueassertNullassertSame,以及對應的反向版本,以處理測試中的各種條件,JUnit 5 善用 lambda 表示式,在這些驗證函式提供多載版本,可接受 java.util.function.Supplier 的實體作為參數,延遲驗證訊息的計算,意味著潛在複雜的計算可以延遲到驗證失敗時才執行,Listing 6 是在驗證中使用 lambda 表示式的例子。

Listing 6.
class AssertionsTest {

    @Test
    void assertionShouldBeTrue() {
        assertEquals(2 == 2, true);
    }

    @Test
    void assertionShouldBeTrueWithLambda() {
        assertEquals(3 == 2, true, () -> 3 not equal to 2!);
    }
}

org.junit.jupiter.api.Assumptions 類別提供 assumeTrueassumeFalseassumingThat 靜態函式,如同文件所描述的,這些函式用來描述讓測試有意義的條件假設時相當有用。如果假設不成真,這不代表程式壞掉了,只是代表測試無法提供有用的資訊,預設的 JUnit 執行器會忽略這類失敗的測試,這讓系列中的其他測試能繼續執行。

群組的驗證

現在可以將一連串的驗證群組在一起,如 Listing 7 所示,用 assertAll 靜態函式讓所有的驗證一起被執行,全部的失敗一起被回報。

Listing 7.
class GroupedAssertionsTest {

    @Test
    void groupedAssertionsAreValid() {
        assertAll(
            () -> assertTrue(true),
            () -> assertFalse(false)
        );
    }
}

如果假設不成真,這不代表程式壞掉了,只是代表測試無法提供有用的資訊,預設的 JUnit 執行器會忽略這類失敗的測試,這讓系列中的其他測試能繼續執行。

期待意外

JUnit 4 提供一種方式處理例外,將例外宣告成 @Test 註釋的屬性,與前個版本需要 try-catch 區塊補捉例外相比,已經進步了,JUnit 5 引入 lambda 表示式處理在驗證述句中拋出的例外,Listing 8 展示例外直接放在驗證中。

Listing 8.
class ExceptionsTest {

    @Test
    void expectingArithmeticException() {
        assertThrows(ArithmeticException.class, () -> divideByZero());
    }

    int divideByZero() {
        return 3 / 0;
    }
}

使用 JUnit 5,可以將拋出的例外指派給某個變數,以驗證其值,如 Listing 9 所示。

Listing 9.
class Exceptions2Test {

    @Test
    void expectingArithmeticException() {
        StringIndexOutOfBoundsException exception = expectThrows(
            StringIndexOutOfBoundsException.class,
            () -> "JUnit5 Rocks!".substring(-1)
        );
        assertEquals(exception.getMessage(), "String index out of range: -1");
    }
}

動態測試

有 JUnit 5 的動態測試新功能,現在有機會在執行期建立測試案例,這在第五版之前是不可能的,因為所有的測試程式必須在編譯期就被定義,Listing 10 是動態建立測試的例子。

Listing 10.
class DynamicTestingTest {

    @TestFactory
    List<DynamicTest> createDynamicTestsReturnAsCollection() {
        return Arrays.asList(
            dynamicTest("A dynamic test", () -> assertTrue(true)),
            dynamicTest("Another dynamic test", () -> assertEquals(6, 3 * 2))
        );
    }
}

要建立動態測試,首先我在類別中建立一個函式並加註 @TestFactory,JUnit 分析類別時會處理這個 @TestFactory 函式,用它的回傳值動態建立測試單元,@TestFactory 加註的函式必須回傳內容為 DynamicTestCollectionStreamIterableIterator 實體,DynamicTest 類別代表在執行期中產生的測試案例,實際上,它是一個包裝類別,包含一個名字與一個可執行實體,執行實體指向測試程式,dynamicTest 靜態函式宣告在 DynamicTest 類別中,其目的是用接收到的名字與 Executable 實體 (如 Listing 10 所示,執行兩個驗證的 lambda 表示式) 建立 DynamicTest 的實體。

動態測試的生命週期與標準的 @Test 加註函式不同,這意味著生命週期的回呼函式,例如 @BeforeEach@AfterEach 不適用在動態測試上。

JUnit 團隊成功提供一個新的、重新設計的 JUnit 版本,解決前個版本中幾乎所有的限制。

參數化的測試函式

有 JUnit 5 動態測試的協助,可以用不同的資料執行相同的測試,這在 JUnit 4 同樣可以用 Parameterized 執行器做到,用 @Parameterized.Parameters 註釋定義測試資料,但這方法有個限制,它會為每個參數執行所有加註 @Test 的測試函式,一次又一次,導致不需要的執行。為每筆資料建立動態測試能提供較好的封裝,侷限在該測試函式上,我在 Listing 11 說明它。

Listing 11.
@TestFactory
Stream<DynamicTest> dynamicSquareRootTest() {
    return Stream.of(new Object[][] {{2d, 4d}, {3d, 9d}, {4d, 16d}})
        .map(i -> dynamicTest("Square root test", () -> {
            assertEquals(i[0], Math.sqrt((double)i[1]));
        })
    );
}

@TestFactory 加註的 dynamicSquareRootTest 函式不是一個測試案例,但它建立一個 Stream 實體,內容是 DynamicTest 類別,能包含不同測試的實作。為 stream 中每個元素組合,我執行一個 lambda 表示式,將資料對應到可執行的測試案例,該測試案例驗證資料組合中第一個元素的平方是否等於資料組合中的第二個元素,這樣是否更優雅呢? [譯註:若還不太熟悉 Java 8 的 Stream,可能反而比較難讀懂]

結論

JUnit 團隊成功提供一個新的、重新設計的 JUnit 版本,解決前個版本中幾乎所有的限制。注意到 JUnit 5 的 API 仍可能變動,開發團隊在公開的型別上加註 @API,並給予 ExperimentalMaintainedStable 等值識別。嘗試一下 JUnit 5,並保持總是綠燈!

This article is a considerably updated version of our initial coverage of JUnit 5 in the May/June issue. —Ed.

Learn more
JUnit 5 oicial documentation
Another overview of this release of JUnit 5

譯者的告白
翻譯的過程中,總覺得例子好像沒有很到位,例如測試階層的例子若沒有仔細想,會感受不到好處,延遲的例子也是,讓我想到最近在寫閒談軟體架構時也是如此,若要舉例,總是會想很久,什麼樣的例子不會太複雜又可以很到位呢?回到 JUnit 5,隨著語言的新特性,測試程式碼可讀性更高,但這也更考驗寫程式的開發者,怎樣才能用測試寫出像自然語言描述的需求與規格呢?

 
almost 2 years ago

Translated from "JUnit 5: The New Generation of Unit Testing", Java Magazine November/December 2016, page 13. Copyright Oracle Corporation.

JUnit 是 Java 界最廣泛使用的測試工具,反覆的調查顯示,它是如此的普遍,其他的測試工具大多建築在 JUnit 之上 (例如 Spock 和其他多數 behavior-driven 的開發框架),而不是複製其功能。JUnit 的速度、易用性與通用性讓它成為 Java 開發人員皆用的工具。

第五版是從根本上重寫與重新架構的 JUnit,這個版本的新功能整理於我們的第一篇文章 (第 14 頁),文中對這個版本的改善提供詳盡的概觀;第二篇文章 (第 20 頁) 說明如何將 JUnit 加到你的工具鏈中,特別是如何在同個測試中執行第四版與第五版的測試。

現在您看到這個版本帶來的好處,我們將帶您深入其架構 (第 25 頁),這篇文章正適合不想只待在單純驗證的程度,想從這新版本榨取更多能力的開發者,它同時是 JUnit 擴充點相當好的簡介,用來設計工具以驅動 JUnit 或與它互動。

但這是特刊,所以還有更豐富的內容,在第 36 頁,我們採訪 Kent Beck,JUnit 最初的開發者,也是極限編程 (extreme programming,構成現代軟體開發基礎的核心實踐) 的創始人之一,您將會見到,Beck 對測試的觀點,已從根深蒂固的測試優先開發進化到平衡測試的好處與其帶來的成本,他詳加解釋微妙的觀點,想必給 TDD 死硬派粉絲帶來躊躇。

最後,對於依賴單元測試作為關鍵程式碼防護網的開發人員,我們探討測試突變 (第 43 頁),繁重但自動化的測試,用來尋找單元測試的缺口,它使用單元測試並稍微調整它們,看測試條件改變或移除是否會造成測試失敗或拋出例外,這個找出重複的測試、不完整的測試以及那些其實不是您所預期的測試。願綠燈與您同在!

譯者的告白
本來按計畫應該是,閒談軟體架構系列的文章,但準備的材料還沒有全部消化完,正好 Java Magazine 這期特刊實在太有意思了,就透過翻譯的方式讓自己有時間能好好看看這些文章,也希望能對測試有興趣的人提供好的翻譯。

 
almost 2 years ago

其實已經夠忙了,要寫的東西也超多的,除了閒談軟體架構和 Java Magazine 的翻譯,沒想到又弄了雜記,是怎麼了呢?

軟技能

周三晚上參加了臺北市政府產業發展局主辦的老闆學校講座最終場《2016年, 老闆,我想做遊戲可以嗎?》,本來只是想看看這類的活動是什麼,不過整場歡笑不斷,主持人挺厲害的,加上參與座談的創業家都喝了不少啤酒 (是的,啤酒),所以也爆了不少料,但因為是喝酒後說出的,一概不負責 XD

自己曾想過開發遊戲,所以在高中選類組時,就選了二類組,一直朝軟體工程師的路邁進,也一度在遊戲橘子待了三年,不過呢?不是開發遊戲,嗯,再次幫老東家打次廣告,是開發 BeanGo!,自己也知道台灣遊戲產業的現況,所以,並沒有真的開發過遊戲,只有在學校寫過一些小玩具。

會中,聊到怎麼避免打掉重練時,提到了我耳熟的字眼:scrum,事實上創作避免不到打掉重練,不好玩的成分留在遊戲中不但不會加分,反而是扣分,只是有沒有什麼方式讓這件事提早發生,讓傷害降到最低,在場四個團隊有兩個明確提到他們用 scrum,而且都是分成兩個小 team,主要的原因都是想維持較小的團隊有較小的溝通成本。

這讓我想起去年,在 Agile Tour Taipei 2015 分享前團隊怎麼朝 scrum of scrums 邁進 (投影片),從一個變成兩個 (嚴格上算是三個,一個是負責營運),投影片其實列了蠻多的觀察與身為 scrum master 的自省,後來看到 David Ko 在 FB 的社團上發的一張照片,上面寫著:

Culture eats process for lunch
Don't create a process, create a movement

是啊,敏捷重要的是文化與精神,流程倒是其次,但要怎麼塑造出團隊的文化與精神,或換一個方式想,如何讓團隊自己想要有敏捷的文化與精神,自身能力確實不夠,在離開前團隊時,雖然用了些方法讓團隊自己組成他們認為最合適他們現況的隊形,但從他們討論的內容,隱隱約約覺得少了點什麼?但我似乎又幫不上忙。

會後,我與其中一位創辦人聊聊他們 scrum 怎麼跑,以及程式、美術與企劃,這三種技能差異甚大的成員怎麼合作,他也苦笑,其實他們也花了很多時間磨合,但我們都提到,要引導團隊需要的不是 process,而是很多的軟技能,讓團隊自己能夠成長。

API 風格

前陣子寫了篇《閒談軟體架構:API Naming Style》,而最近則是特別有感覺,目前的專案,用 Swift 3 在開發,大致由三個人寫,雖然貢獻比例差距蠻大的,但從程式碼就看的出來三種不同的風格,一個是寫 iOS App 好幾年的老手,也因此 API 幾乎是 Objective C 的風格,自己呢,不敢說完全掌握到 Swift 的風格,但自認是最接近 Swift 3 提出的新風格,最後一位是最近剛開始學 iOS App 開發的新手,也因此風格相當不穩定。

有人會說,怎麼有差這麼多嗎?其實真的會,Swift 和 Objective C 一樣,函式的參數名稱有分內外,若從別的語言轉過來,一開始最不習慣的就是 label (對外的名稱),會覺得很麻煩,平常想一個變數名稱就已經很難了,寫 Swift 要想兩個,超煩的,這也就是三個人的差別:囉嗦、自認精煉和不知道該怎麼命名。

我個人是這樣,用什麼語言就要像用什麼語言,不然自己的程式在那個語系中與其他函式庫會格格不入,同時我也把寫程式當作在寫作 (這樣說來,包含技術部落格,我現在幾乎天天在寫作,而且寫作的量很龐大),我都是以如何讓程式讀起來像自然語言的角度,去想 label 的名稱該是什麼,所以會常用到大量的介係詞當作 label,希望透過這些 label 將函示名稱、label 和變數名稱能組成一句可以讀的完整句子。

短期內,我也不認為這專案的 API 風格會收斂到一個大方向,就讓它隨風飄吧。最後,分享給還需要改程式作業的後輩助教們,別再相信:『這程式很簡單啊~所以大家才會都寫得一樣』這句話,這肯定是屁話!

基本上,這系列會比較是跟軟體開發上有關的牢騷,浪費大家的時間看,真是不好意思。

 
almost 2 years ago

目前閒談軟體架構系列,每一篇大概都需要 1x 小時,包含構思、收集資料、撰寫與修稿,每個禮拜一篇已經是極限了,還好有龐大的 architecture pattern 素材,題材不是大問題,但要有自己的風格,內容還是盡量以實作及自身的經驗為主,撰寫起來還是要想蠻久的。話雖如此,有任何想知道的題材,可以留言告知,若本來就有打算寫相關的題材,可以盡量將該題材的 priority 往前移。

高中參加資訊社,社團有年度刊物,為了編輯與發行年度刊物,跟著學長學了 PageMaker 和 Photoshop,前者是出版業常用的排版軟體 (現今被 InDesign 取代),後者不用多說,號稱是軟體界的 SK II,什麼照片都能修,手繪功力不好的我,常常玩的是 Photoshop 的多種濾鏡,在還有大補帖的時代,我就收集了許多濾鏡,只要把檔案丟到特定目錄,開啟 Photoshop 就可以多好幾種濾鏡可以用,那時就覺得好神奇,為什麼這樣就可以用了?

後來用 Eclipse 開發軟體時,再次為了其 plug-in 的架構感到好奇,不過 Eclipse 的 plug-in 底層運行架構是極為複雜的 OSGi,所以當初在執行國科會計畫時,試著想用自己設計的簡化版 plug-in 架構將兩個不同子計畫的 UI 整合在一起,計畫成果是成功的,不過後來回頭看架構,其實並不是很好的設計,因為沒有 plug-in 相依管理的設計,所以兩個子計畫的程式在彼此相依的情況下,無法單獨安裝也無法像 Eclipse 在安裝時能將相依的套件一併安裝或移除。

後來自己閒暇之餘寫的看漫畫軟體 Comic Surfer,一開始並沒有 plug-in 架構,但後來想想,自己再怎麼寫,也不可能支援所有格式,所以在想有什麼方法可以讓別人擴充 Comic Surfer 能讀取的格式呢?答案很明顯,就是 plug-in 架構,事實上這已經是常用的一種架構模式 (architecture pattern),如果看 Martin Fowler 的 《Pattern of Enterprise Application Architecture》描述何時使用 plug-in pattern?你會看到:

Use Plugin whenever you have behaviors that require different implementations based on runtime environment.

《Pattern of Enterprise Application Architecture》

更簡單的說法:

Link classes during configuration rather than compilation.

David Rice and Matt Foemmel

不是在軟體建置之初就決定所有一切的行為,而是執行時根據配置 (configuration) 或執行時的環境 (例如:特定目錄有什麼 plug-in) 決定有哪些行為,話是這麼說,但要實作一個可以載入 plug-in 的軟體 (以下稱作 host application) 倒是有不少事情要考慮。

首先是怎麼建立 plug-in 的實體 (instance)?以 Java 為例,有 Reflection 機制可以在執行期間動態地載入與建立物件,但要建立那個物件呢?總不能把特定目錄底下的所有 JAR 檔載入後,建立全部類別的實體吧?畢竟不是所有的物件都是作為 plug-in 使用,因此,設計 host application 時要先決定用什麼機制載入 plug-in,可能的機制有:

  • 透過實作特定的介面:在還沒有 annotation 之前,是可以要求 plug-in 實作特定的介面,該介面可以是一個空介面,目的是讓 host application 可以透過掃描所有類別,看哪個類別有實作指定的介面,若有實作就視為是 plug-in 的主物件。
  • 為 plug-in 物件加上特定annotation:自從 Java 可以自訂 annotation 後,主流就變成以 annotation 提供 metadata 了,所以 host application 可以掃描所有類別,看有沒有特定的 annotation,像 Spring framework 就大量使用這個機制載入元件。
  • 透過文件提供 metadata:雖然說以現在 CPU 的能力,掃描所有類別應該花不上多少時間,但若有指定的規則,可以加速載入的時間,像 Eclipse 的 plugin.xml,裡面提供足夠的資訊讓 Eclipse 決定怎麼載入它,例如 ComicSurfer 就要求 plug-in 提供一個 reader.xml 的檔案,讓 Comic Surfer 知道該建立哪個物件:
    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    <reader package="tw.funymph.comicsurfer.readers.dropbox"
            factory="DropboxComicBookSuiteReaderFactory" />
    

不管是哪一種方式,都只是讓 host application 得知該載入哪個類別,但是要用 Reflection 建立物件,還是得知道建構子有哪些參數,最簡單的機制是要求 plug-in 的物件提供預設建構子,也就是零參數的建構子 (zero-argument constructor),讓 host application 簡單地建立物件,但這就會讓 plug-in 失去 constrcutor-injetion,若要讓 plug-in 的建構子有參數,可以參考 Spring framework,用 XML 或 annotation 描述參數的來源。

當 plug-in 的實體被載入,host application 怎麼知道該 plug-in 提供什麼服務呢?最單純的做法是由 host application 定義介面,然後由 plug-in 提供實作,以 Comic Surfer 為例,plug-in 能提供多個讀取器,透過 reader.xml 所建立第一個物件,必須實作 ComicBookSuiteReaderFactory,host application 可以呼叫 createReaders() 由 plug-in 自行建立讀取器的實體:

public class DropboxComicBookSuiteReaderFactory implements ComicBookSuiteReaderFactory {

    @Override
    public ComicBookSuiteReader[] createReaders() {
        ComicBookSuiteReader reader = new DropboxComicBookSuiteReader();
        return new ComicBookSuiteReader[] { reader };
    }
}

除此之外,Comic Surfer 還定義了許多介面要求讀取器實作,像是 ComicBookSuiteReader 負責讀取整套的漫畫,每套漫畫必須是實作 ComicBookSuite 的實體,一套漫畫中的每本漫畫是 ComicBook 的實體,最後每本漫畫的每一頁面是 Page 的實體,如此,Comic Surfer 根據這些介面知道這個讀取器能讀什麼漫畫 (透過 canRead(URI) 判斷),當漫畫被讀進來後 (透過 read(URI)),可以知道這套漫畫有幾本,每本有幾頁,能讀取每一頁的內容,有了這些,所有換頁、換冊以及快取等機制都能正常運作。

Comic Surfer plug-in 需實作的介面

Comic Surfer 會在程式啟動時檢查自身路徑底下的 readers 目錄是否有 plug-in,有的話就會讀取進來,一般來說這已經很足夠,如果要更進一步可以監控目錄是否有變化,這可以做到在程式運作時就能動態載入 plug-in 不須重新啟動應用程式,若搭配上安裝介面,就能做到安裝完就立即能使用。

回頭看 Photoshop 的濾鏡,每個濾鏡都是一個 plug-in,不過由於每種濾鏡都需要不同類型、數量的參數才能使用,更重要的是提供預覽畫面。為此,host application 可以有幾種做法,第一種是 host application 定義一個函式,例如 preview(Image),host application 將要套用濾鏡效果的原始圖副本 (讓使用者可以取消) 交給 plug-in,由 plug-in 自行建立預覽畫面與設定參數的 UI controls,按下確定後,host application 呼叫 apply(Image) 讓變動生效,這對 host application 較為簡單,因為預覽畫面的責任在 plug-in 身上。

但若為了 host application 整體 UI 的一致性,也可以由 host application 定義大多數參數的 UI controls,然後定義介面讓 plug-in 提供參數,host application 建立預覽畫面與控制項,然後將參數與控制項綁定,plug-in 則藉由事件綁定得知使用者調整了哪些參數,立即更新預覽的效果,這樣的話 host application 責任較大,但對開發 plug-in 的開發者就會比較輕鬆,專注在濾鏡的演算法上。

定義介面,然後由 plug-in 提供實作,但當 host application 需要 plug-in 提供更多功能時,一般會在介面上新增函式。某些語言,像 Objective C 可以將介面中某些函式宣告為 @optional,也就是說繼承的類別可以不需要提供實作,host application 可以在執行期間檢查是否有時做特定函式,若有才呼叫,若沒有則以預設的行為取代。

但並不是所有的語言都有這樣的設計,像 Java 在載入類別時會檢查類別是否滿足所實作的介面,若驗證失敗就不會載入該類別,因此 host application 在提供 SDK 讓第三方開發 plug-in 時,盡量提供一個抽象類別讓第三方繼承,而不是直接讓第三方實作介面,當要在介面新增函式時,可以在抽象類別中提供預設行為的實作,如此第三方即使不重新編譯,既有的 plug-in 也能繼續使用。不過,這是有預設行為的前提,若沒有的話還是會面臨不相容的問題。

到目前為止,都是由 host application 向 plug-in 請求服務,但 plug-in 有時候也會需要向 host application 請求資源才有辦法提供服務,例如可以向 Eclipse 請求取得目前檔案的 AST (abstract syntax tree),進行語義的分析然後提供額外的服務。為了保護 host application 自身,並不會讓 plug-in 毫無限制取得任何資源或服務,因此,還是會定義一個介面作為 Facade,讓 plug-in 註冊事件、取得資源、操作資源等等,不在 Facade 介面中的函式 plug-in 就無法使用。如何讓 plug-in 取得該介面的實體呢?constructor injection 或 setter injection 都是可以使用的方法,要求 plug-in 提供注入 Facade 的建構子或是提供 setter 讓 host application 在建立 plug-in 後呼叫 setter 注入。

現在,host application 可以呼叫 plug-in 特定介面取得額外的服務,plug-in 也可以透過介面存取 host application 的服務與資源,那 plug-in 之間要交換資訊怎麼辦呢?原則上,plug-in 之間直接互動不是不行,但如此一來,plug-in 之間就變成較強的耦合,若想避免這情況,一個較好的做法是透過 host application 中介,plug-in 向 host application 請求服務,host application 看自身能否提供,若不能提供再詢問是否有其他 plug-in 提供該服務。

不過,不管是 plug-in 直接呼動或是透過 host application 中介,最後都會在交換的資料結構產生耦合,畢竟,直接存取資料物件會方便很多,特別是交換大量資料更是如此。若資料量不大,是可以轉成與特定物件格式無關的文字或文字檔,例如以 URL 的 query parameters 傳遞參數,以 JSON 格式回傳資料等等,但資料的解析還是省不掉 (至少要知道參數名稱),因此由 host application 定義可能交換的資料格式或使用語言內建的資料格式也是一種方法,就看資料存取的方便性與 plug-in 之間獨立性的平衡點要抓在哪裡。

設計 plug-in 架構還有很多東西可以考慮,例如怎麼安裝、移除、檢查相依性等等,但如果只是簡單的 plug-in 架構,上述的內容應該能滿足大多數的需求。最後,若以 plug-in 架構來看 Android,就可以知道什麼是 Intent,知道為什麼 Activity 必須要有 zero-argument constructor,還有為什麼要在 AndroidManifest.xml 宣告那麼多東西了?

簡單說,每個安裝到 Android 中的 apk 都可以視為是一個 plug-in,AndroidManifest.xml 告訴 Android (也就是 host application) 啟動這個 plug-in 要建立哪個 activity,該 plug-in 提供什麼 <service><provider> (資料)。而 plug-in 之間則是以 Android 中介,透過 Intent 告訴 Android 想要什麼服務或資料,Android 會根據每個 plug-in 的 <intent-filter> 描述決定啟動哪個 plug-in 來滿足請求,Intent 可以夾帶參數給另一個 plug-in,並在完成後回傳資料。

雖說用 Android 的例子做結尾,但是我個人還是不喜歡 Activity 的建構子不能有參數,也不能自己建立 Activity 的物件,造成這的最主要原因是 Activity 身兼太多責任了,既是 plug-in,也是 MVP 中的 presenter (是的,不是 MVC),實在不是一個 high cohesion 原則下該有的設計。

話說 Comic Surfer 已經好幾年沒空維護了,趁這次乾脆把 Comic Surfer 變成公開專案,不過當初並沒有把 Maven 的 pom 檔處理好,所以 check out 下來如果沒有對應的設定應該也無法編譯就是了,但如果想看 Comic Surfer 的原始碼可以到這裡取得。

系列索引
上一篇:《閒談軟體架構:例外處理》

 
about 2 years ago

本文源起於前幾天,前同事在 Facebook Messenger 中問我:為什麼 BeanGoException (是的,沒錯,就是最近發表的 BeanGo!,過去三年的青春都在開發這個產品) 是繼承 RuntimeException 而不是 Exception?這是一種設計選擇,但這樣的選擇是好是壞就可以討論了。

在設計軟體架構時,常常被忽略的就是例外或錯誤的處理,原因不外乎在設計時並不會知道會拋出什麼例外或以什麼形式回報錯誤,特別是還未決定或熟悉第三方套件前,因此,當真正遇到第三方套件例外拋出時,常會受限於既有的介面規範 (method signature) 只好將例外攔截下來然後無視它,於是這個錯誤就沒有人注意到了,等到產品上線後被忽略的例外會以別的形式讓產品當掉。這情況在 Java 生態特別明顯:

至於採用 Java 語言作為例子的原因很簡單:「因為它是當代流行的商業語言裡面,例外處理機制最困難 (或是說最討厭) 的語言。」搞懂了 Java 的例外處理,再應用到其他物件導向語言,就好像喝開水一樣,變得非常容易。

《笑談軟體工程:例外處理設計的逆襲》序言

會說 Java 特別難搞,原因是當初設計者希望 Java 是安全的語言,因此將例外設計成 checked 和 unchecked 二種,一旦拋出 checked 類型的例外,編譯器就會強制要求宣告成為 method signature 的一部份,若呼叫會拋出 checked 例外的函式,就一定要使用 try-catch-finally 處理或轉拋出去,簡言之,Java 設計者強制要求例外一定要有「人」處理,但是由誰處理就讓開發者自己決定,下場往往就是像下面這種啞巴處理:

try {
    inputStream.read(buffer);
}
catch (IOException e) {
    // do nothing

}

這種有強迫症的語言大概只有 Java 和最近誕生的 Swift 了,C++ 和 C# 有拋出例外的機制,但不強迫開發者宣告或捕捉,是否處理外全憑開發者的良心 (是的,寫作良心唸作時間,有多少良心做多少事)。所以大部份語言有提供一些機制,簡化因例外拋出要處理的資源釋放機制,例如 C++ 的 RAII (Resource acquisition is initialization),簡單說,由物件的建構子取得資源,解構子釋放資源,物件的生命週期隨著例外消逝的話,C++ 就能確保資源能確實地被釋放。C# 有 using 的語法,Java 則在 Java 7 之後有 try-resources 的語法,避免開發者如上例那樣忘記在 finally 區塊釋放資源。

Objective C 雖然有@try@catch@finally 關鍵字與拋出 exception 的機制,但個人好像沒有用到哪個 API 是以例外的方式處理錯誤,這麼說不太精確,runtime 還是會拋出例外,但都是像 NullPointerException 這種類型的例外,本來就不是用捕捉的方式處理,實際上官方也是建議例外只用在 runtime 錯誤上

You should reserve the use of exceptions for programming or unexpected runtime errors such as out-of-bounds collection access, attempts to mutate immutable objects, sending an invalid message, and losing the connection to the window server. You usually take care of these sorts of errors with exceptions when an application is being created rather than at runtime.

《Introduction to Exception Programming Topics for Cocoa》

其他則以 NSError 物件的方式處理:

NSURLSession* session = [NSURLSession sessionWithConfiguration:defaultConfiguration];
NSURL* url = [NSURL URLWithString:@"https://www.example.com/"];
 
[[session dataTaskWithURL:url completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) {
    if (error != nil) {
        // handle error
        
    }
}] resume];

為了與 Objective C 的 runtime 相容,Swift 錯誤處理機制大致與 Objective C 相同,不過,Swift 和 Java 一樣希望有更安全的語言,所以和 Java 一樣可以在 function 上加上 throws 的宣告,但不用宣告拋出的例外類型,因此也不用擔心介面演變 (interface evolution):

func canThrowErrors() throws -> String

而呼叫宣告會拋出例外的 function,編譯器會強制開發者要進行處理,不論是用 do-try-catch,還是用 try?try! 處理,以及用 defer 清除資源 (這裡就不說明這之間的差異,對細節有興趣可以參考官方文件),總之,不處理編譯就會錯誤,個人覺得動機很好,但這機制其實常常讓開發人員在寫程式的當下,因為沒有足夠的資訊可以處理,所以只好選擇忽略,就好像生命會自己找到出路一樣,例外也是 (逃離處理機制)。

傳遞錯誤的機制而非拋出例外中斷目前執行緒的機制,個人認為有一部份的原因是非同步 (asynchronous) 機制,要處理例外需要有足夠的 context (請參考《笑談軟體工程:例外處理設計的逆襲》第 9 章),會發生錯誤的程式執行在另一個執行緒,發生時並沒有任何有用的資訊可以處理,但又不能中斷目前的執行緒 (通常這個執行緒還需要執行 task queue 的其餘 task),只好將錯誤以 callback 的方式 (例如先前 Objective C 的例子) 傳遞給當初的請求者,讓該執行緒可以執行,不過 callback 也常會造成 callback 地獄就是了 (已經有語言開始著手讓這邊能更優雅地被處理)。

繞了一大圈,回到前同事的問題,其實一開始 BeanGoException 確實是繼承 Exception,是 checked exception,這個設計決定比要符合 Java 原本的風格,不過,checked exception 所造成的介面演變馬上就出現了,雖然個人也是比較偏好顯式的介面演變,但並不是所有的介面都是可以讓我們修改的,例如 Runnable 介面,正好也是在當初設計非同步機制時,希望有跨 Android 與 PC 平台的 AsyncTask,當時遇到 checked exception 讓處理變複雜,只好繞過:

public abstract class AsyncTask implements Runnable {

    // some callbacks and implementation ...


    // unable to add throws on run() method

    @Override
    public void run() {
        try {
            if (execute()) {
                callback.taskCompleted(this);
            }
            else {
                callback.taskFailed(this);
            }
        }
        catch (Throwable e) {
            this.cause = e;
            callback.taskFailed(this);
        }
    }

    // the actaul exception type could be varied

    protected abstract boolean execute() throws Throwable;
}

個人不覺得這種繞過是一個很好的設計,因為這更容易讓開發者忽略 cleanup actions,但權衡之後仍是用了這樣的設計,並且在考慮各種情況下的處理機制後,參考 Spring framework (大多數的 exception 都是 unchecked),在一些配套設計下 (是的,變更設計一般都需要有配套),將 BeanGoException 改繼承 RuntimeException。不過,不代表每種專案我都會是這樣的選擇,一般來說還是會根據專案的類型和需求搭配軟體架構一起考慮和做選擇,但大多有一些原則:

  • 決定例外處理的最基本的策略
  • 一律轉成 domain model exception
  • 決定 exception 的非同步處理機制

決定例外處理的最基本的策略

《笑談軟體工程:例外處理設計的逆襲》一書提到了三種強健度等級:(等級 1) 錯誤回報、(等級 2) 狀態恢復和 (等級 3) 行為恢復,每往上一個等級,要付出的成本就高出許多,所以,一般不會全部的程式都要求做到行為恢復等級,但如果在思考軟體架構時,完全不要求,大多數的情況會變成什麼都沒有處理。因此,一般來說,我會要求最起碼要到錯誤回報,不論是透過使用者介面回報或是 log 記錄下來,若是設計 Web API,exception 的回報還包含思考怎麼搭配 HTTP Status Code,什麼情況下該用 4xx 的錯誤碼,什麼情況下該用 5xx 的錯誤碼,那些例外 server 應該處理,處理到哪個層級,那些例外該由 client 處理,處理到甚麼層級等等。

一般會再根據模組的類型,決定模組的處理策略,例如處理資料庫存取的 Repository 就會規範至少要到狀態恢復等級,也就是說除了釋放資源外,還會將資料庫還原到先前的狀態,雖然說這好像是處理資料庫時的慣用方法,但若是在設計模組時就處理好,狀態恢復的程式就不會四散在使用模組的上層程式中。一般來說,個人是用設計來處理例外,而不是在個別的函式呼叫處理,像是 processor-chain 搭配重試機制等等,因此,會提前思考處理的策略。

一律轉成 domain model exception

為了避免因 exception 引起的介面演變,通常會將專案的 root exception 設計成 XException,X 是專案名稱 (若有公司內部的函式庫,那 X 可能會遵循內部函式庫管理的規則),各個模組內可能拋出的例外都會被攔截下來並處理,若無法處理要往上層拋時,一定會轉成繼承 XException 的例外,例如 將 SQLException 轉成 RepositoryException,若之後模組內部的實作更換了,也可以用同樣的方式轉成相容的例外,像是將 MongoException (也是 unchecked exception) 轉成 RepositoryException,又例如 Android 的 SQLiteDatabase 並不會拋出例外,而是回傳錯誤碼,所以也可以轉成 RepositoryException,如此,模組的介面就不會因此被破壞或被迫改變。

轉成 domain model exception 另一層意義是提供更明確的語意,讓呼叫者清楚知道該怎麼處理,至於 XException 是 checked 或 unchecked 還要考量其他因素來決定。但轉成 unchecked exception 倒是有一個好處,可以告訴團隊成員:若不知道怎麼處理例外,最起碼用 XException 包起來再轉拋出去,如此一來,可以避免啞巴把當下的例外吃掉,若例外真的發生還可以靠其他設計捕捉轉拋出來的例外。

決定 exception 的非同步處理機制

若是 Objective C,基本上處理機制是固定的,不過還是盡量會根據專案有統一的處理機制,若原生的 API 不相容,會進行轉換,讓處理方式盡可能一樣。若是 Java 語言就比較討厭一點,偏偏 Android 又不允許在 UI thread 呼叫網路,所以蠻多情況下都是用 Android 原生的 AsyncTask 中呼叫網路,然後直接在裡面處理因網路問題而拋出來的例外,這沒什麼問題,只是程式挺醜的又不好讀,因此有蠻多套件將這樣的機制都轉成 callback 的形式,若搭配 Java 8 的 method reference Lambda,就可以有較好讀的程式。

基本上就是這樣,例外處理是可以在軟體架構設計時就考慮進去,或者說,在軟體架構設計時就該考慮進去,制定方針讓團隊有一個原則可以遵循,透過設計讓例外的處理較容易與一致,最終讓軟體的品質可以更好。最後,想了解更多關於怎麼處理例外,推薦可以看《笑談軟體工程:例外處理設計的逆襲》(直接去書店買,找我沒打折 XD),文字很幽默但又有很多更深入的內容。

最後,幫前東家的 BeanGo! 打廣告,大家快去下載吧:

系列索引
上一篇:《閒談軟體架構:意志力不是萬能藥
下一篇:《閒談軟體架構:Plug-in

 
about 2 years ago

今天來談一點比較軟的東西,算是有感而發吧!昨天職棒的總冠軍賽落幕,自己支持的球隊很可惜無法板平賽事,將戰線延到第七戰,不過從整個球季到季後賽可以看到一些有趣的東西,這次季後賽的兩支隊伍很有意思,簡短看一下兩支球隊:

一支球隊在開季前透過交易自由球員的方式得到王牌級的打者,並透過外籍打擊教練的調整讓整體的打擊獲得成長,因此在上半季很快就快得到季後賽門票,但魔術數字點亮後卻沒有如一開始那麼迅速確實拿下上半季冠軍,雖然最終還是拿下,但下半季的狀況卻是跌跌撞撞;另一支球隊,有相當平均的戰力,投手、打擊和守備都很優秀,但上半季戰績卻始終無法超越首位,甚至上半季後段的連敗,讓東家決定要出售球隊,下半季則是調整戰力,緩緩地成為首位,到最後剩餘的幾場賽事,才拿下下半季冠軍,最終拿下總冠軍。

這兩支球隊與軟體開發有什麼關係?雖然我沒有參與球隊的決策,但從球迷的角度來看,支持的球隊為了「上半季冠軍」這個短期目標,忽略戰力的調整,即使牛棚有快撐不住的跡象,仍靠著球員的意志力撐下去,拿到上半季冠軍後,理論上下半季的賽事應該為總冠軍賽調整優先,戰績次之,但又為了下一個「全年度戰績第一」短期目標,整個調度又亂掉,然後又是靠意志力撐過去。

意志力很重要沒錯,特別是像總冠軍這種高強度的短期賽事,球員打了一整季的比賽,體力、傷勢等不利條件上身,真的需要有堅強的意志力,但意志力不是萬能,意志力無法彌補投手戰力的消耗,意志力無法像自來水那樣想要就有,更何況,參與總冠軍賽的兩支球隊,雙方意志力都很強,最終,還是看團隊的整體戰力而不是意志力。

看完有沒有覺得好像在軟體開發的職場也很像?為了業務的短期目標,工程師靠著意志力加班,勉強達成目標,但大家都知道,這段期間不只完成目標也累積龐大的技術債,本來承諾短期目標達成後,會有時間調整,像是整理技術債,或是讓菜鳥工程師能獲得技術訓練,但想也知道,馬上又會有下個短期目標,工程師為了技術債已經焦頭爛額時,又為了新需求發動意志力 (或是燃燒小宇宙),有達成目標可能還好,也許還能得到獎金,平撫受傷的意志力,但沒達成目標呢?就像支持的球隊,在九局上半救援投手崩盤沒撐過去呢?

在軟體開發的職場上崩盤會是什麼情況呢?工程師的離職是最常見的,特別是王牌級的工程師會最先離開,因為王牌級的工程師不乏其他工作機會,王牌級工程師離職後,本來的工作量由其他工程師承擔,其他工程師只好繼續燃燒小宇宙,留下更多技術債,更難達到目標,然後又有新的短期目標,專案整個嚴重 delay,看到這,我想應該有很多人都會心有戚戚焉。

在戰力有限的情況下,怎樣可以不這麼依賴意志力呢?簡言之,就是讓團隊有好的體質,方法不外乎幾點:

  • 有好的基礎建設
  • 專注做真正該做的事
  • 保持靈活的彈性

好的基礎建設,像是版控系統幾乎是最基本的,我蠻難想像沒有版控該怎麼開發大型的軟體,但只有版控系統是不夠的,雖然需要額外的開發時間,但與其用無法重複使用的人力測試,我個人比較相信能不斷重複執行的單元測試,在修改程式時,單元測試還能幫忙測試有沒有改壞什麼東西,甚至透過構思「如何測試?」以寫出容易維護與好設計的程式。

單元測試加上持續整合,能發揮更好的效果,每次簽入程式碼,若是需要編譯的程式語言,除了確保簽入的程式不會造成編譯失敗,還能在短時間內執行大量的單元測試與整合測試,能儘早發現問題,減低之後修復的成本。由於現在的系統越來越龐大,部署變成是一件很複雜的事,若只依賴人工部署,龐大的配置 (configuration) 很容易出錯,所以更進一步是最好能自動部署,讓容易出錯的部分減到最小。

專注做真正該做的事,如果將團隊的開發能力當成分母,要開發的需求作為分子,在開發團隊的能力無法快速提升的情況下,要讓產品更快完成,就是讓分子變小,這不是指減少開發的項目,而是避免讓不必要的項目加到分子中,有人可能會說,在沒上市前誰會知道哪些項目是不需要的,但這句本身就是個盲點,既然不知道是不是不需要的,同樣也表示他可能不是需要的,其實不須急在這個版本中釋出,我相信「能決定開發項目的優先順序以讓產品成功」是好的產品經理或 product owner 最重要的能力之一。

保持靈活的彈性,簡單說就是擺脫過去 waterfall 開發方式的思維,不論是用 scrum、kanban 或其他敏捷開發法,在一個較短的週期裡,馬上能有一定的產出,能檢視過去做錯的部分,不管是開發中遇到的技術問題或是需求的問題,能在短的週期裡立即作出調整,當產品有一定的程度,其實就可以投入市場接受考驗,不要思考說做到完美才上,因為就如同剛說的,未上市前不會知道什麼是完美的,甚至花了一堆時間做的完美,對使用者來說卻毫無意義,那時間都是浪費掉的。

至於新創團隊要不要寫單元測試?是要跑 scrum 還是跑 kanban?先談單元測試,有一種說法是新創團隊不寫測試是因為開發的東西還不知道有沒有市場,花時間寫測試不划算,因為需求隨時都可能改掉。我覺得這完全是兩回事,單元測試是用來驗證開發的程式是否如設計般正確,不是驗證是否有市場,與其用寶貴的人力做設計的驗證,我還是覺得單元測試比較好,但這決策只能由新創團隊自己決定。至於要跑哪種開發法,我倒覺得重點是彈性,而不是死守這些方法的規範,當然,前提是真的了解為什麼要有這些規範後,才能知道哪些規範也許現在不適用。

用棒球賽類比軟體開發或許不夠精確,畢竟季後賽打完,整個賽季也就結束了,新的賽季開始,整個戰績全部重來,每支球隊都從零勝零敗開始,這就是比賽。但軟體開發則不是,可能這一仗打輸了,市場沒了,整個公司就結束了,所以每一次釋出都像是在打季後賽,意志力是有必要的。可是,當公司已經在市場上,透過不斷地釋出添加新功能,反而像是例行賽,是一場長時間的考驗,若沒有好的團隊體質,光靠意志力是不夠的。而這兩者間其實沒有那麼明確的一條線,當跨過那條線之後就是例行賽,沒跨過前每場都像季後賽。即使有那麼一條明確的線,良好的團隊體質是需要時間培養的,跨過那條線才開始培養有點太晚了。

最後,我想起《瘋狂改變世界:我就是這樣創立Twitter的!》書中的一段,Biz Stone 提到即使在有信用卡卡債的情況下,只要稍微有一點點餘力,還是會捐款給有需要的組織,因為總有比他們更需要這筆錢的人。也許,為了短期目標已經快沒有餘力了,可是即使只有一點點,一丁點一丁點的投資,都比不投資要好,這些投資最終會打造出好的團隊體質。

系列索引
上一篇《閒談軟體架構:內部函式庫
下一篇《閒談軟體架構:意志力不是萬能藥

 
about 2 years ago

今年因為轉職,特休歸零了,一年一度跟家人的國外旅遊也就暫緩,只有安排短短的宜蘭兩天一夜的小旅行,宜蘭雖然去過不少次,但這次幾個點都蠻不錯的,算是有個小小的充電,但...總覺得少了些什麼?

上一回提到在開發 framework 或 library 時,naming style 是很重要的,有一致且達意的 API 讓使用 framework 或 library 的開發者不會有意外感,容易上手。但可惜的是自動化工具能檢查格式 (縮排、空白等等),但無法幫忙判斷這個變數名稱好不好或是這個函式名稱達不達意,也許哪一天,將人工智慧用來判斷程式碼的 naming style 也許是個不錯的研究方向。

當公司的規模越來越大,開發的項目變多變大時,總是會出現在不同的專案但卻開發重複的東西,因此,公司內部的 library 或 framework 就會出現,一般來說,這些內部 library 或 framework 不會一開始就是 open-source 的形式出現在 Maven Central RepositoryJCenternpmGitHub 中。除了牽涉到公司利益的問題外,即使要開放,也會是等到內部使用一陣子後,API 有一定的穩定度後,才會釋出,例如 okhttp 原先是 Square 的內部眾多 library 的其中一個,然後回饋給 open-source 社群,類似的例子還蠻多的,像是 Cassandra 原本也是 Facebook 的內部專案。

一旦公司內開始有內部函式庫,某種程度需要一些管理,確保不會花兩倍的人力開發相似的東西,或是避免開發的東西沒人知道。之前曾聽在硬體公司的軟體部門的人聊過,公司內部的 (韌體相關) 函式庫沒有進版控系統,可能聽誰說有什麼可以用,於是找主管複製,然後在自己的專案裡一起編譯,因為沒有人維護這些函式庫,踩到雷也只在自己的專案裡東修西修,沒有任何回報機制,頂多是自己保留一份修正版本,下次要用時就用自己的版本。又或者是,不斷地複製 (分支) 然後根據專案的需要修改,到最後函式庫有眾多個不同版本,完全不知道哪個版本才是最新最完整的,最後,沒人想用這些函式庫。

沒人維護公司內部函式庫是很常見的一種情況,因為專案或產品的產出才是能賺錢的部分,函式庫只是開發過程中,附帶的產物,當初貢獻的工程師可能在忙別的專案或產品,公司也不太可能讓這工程師變成專職維護內部函式庫的工程師。所以,最起碼內部函式庫要進到版控系統,如果要修改,要在版控系統上建立分支,並留下 commit,如果哪天真的要整理函式庫,還能透過版控系統進行比較 (compare) 與合併 (merge),更進一步還能進行函式庫版本控管,如果沒有版控系統,程式碼只是各自電腦中的一個目錄,那要整理就會是一件很困難的事了。

當整個公司 share 同一個 code base 時,內部函式庫被頻繁使用的情況下,就比較不會有無人維護的情況,但要進行設計上的改版,就可能會引發公司內的政治問題,例如,一個內部函式庫 X,同時被 A 及 B 兩個專案使用,一開始可能都沒問題,但 A 專案的工程師想對 X 針對專案的需要進行設計上的調整,B 專案的工程師也想對 X 做一些針對 B 專案的調整,若兩者的設計不相違背,那是最理想的情況,但有衝突時,就需要有人出面協調,可能是原 X 函式庫的開發者或是主導整個公司技術的負責人。

不論是採用哪種方式進行改版,有使用到 X 函式庫的其他專案都可能會受到影響,所以,務必要做好函式庫的版本控管,至少讓其他專案能有使用既有版本的選擇。相較於直譯式語言,只需要做好程式碼的版本控管即可。編譯式語言或是能以 binary 形式 (像是副檔名為 libdllsoajar 的檔案) 使用函式庫的語言,函式庫的版本控管就多了一個東西要管理,也就是 binary 檔案的版本控管。雖然說,有原始碼就能夠重建這些 binary 檔案,但有些情況會盡可能避免重建這些檔案:

  • 編譯時間太長,若編譯一個 so 需要五分鐘,使用到多個 so 檔可能會花上半小時,這太花時間了
  • 編譯環境,編譯函式庫可能需要一些環境,但不見得和專案的開發環境相同,若已經有 binary 檔案,可以省去一些設定開發環境的麻煩

目前大多數的語言生態系中都有 binary 層級或是 source code 層級的函式庫版本管理系統,以 Java 來說,想在公司內管理內部函式庫,可以使用 Artifactory,透過 CI 的協助,在程式碼有變動時,可以建置、測試,然後部署新版的 binary 檔案到 Artifactory 的檔案庫 (repository) 中,就能夠在 Maven 的 pom 檔或是在 Gradle 的腳本檔中使用。

內部函式庫雖然可以幫公司省去不少開發重複東西的時間,但內部函式庫也是有一些缺點存在的。首先,一個專案中內部函式庫使用的量,會影響一個剛加入專案的新人了解專案架構的速度,使用的函式庫越多,特別是 framework 層級的函式庫,常會讓專案的程式邏輯散落在很多與 framework 有關的物件中,程式流程會較難以理解,可能會造成只知道如何撰寫特定程式加到 framework 中完成指定的功能,但整個專案的架構不熟悉,要解決要深入的問題時,就不知從何下手。

再者,是相依性問題,專案相依在許多內部函式庫時,若每個內部函式庫都很穩定,那專案應該會很容易在巨人的肩膀上專注在專案的功能上,但若有一兩個內部函式庫不是那麼穩定時,一出錯常常要找半天還不能確定是專案的問題或是某個內部函式庫的問題。

第三是部署或發佈可能會是一個問題,若公司是以產品為主,那這問題就比較好處理,但如果專案是一個接進來的外包案,內部函式庫的發佈就比較麻煩一點,直譯式語言只能將內部函式庫以原始碼的方式交給業主 (有其他方式嗎?),但即使是 binary 形式,在發佈的版本中,建置腳本需要一些特別的調整,總不能讓業主能直接連進公司內取得 binary 檔吧。

即使有上述幾個問題,但以長遠的角度來看,內部函式庫還是值得投資的公司資產,只是它需要時間、人力與管理才能做得好。若有不錯的內部函式庫其實也可以回饋給 open-source 社群,畢竟,現在開發軟體已經不太可能沒有用到任何 open-source 的東西了。回饋的形式有很多種,雖然說是將公司資產以 open-source 釋出,但換取的利益卻不見得是零,像是提升公司的聲望,在招募時能有更好的人才願意進來;獲得 open-source 社群的再貢獻,讓函式庫變得更好等等。話雖如此,但釋出時,還是要妥善選擇授權條款。

系列索引
上一篇《閒談軟體架構:API Naming Style
下一篇《閒談軟體架構:意志力不是萬能藥

 
about 2 years ago

Translated from "The Evolving Nature of Interfaces" by Michael Kölling, Java Magazine September/October 2016, page 47-52. Copyright Oracle Corporation.

介面本質上的進化

了解 Java 的多重繼承

在 “New to Java” 系列,我試著精選些能引起更深入瞭解語言概念1 (language construct) 背後理念的議題,通常,新手對一個概念有能動手開始做的知識,能將這概念用在多數情境下,但他們缺乏對底層原則的了解,這些原則能讓她們寫出更好的程式、建立更好的組織以及能在選擇何時用某個概念時作出更好的決策。Java 的介面正好一個合適的議題。

在本文中,我假設您對繼承已有基礎的認識,如同 extendsimplements 關鍵字,Java 的介面與繼承息息相關,所以我將討論為什麼 Java 有兩種迥異的繼承機制 (用關鍵字區別)、什麼是抽象類別以及介面能用在什麼任務上。

通常都是這樣,這些特性的故事都從某些相當簡單且優雅的想法出發,這些想法定義先前 Java 版本中的眾多概念,然後當 Java 進一步開始處理現實世界中複雜的問題,故事變複雜了,這導致 Java 8 引入預設函式 (default methods),讓水變混濁了2

一些關於繼承的背景

基本上,繼承是相當容易理解:一個類別可以被指定成另一個類別的擴充,在這情況下,當下的類別稱作 subclass,被擴充的類別稱作 superclass3,subclass 的物件擁有 superclass 和 subclass 的所有屬性 (properties),它們有 subclass 或 superclass 定義的所有欄位4 (fields) 及函式,到目前為止一切順利。

然而,繼承就像是程式設計中的瑞士小刀:它能用來達成一些相異的目標。我可以用繼承來重複利用我以前寫過的程式、我可以用它來做分類與動態派遣、我可以用它將規格與實作分開、我可以用它規範一個系統中不同部分之間的合約、我可以用它做很多不同的事。這些都很重要,但都是相當不同的想法。因此有必要了解這些差異才能對繼承與介面有好的體認。

型別繼承 vs. 程式繼承

繼承提供的兩個主要能力是繼承程式碼與繼承型別,從概念上將兩個想法分開是很有用的,特別是因為 Java 的繼承將兩者混在一起,在 Java 中,定義類別也同樣定義型別,例如,在擁有一個類別的當下,我可以建立該型別的變形。

當用 extends 關鍵字建立一個 subclass,這個 subclass 繼承了 superclass 的型別與程式,繼承得到的函式可以被呼叫 (我之後稱之為程式),subclass 的物件可被放在預期是 superclass 物件的地方 (因此,subclass 建立 subtype)。

現在看個例子,若 StudentPerson 的 subclass,則 Student 的物件既是 Student 型別,同時也是 Person 型別,一個學生是 (is a) 一個人,程式與型別都被繼承。

Java 決定將型別繼承與程式繼承連結在一起是一種語言設計選擇:因為它通常很有用,但這不是設計語言時唯一的方式,其他程式語言允許繼承程式但不繼承型別 (例如 C++ 的 private inheritance),或是繼承型別但不繼承程式 (Java 同樣支援,稍後會解釋)。

多重繼承

下個開始混淆的概念是多重繼承 (multiple inheritance),一個類別可以有一個以上的 superclass,給您個例子:PhD 學生在我的大學中同時也是講師,某種意義上,他們是職員 (他們是某門課的講師,有研究室號碼,有薪資帳號等),但他們同時是學生,要修課,有學號,我可以將這塑模成多重繼承 (如 Figure 1)

Figure 1. An example of multiple inheritance

PhDStudent 同時是 FacultyStudent 的 subclass,如此,一個 PhD 學生將擁有學生與職員的屬性,概念上這很直覺,然而,實務上,若允選多重繼承會讓語言變複雜,因為會引發新問題:如果兩個 superclass 都有相同名字的欄位該怎麼辦呢?如果有相同的函式特徵 (signature)5但實作不同怎麼辦?為此,需要額外的語言概念指定模稜兩可問題的解決方式與名稱多載,然後,這只會更糟6

菱形繼承

更複雜的情節是菱形繼承 (diamond inheritance,見 Figure 2),一個類別 (PhDStudent) 有兩個 superclass (FacultyStudent),而各自有一個共同的 superclass,繼承圖形成一個菱形。

Figure 2. An example of diamond inheritance

現在,思考一個問題:如果最上層的 superclass (在這例子中,Person) 有一個欄位,最底層的類別 (PhDStudent) 該欄位應該有一份還是兩份?它繼承這欄位兩次,畢竟,從繼承分支中各繼承一次。

答案是看情況,如果問題中該欄位是是一個 ID 編號,例如 PhD 學生應該有兩個編號:一個是學號及一個職員或薪資帳戶編號,兩者可能不同,然而,如果該欄位是該人的家族姓,則您會希望只有一個 (PhD 學生只有一個家族姓,即使從兩個 superclass 繼承該欄位)。

簡而言之,事情變得很混亂,允許完整多重繼承的語言需要規則與概念去處理這些情況,而這些規則是複雜的。

型別繼承來解救

當您認真思考這些問題,您會理解這些多重繼承的問題都和繼承程式 (函式實作及欄位) 有關,多重程式繼承是混亂的,但多重型別繼承則沒問題,另一個觀察的事實是,多重程式繼承並沒有非常重要,因為可以用委派 (delegation,使用另一個物件的參考) 取代,但多重型別繼承則是非常有用,且沒有優雅的方式能輕易取代。

這是為什麼 Java 設計者做出決定:只允許程式單一繼承,但允許多重型別繼承。

介面

為了讓型別與程式有不同的規則,Java 需要可以指定型別但不需要指定程式內容,這正好就是 Java 介面。

介面指定型別 (型別名稱與其函式的特徵) 但不用提供任何實作,欄位與函式實體都是不需要的,介面可以有常數,您可以不寫修飾詞 (常數的 public static final 與函式的 public),它們在暗地中就被假設是如此。

在 Java 中,這安排提供我兩種繼承:我可以繼承 (用 extends) 一個類別,同時繼承型別與程式,或透過繼承介面,我可以只繼承 (用 implements) 型別,我可以用不同的規則思考多重繼承:Java 允許型別 (介面) 多重繼承,但有程式的類別只允許單一繼承。

多重型別繼承的好處

顯而易見,允許多重型別繼承的好處是可以在不同時間以不同的型別看待一個物件,假設您在寫交通模擬的軟體,有許多 Car 物件,除了汽車,還有其他活躍物件在您的模擬之中,例如行人、卡車、紅綠燈等,您可能在程式中有一個集中的容器 (一個 List) 來保存這些參與者 (actor):

private List<Actor> actors;

Actor 在這例子中,可以是一個介面並有一個 act 函式:

public interface Actor {
    void act();
}

接著,您的 Car 可以實作這介面:

class Car implements Actor {

    public void act() {
        ...
    }
}

注意,由於 Car 只繼承型別,包含 act 函式的特徵,但不含任何程式,因此自身先需提供型別的實作 (act 函式的實作) 才能建立物件。

到目前為止,這只是單一繼承,也可以用繼承類別的方式完成,但想像一下,現在要在螢幕上畫出一連串的物件 (和 actors 不完全相同,因為有些 actor 不用畫在螢幕上,有些畫在螢幕上的物件不是 actor):

private List<Drawable> drawables;

在某個時間點,您可能想將模擬的情況儲存到永久的儲存空間裡,而這些物件可能又是另一個不同的容器,要被儲存,他們需要是 Serializable 型別:

private List<Serializable> objectsToSave;

在這情況中,假設 Car 物件都在三個容器中 (他可以做動作、要可以畫在螢幕上、可以被儲存),Car 可以實作三個介面:

class Car implements Actor, Drawable, Serializable ...

像這樣的情境非常普遍,有多種 supertypes 可以讓你用不同的觀點看待單一一個物件 (在這例子中是汽車),專注在不同面向,將相似的物件群組在一起,或是根據特定可能的行為來對待它們。

Java 的事件處理模型就是環繞在相同理念下建造出來:事件處理由事件聆聽者 (例如 ActionListener) 完成,通常只需要實作一個函式,需要時,實作的物件就可以被視為聆聽者的型別。

抽象類別

我需要簡單說一下抽象類別 (abstract classes),因為它常納悶如何與介面相處,抽象類別介於類別與介面的中間:它可以定義一個型別並和類別一樣可以擁有程式,但他同時可以有不需實作的抽象函式 (abstract methods),您可以把它們想成部分實作的類別,有些縫隙需要由 subclasses 實作程式去填滿。

在先前的例子,Actor 介面可以用抽象類別取代,act 可以是抽象函式 (因為每種 actor 都有不同的行為,沒有合理的預設行為),但可以有其他程式是所有 actors 共用的。

在這情況下,我可以將 Actor 寫成一個抽象類別,然後 Car 繼承該類別:

class Car extends Actor implements Drawable, Serializable ...

如果我想要多個介面包含程式,將它們都轉成抽象類別是沒用的,如同我一開始說的,Java 只允許繼承單一類別 (指在 extends 關鍵字後只能有一個類別),多重繼承只能用在介面上。

有一個方法能解決,透過預設函式 (default methods),待會在介紹 Java 8 時會提到。

空介面

有時候,您會碰見空介面,只有名稱沒有任何函式宣告,剛提到的 Serializable 就是一個空介面,Cloneable 是另一個,這些介面又被稱為標識介面 (marker interfaces),它們將類別標識為某種特殊屬性的處理,相較於定義型別或是定義元件間的合約,用途比較接近提供中介資料 (metadata),從 Java 5 開始,有 annotation 更適合用來提供中介資料,如今在 Java 中使用標識介面的原因已經很少,如果您有興趣,試著用 annotation 取代。

如今在 Java 中使用標識介面的原因已經很少,如果您有興趣,試著用 annotation 取代。

Java 8 的新曙光

到目前為止,我忽略部分 Java 8 所引入的新特性,這是因為 Java 8 新增的功能抵觸早先語言設計時的部分決策 (例如只許允單一程式繼承),這讓解釋某些語言概念之間的關係變得有點困難,例如,辯論介面與抽象類別的不同與存在的理由變得的微妙,如我馬上就會介紹的,Java 8 的介面已被擴充成更像是抽象類別,但還是有些微的不同。

我在這議題的說明上,先帶您走過歷史的軌跡,說明在 Java 8 之前的情境,現在加入 Java 8 的新特性,會這麼做,是因為有鑒於這些歷史,才能瞭解這些特性如今合併的正當性。

如果 Java 團隊現在重新設計 Java,且打破向下相容不是個問題,他們不會以相同的方式設計 Java,然而,Java 語言不只是最早的理論運用,也是實際使用的系統,且在現實世界中,您必須找到方法在不打破任何既有東西的前提下,去進化與擴充您的語言,介面的預設函式和靜態函式是讓 Java 8 前進變可能的兩個機制。

介面進化

開發 Java 8 時的一個問題是如何進化介面,Java 8 加入 lambda 及許多特性到 Java 語言中,也希望用這些用來改造 Java 函式庫中既有的介面,但您如何進化一個介面而不破壞已經使用這介面的程式呢?

想像一下,在您既有的函式庫中,有一個介面 MagicWand

public interface MagicWand {
    void doMagic();
}

這介面已經在許多專案中被使用與實作,但您現在想到某些超棒的新功能,且您真的想加一個非常有用的新函式:

public interface MagicWand {
    void doMagic();
    void doAdvancedMagic();
}

如果您這麼做,所有實作這介面的類別將會壞掉,因為他們會被要求實作新函式,所以,乍看之下,您被困住了,要不打破既有的程式 (您不想這麼做),要不因沒任何機會改善舊函式庫,所以卡住而失敗 (實際上有幾個可能性可以嘗試7,例如用 subinterfaces 擴充既有介面,但仍有其他問題,我不打算在這裡討論),Java 8 想出一個聰明的妙計解決上述兩個處境:在既有介面新增功能但不破壞既有程式,這方法便是預設函式與靜態函式,我接著說明。

預設函式

預設函式是介面裡有實作內容 (預設實作) 的函式,定義它們時,會在函式特徵的開頭加上 default 修飾詞,並提供完整的函式實體:

public interface MagicWand {
    void doMagic();
    default void doAdvancedMagic() {
        ... // some code here

    }
}

實作該介面的類別可以選擇覆寫這函式提供它們專屬的實作或是完全忽略這函式,若忽略,則接收介面的預設實作。舊的程式碼繼續運作,同時,新的程式碼可以用新功能。

靜態函式

介面現在可以包含有實作的靜態函式,定義函式特徵時,在開頭加上 static,一如往常,在寫介面時,public 修飾詞是可以省略的,因為介面中的函式與常數永遠都是公開的。

所以,菱形繼承的問題呢?

如您所見,抽象類別與介面現在變得非常相似,雖然語法迥異,但兩者皆可以包含抽象函式與有實作的函式,仍然有些不同 (例如抽象類別可以有實體欄位,可是介面不行),但核心焦點是,從 Java 8 開始,透過介面,您可以有多重的程式繼承!

在文章的一開始,我指出 Java 設計者如何小心地前進避開多重程式繼承,因為多重繼承與名稱衝突可能引發問題,所以現在的情境呢?

像往常一樣,Java 設計者安排以下實用的規則處理這些問題:

  • 繼承多個同名的抽象函式不是問題,他們被視為相同的函式。
  • 困難的問題之一,欄位的菱形繼承,因為介面依然不允許有不是常數的欄位,所以避開了。
  • 繼承靜態函式與常數 (就定義上是靜態的) 不是個問題,因為使用它們時,需加上介面名稱作為前綴,所以名稱不會衝突。
  • 繼承多個同函式特徵的預設函式實作會是個問題,但 Java 和其他語言不同,選擇一個非常識做的做法:與其設計一個新的語言概念來處理這問題,不如讓編譯器直接將此視為錯誤,換句話說,這是您的問題,Java 直接告訴您:別這麼做!

結論

介面是 Java 中非常強大一個特色,在很多情境中都很有用,像是定義程式中不同部分之間的合約、定義分類與動態派遣、將型別的定義與實作分開、以及在 Java 中允許多重繼承。在您的程式中,它們通常非常有用,您應該確保您十分了解它們的行為。

在 Java 8 中,介面的新特色,像是預設函式,在您寫函式庫時非常有用,較少機會用在應用程式的程式碼中,然而,Java 函式庫現在大量地使用它們,所以確定自己熟悉它們能做什麼,小心使用繼承可以大幅改善你的程式碼品質。

譯註
1.研究所後期用的書很少有中文翻譯,所以 language construct 的中文是什麼,我還真的沒見過,基本上 language construct 是指組成一個程式語言的元素,像函式、類別、繼承、迴圈等都能稱作是 language construct,但我實在不想翻成語言的組成元素,因為實在是太長了。另外,本文中大多數情況下,construct 和 concept 都指相同的東西,而事實上把類別當成一個概念也不會有太大的問題,所以我把 language construct 翻成語言概念,讓在只讀中文的情況下,確保整篇語意的通順。
2.加一個新東西或概念,不一定是真的解決問題,只有正確地使用才會解決問題,但反之,在錯誤的想法下使用新概念,通常只是讓事情變得更糟糕。
3.本來想用慣用的中文翻譯,但由於 superclass 和 subclass 的慣用中文不夠「中性」,所以還是決定保留原文。
4.以目前 Java 的生態,為了讓 framework 提升 productivity 的決策優於 information hiding 的情況下,幾乎所有的 field 都加上 getter/setter,物件的 property 和 field 幾乎快劃上等號。
5.以 Java 來說,signature 包含名稱以及參數的數量、順序與型別。
6.雖然 C++ 支援多重繼承,但學習門檻也變超高,真正大量使用多重繼承的例子不多見。
7.之前開發 Comic Surfer 我就遇到這問題,而我採用的方式是抽象類別,讓第三方程式依賴在抽象類別上而不是介面上,其實也就是在沒有預設函式的變通作法,但問題是會讓第三方的類別失去唯一的類別繼承。

譯者的告白
當初選擇翻譯這一篇文章,主要是因為我想起兩年前,我也曾寫過探討關於預設函式的文章,但質量差蠻多的,我覺得這一篇寫得非常好,包含設計的抉擇與使用的時機都有很好的描述,對於想使用預設函式的人說,有很好的指引。不過,過了兩年,Java 8 的採用率似乎沒有很理想,甚至網路上不少抱怨討論 Oracle 對 Java 的發展到底是玩真的還是玩假的 (只想用專利告人),看完都覺得好可惜啊,Java 其實還有不少發展空間,往好處想,至少 Google 在 Android N 已經支援大多數 Java 8 的新特色了。

 
about 2 years ago

Translated from "Interacting with Sensors on Intel’s x86 Galileo Board" by Gastón Hillar, Java Magazine September/October 2016, page 14-23. Copyright Oracle Corporation.

在 Intel x86 Galileo 開發板上與感應器互動

捕捉資料與反應是 IoT 的核心,本文說明如何用 Java 與不貴的 x86 處理器開發板做到

在本篇文章中,我開發並解釋一個量測環境光源與調節紅、綠與藍 LED 作為反應的專案,透過這方式,您可以看到如何使用 Java SE 8 來和類比輸入及脈衝寬度調變 (pulse width modulation, PWM) 輸出互動,我使用最新的 Intel IoT 套件,它包含對 Java 的支援,我善用 upm 及 mraa 兩個函式庫的優點:提供高階的介面控制 Intel Galileo Gen 2,相容 Arduino 的 x86 Pentium 架構處理器,我也會說明如何控制連接的電子元件、感測器 (sensor) 與驅動器 (actuator)。

必要條件

Intel Galileo Gen 2 開發板用 Yocto Poky Linux 映像檔開機,它是 Intel Galileo 最新版的 microSD 記憶卡 Linux 作業系統映像檔,您可以在 Intel 官網下載最新版的 Yocto Poky 映像檔。

為了開發,我使用 Intel System Studio IoT Edition 軟體,它是基於 Eclipse 的 IDE,讓建立以 Java 為主語言的 IoT 專案變簡單,我同時使用最新版的 mraa 與 upm 函式庫,mraa 是 Linux 平台上用來溝通的低階基礎函式庫,而 upm 是與感應器及驅動器互動的函式庫集,兩者皆包含在 Intel IoT 開發套件中,您可以在 Intel IoT 官網下載 Intel System Studio IoT Edition。

剛開始使用 Java 開發 IoT 專案時,您可能會遇到的問題:包含在 Intel System Studio IoT Edition 中的 upm 和 mraa 版本和 Galileo Gen 2 開發板上的 Yocto Poky Linux 所安裝的函式庫版本必須一致。我在使用 IDE 的同步功能時遇到蠻多問題,因此,我建議分成兩步驟:首先更新 IDE 的函式庫,然後再更新開發板上的函式庫,選擇 IDE 主選單上的 Intel(R) IoT | Libraries Update...,然後按指示逐步安裝函式庫的最新版本。

以 microSD 記憶卡 Linux 作業系統映像檔完成開機後,用乙太網路孔連上您的區域網路,DHCP 伺服器將會配發一個 IP 位址給開發板,您稍後將會需要這個 IP 位址,您有幾種方式可以取得配發的 IP 位址,例如,假設您進到 DHCP 伺服器網頁介面,您可以得到一個 IP 位置配發給一個裝置,它的 MAC 位址和開發板上標籤印的一樣。

您同樣可以透過零設定網路 (zero-coniguration networking) 的實作在區域網路中找到開發板及其服務,在 Windows 上,您可以使用免費的 Bonjour Browser for Windows,在 OS X 上,您可以使用免費的 Bonjour Browser for OS X。這兩個應用都能顯示可供使用的 Bonjour 服務,您只需要注意名稱為 galileo 的服務,名為 galileo 的 SSH 服務其 IP 位址將是您能用來建立連線到開發板的 IP 位址。

然後,用您喜好的 SSH 終端機工具,使用剛剛取得的 IP 位址、root 帳號及空白密碼,登入到 Intel Galileo Gen 2 開發板上的 Yocto Linux,接著在 SSH 終端機中執行下列指令:

opkg update
opkg install mraa
opkg install upm

接著,在 IDE 中點擊 Create an IoT Project,然後選擇 Java Project,我將使用 AmbientLightAndLed 作為專案名稱,輸入連線名稱與您的 Intel Galileo Gen 2 開發板 IP 位置,這樣,IDE 將提供您一個上傳專案到指定的開發板並執行的環境,在輸出畫面上,您將會看到專案執行時的輸出結果,且您能在終端機上執行指令。不過,一如往常,當您知道事情如何運作後,您會發現客製化自己的腳本,執行指令上傳編譯後的 JAR 檔並執行它會更簡單。當您開始以 Java 使用 mraa 及 upm 函式庫時,IDE 提供的整合除錯體驗會非常有用。

連接電子元件

光敏電阻 (photoresistor) 是一個電子元件,又稱為光電組 (light-dependent resistor, LDR) 或光導管 (photocell),它不是能以高精準度量測環境光源的最佳元件,但這元件在判斷環境是亮是暗很有用,且在這個例子中,延遲性不是個問題 (延遲亮度調節的反應),如果您要在更複雜的專案中量測環境光源,確保您有考慮到精準度與延遲性。

光敏電阻是一種可變電阻,電阻值會隨著環境光源改變,當環境光源強度增加,光敏電阻的電阻值下降,反之亦然。透過類比輸入接腳,開發板可以讀取電壓值,我將光敏電阻作為電壓分配電路 (voltage divider coniguration) 兩個電阻中的一個,當光敏電阻接收到大量的光,電壓分配電路輸出高電壓,當光敏電阻接收到少量的光或完全沒有光,則輸出低電壓。

Intel Galileo Gen 2 開發板允許您使用六個數位 I/O 接腳作為 PWM 輸出腳,六個接腳以 tilde (~) 符號標示,作為開發板上編號的前綴。我使用標示為 A0 的類比接腳連接到包含光敏電阻的電壓分配電路的正極。此外,我用下列 PWM 接腳控制 RGB LED 的亮度:

  • 接腳 ~6 連接到控制 RGB LED 紅色成分的陽極
  • 接腳 ~5 連接到控制 RGB LED 綠色成分的陽極
  • 接腳 ~3 連接到控制 RGB LED 藍色成分的陽極

您需要下列零件以完成整個範例:

  • 一個光敏電阻
  • 一個允許 5% 誤差的 10,000Ω (10 kΩ) 電阻,色碼為棕、黑、橘、金。
  • 一個共陰極的 5mm RGB LED
  • 三個允許 5% 誤差的 270Ω 電阻,色碼為紅、紫、棕、金。

Figure 1 電子元件連接在麵包板上並與 Intel Galileo Gen 2 開發板連接

Figure 1 呈現剛剛描述的電子元件連接在麵包板上,以及從 Intel Galileo Gen 2 開發板到麵包板的必要連線 (我用廣受歡迎的 Fritzing multiplatform application 建立原始圖,這檔案和本文的程式碼可在 Java Magazine 的下載區取得)。

有三個能作為 PWM 的通用 I/O (GPIO, general-purpose input/output) 接腳:~6~5~3,每一個都接上一個 270Ω 的電阻並接在 LED 每個顏色的陽極接腳,共陰極則是接地 (GND),標示為 A0 的類比輸入接腳接到由光敏電阻與允許 5% 誤差的 10 kΩ 電阻所組成的電壓分配電路,光敏電阻接到 IOREF 接腳,我使用開發板的預設組態,因此 IOREF 是 5V,10 kΩ 則是接地 (GND)。

寫程式用 PWM 調節 LED 顏色

當您完成所有必要的連線,您需要寫 Java 程式判斷是否在暗的環境,及根據環境光源的強弱控制 RGB LED 三個顏色的亮度,程式讀取由電阻值轉換的電壓值,然後將此類比數值轉成數位形式,程式將數位值轉成電壓,然後將電壓轉成量測值。

為了讓程式簡單容易理解,接下來我解釋的 Java 類別不會為每個運算檢查結果,但在範例的最終版本,您應該檢查每次呼叫 mraa 的結果,確保回傳值等於 mraa.Result.SUCCESS

我將建立以下三個類別:

  • VariableBrightnessLed 代表連接到開發板的 LED,允許我控制其亮度。
  • VoltageInput 代表連接到開發板上的類比輸入接腳的電壓源,允許我將從類比輸入讀取的原始值對應成電壓值。
  • SimpleLightSensor 代表一個光感應器,允許我將從 VoltageInput 實體讀取到的電壓值轉成光量測值與描述。

接著,我將建立一個 BoardManager 類別,負責建立 VariableBrightnessLedSimpleLightSensor 類別的實體並與之互動,最後建立一個主類別 AmbientLightAndLed 協調前述類別的運作。

首先,我建立一個新的 VariableBrightnessLed 類別代表連接到開發板的 LED,可以有 0 到 255 階的亮度,接下來的程式碼呈現我所有類別會用到的 imports 及此類別的程式:

import mraa.Aio;
import mraa.Pwm;

class VariableBrightnessLed {

    private final int gpioPin;
    private final String name;
    private final Pwm pwm;
    private int brightnessLevel;

    public VariableBrightnessLed(int gpioPin, String name) {
        this.name = name;
        this.gpioPin = gpioPin;
        this.pwm = new Pwm(gpioPin);
        this.pwm.period_us(700);
        this.pwm.enable(true);
        // Set the initial brightness level to 0

        this.setBrightnessLevel(0);
    }

    public void setBrightnessLevel(int brightnessLevel) {
        int validBrightnessLevel = brightnessLevel;
        if (validBrightnessLevel > 255) {
            validBrightnessLevel = 255;
        } else if (validBrightnessLevel < 0){
            validBrightnessLevel = 0;
        }
        float convertedLevel = validBrightnessLevel / 255f;
        this.pwm.write(convertedLevel);
        this.brightnessLevel = validBrightnessLevel;
        System.out.format("%s LED connected to PWM Pin #%d set to brightness level %d.%n", this.name, this.gpioPin, validBrightnessLevel);
    }

    public int getBrightnessLevel() {
        return this.brightnessLevel;
    }

    public int getGpioPin() {
        return this.gpioPin;
    }
}

當我建立一個 VariableBrightnessLed 實體時,需要用 int pioPin 參數指定連接到 LED 的 GPIO 接腳編號,及用 String name 參數指定 LED 的名稱,建構子以 gpioPin 作為參數建立一個新的 mraa.Pwm 實體,並保存其參考在 pwm 欄位,接著呼叫其 period_us 函數設定 PWM 週期為 700 微秒 (700 μs),這樣輸出的工作週期 (duty cycle) 將決定在 700 μs 的週期中訊號為 on 狀態的比例。例如,百分之十 (0.10) 的輸出工作週期指在 700 μs 的週期中,訊號為 on 的狀態為 70 μs。

接著,建構子以 true 為參數呼叫 pwm.enable 設定 PWM 接腳為開啟的狀態,並允許程式開始用 pwm.write 函式設定 PWM 接腳的輸出工作週期比例。

最後,建構子以 0 作為 brightnessLevel 參數呼叫 setBrightnessLevel 函式,如此會將 LED 亮度設為 0,也就是將指定接腳的 LED 關閉,具體來說,這呼叫關閉 LED 特定的顏色。

此類別宣告一個 setBrightnessLevel 函數,將 0 到 255 的亮度值轉換成給 PWM 接腳相應的輸出工作週期,這函式接收 brightnessLevel 參數作為亮度值。

首先,程式確定亮度值在 0 到 255 (含)之間,如果值超過範圍,程式會以下限或上限作為 validBrightnessLevel 區域變數的值。

然後,計算給 PWM 接腳必要的輸出工作週期比例,以 1.0f (100 %) 到 0.0f (0 %) 的區間代表亮度,將合法的亮度值 (validBrightnessLevel) 除以 255f,結果保存在 convertedLevel 變數,下一行呼叫 this.pwm.write 函式將 convertedLevel 作為百分比參數,將連接的 PWM 輸出工作週期設定為 convertedLevel

此類別宣告一個 setBrightnessLevel 函數,將 0 到 255 的亮度值轉換成給 PWM 接腳相應的輸出工作週期。

最後,程式合法的亮度值存在 brightnessLevel 欄位,只能以 getBrightnessLevel 函式讀取,最後一行將 LED 的亮度值與用來辨識的名稱及接腳編號等細節,用 System.out.format 函式印在畫面上,您若用 IDE 執行產生的 JAR 檔或是透過 SSH 在開發板上執行的 Yocto Linux 中以指令執行程式,就可能看到輸出結果,我稍後會詳述列印有用資訊在畫面上的好處。

透過類比輸入量測環境光源

現在,我建立一個新的 VoltageInput 類別,代表連接在開發板上類比輸入接腳的電壓源,下面為該類別的程式碼:

class VoltageInput {

    private final int analogPin;
    private final Aio aio;

    public VoltageInput(int analogPin) {
        this.analogPin = analogPin;
        this.aio = new Aio(analogPin);
        // Configure the ADC

        // (short for Analog-to-Digital Converter)

        // resolution to 12 bits (0 to 4095)

        this.aio.setBit(12);
    }

    public float getVoltage() {
        long rawValue = this.aio.read();
        float voltageValue = rawValue / 4095f * 5f;
        return voltageValue;
    }

    public int getAnalogPin() {
        return analogPin;
    }
}

建立 VoltageInput 類別實體時,需要以 int analogPin 參數指定電壓源所連接的類比輸入接腳編號,建構子將收到的類比輸入接腳編號保存在 analogPin 唯讀欄位,只能以 getAnalogPin 函式讀取,建構子以 analogPin 為參數建立建立 mraa.Aio 實體,保存在 aio 欄位,然後呼叫其 setBit 函式,設定類比轉數位 (analog-to-digital converter, ADC) 的解析度為 12 bits,如此,ADC 將提供 4,096 (212 = 4096) 個數值來表達 0V 到 5V 的範圍,0 代表 0V,4096 代表 5V。

這需要線性函數將從類比輸入接腳讀取的原始值轉乘對應的輸入電壓值,因此,程式將原始值乘上 5 再除以 4095 已取得輸入電壓值,由於使用 12 bits 的解析度,偵測值的刻度將是 5V / 4095 = 0.001220012V,大概是 1.22 mV。

VoltageInput 類別宣告一個 getVoltage 函式,它呼叫 this.aio.read 函式,從類比輸入接腳讀取原始值,保存在 rawValue 區域變數中,這變數方便除錯與理解程式運作原理,然後將 rawValue 除以 4,095 再乘上 5,然後保存在 voltageValue 變數,最後,此函式回傳 voltageValue 變數的值,如此,此函式能從 this.aio.read 函式讀取的原始值並轉換成電壓值回傳。

現在有一個類別能讓我從電壓源取得電壓值,現在,我將建立一個新的 SimpleLightSensor 類別代表包含在電壓分配電路中且與開發板類比輸入接腳連接的光敏電阻,這新類別用剛完成的 VoltageInput 讀取並轉換類比輸入,允許我將電壓值轉換成光源量測值與描述,下面為新類別的程式碼:

class SimpleLightSensor {

    // Light-level descriptions

    public static final String LL_EXTREMELY_DARK = "Extremely dark";
    public static final String LL_VERY_DARK = "Very dark";
    public static final String LL_JUST_DARK = "Just dark";
    public static final String LL_SUNNY_DAY = "Like a sunny day";

    // Maximum voltages that determine the light level

    private static final float EXTREMELY_DARK = 2.1f;
    private static final float VERY_DARK = 3.1f;
    private static final float JUST_DARK = 4.05f;

    private final VoltageInput voltageInput;
    private float measuredVoltage = 0f;
    private String lightLevel = SimpleLightSensor.LL_SUNNY_DAY;

    public SimpleLightSensor(int analogPin) {
        this.voltageInput = new VoltageInput(analogPin);
        this.measureLight();
    }

    public void measureLight() {
        this.measuredVoltage = this.voltageInput.getVoltage();
        if (this.measuredVoltage < SimpleLightSensor.EXTREMELY_DARK) {
            this.lightLevel = SimpleLightSensor.LL_EXTREMELY_DARK;
        } else if (this.measuredVoltage < SimpleLightSensor.VERY_DARK) {
            this.lightLevel = SimpleLightSensor.LL_VERY_DARK;
        } else if (this.measuredVoltage < SimpleLightSensor.JUST_DARK) {
            this.lightLevel = SimpleLightSensor.LL_JUST_DARK;
        } else {
            this.lightLevel = SimpleLightSensor.LL_SUNNY_DAY;
        }
    }

    public String getLightLevel() {
        return this.lightLevel;
    }
}

這類別定義三個常數,指定每個亮度等級的最大電壓值:

  • EXTREMELY_DARK: 2.1V
  • VERY_DARK: 3.1V
  • JUST_DARK: 4.05V

如果量測到的電壓值低於 2.1V,則環境是極度暗,若低於 3V,環境是非常暗,如果低於 4V,環境是有點暗,這些值適用某些特定的光敏電阻,您也許需要確認您的配置在特定環境的電壓值,只需要調整此類別的常數值。

SimpleLightSensor 類別的主要目的是將定量的值 (電壓值) 轉成定性的值 (環境光源描述),這類別宣告下列常數描述亮度等級:

  • LL_EXTREMELY_DARK
  • LL_VERY_DARK
  • LL_JUST_DARK
  • LL_SUNNY_DAY

當我建立 SimpleLightSensor 類別實體時,需要指定 analogPin 參數,也就是包含光敏電阻的電壓分配電路所連接的類比輸入接腳編號,建構子用 analogPin 參數建立 VoltageInput 實體,並保存在 voltageInput 欄位,下一行程式呼叫 measureLight 函式,將從 VoltageInput 實體 (this.voltageInput) 取得的電壓值轉換成亮度等級的描述。

這新類別允許我將電壓值轉換成光源量測值與描述。

這類別的 measureLight 函式保存從 this.voltageInput.getVoltage 函式取得的電壓值在 measuredVoltage欄位,然後下一行用剛解釋過的常數決定 measuredVoltage 是哪個亮度等級,將根據量測得到的電壓值設定合適的lightLevel,然後可以呼叫 getLightLevel 函式取得亮度等級的描述。

SimpleLightSensor 類別的主要目的是將定量的值 (電壓值) 轉成定性的值 (環境光源描述),這類別宣告下列常數描述亮度等級

透過 Board Manager 控制一個輸入與三個輸出

現在我將建一個新的 BoardManager 類別,它建立一個 SimpleLightSensor 實體與三個 VariableBrightnessLed 實體,如此,RGB LED 的每個顏色對應到一個 VariableBrightnessLed 實體,當環境光源改變這類別會觸發動作,明確來說,這類別根據量測的環境光源調整 RGB LED 三個顏色的亮度,下面為新類別的程式碼:

class BoardManager {

    public final SimpleLightSensor lightSensor;
    public final VariableBrightnessLed redLed;
    public final VariableBrightnessLed greenLed;
    public final VariableBrightnessLed blueLed;

    public BoardManager() {
        this.lightSensor = new SimpleLightSensor(0);
        this.redLed = new VariableBrightnessLed(6, "Red");
        this.greenLed = new VariableBrightnessLed(5, "Green");
        this.blueLed = new VariableBrightnessLed(3, "Blue");
    }

    public void setRGBBrightnessLevel(int value) {
        this.redLed.setBrightnessLevel(value);
        this.greenLed.setBrightnessLevel(value);
        this.blueLed.setBrightnessLevel(value);
    }

    public void updateLedsBasedOnLight() {
        String lightLevel = this.lightSensor.getLightLevel();
        switch (lightLevel) {
            case SimpleLightSensor.LL_EXTREMELY_DARK:
                this.setRGBBrightnessLevel(255);
                break;
            case SimpleLightSensor.LL_VERY_DARK:
                this.setRGBBrightnessLevel(128);
                break;
            case SimpleLightSensor.LL_JUST_DARK:
                this.setRGBBrightnessLevel(64);
                break;
            default:
                this.setRGBBrightnessLevel(0);
                break;
        }
    }
}

BoardManager 宣告三個欄位並初始化:

  • lightSensorSimpleLightSensor 的實體,代表連接到類比輸入接腳編號 A0 包含在電壓分配電路中的光敏電阻。
  • redLedVariableBrightnessLed 的實體,代表連接到 GPIO 接腳編號 ~6 的 RGB LED 紅色成分。
  • greenLedVariableBrightnessLed 的實體,代表連接到 GPIO 接腳編號 ~5 的 RGB LED 綠色成分。
  • blueLedVariableBrightnessLed 的實體,代表連接到 GPIO 接腳編號 ~3 的 RGB LED 藍色成分。

setRGBBrightnessLevel 函式以接收的值作為參數呼叫三個 VariableBrightnessLed 實體的 setBrightnessLevel 函式,如此,透過一次呼叫,RGB LED 的三個顏色都設成相同的亮度。

updateLedsBasedOnLight 函式從 SimpleLightSensor 實體取得亮度等級的描述,然後根據量測的光源,呼叫剛解釋的 setRGBBrightnessLevel 函式設定 RGB LED 三個顏色的亮度,如果環境極度地暗,亮度設為 255,若環境是非常暗,亮度值設為 128,若環境是有點暗,亮度值設為 64,其他情況,亮度值設為 0,也就是關閉 RGB LED。

現在,我將寫程式使用 BoardManager 類別量測環境光源,並根據環境光源設定 RGB LED 三個顏色的亮度值,下面為新類別 AmbientLightAndLed 的程式碼:

public class AmbientLightAndLed {

    public static void main(String[] args) {
        String lastlightLevel = "";
        BoardManager board = new BoardManager();
        while (true) {
            board.lightSensor.measureLight();
            String newLightLevel = board.lightSensor.getLightLevel();
            if (newLightLevel != lastlightLevel) {
                // The measured light level has changed

                lastlightLevel = newLightLevel;
                System.out.format("Measured light level: %s%n", newLightLevel);
                board.updateLedsBasedOnLight();
            }
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.err.format("Sleep interruption: %s", e.toString());
            }
        }
    }
}

這類別宣告 main 函式,當我上傳程式到開發板上並啟動時,會執行這函式,首先,這函式將 lastLightLevel 變數初始化為空字串,建立名為 boardBoardManager 實體,然後執行無窮迴圈。

此迴圈呼叫 board.lightSensor.measureLight 函式以更新環境光源的量測結果,下一行將呼叫 board.lightSensor.getLightLevel 所取得的亮度等級描述保存在 newLightLevel 區域變數中,如果新的亮度等級和最後紀錄的環境光源亮度等級不同,程式更新 lastLightLevel 變數的值,列印量測到的亮度等級,然後呼叫 board.updateLedsBasedOnLight 函式。

您能執行這範例,然後用手電筒或您的智慧型手機作為光源在光敏電阻上移動,您應該可以看到列印的訊息,並看到 RGB 變暗,最後關閉,在您減少環境的光源後,RGB LED 亮度增加。

Figure 2 終端機訊息

Figure 2 是在 IDE 的終端機視窗輸出的例子,終端機視窗顯示所有我印的訊息,讓我容易了解元件發生什麼發事。

結論

這簡單的例子說明您可以利用 Java 8 的優點,用高階的程式碼在 Intel Galileo Gen 2 開發板上與 IoT 元件互動,mraa 與 upm 函式庫會定期更新,將它們與 Java 結合,能讓您善用既有知識,在進行與不同輸入、輸出、感測器、顯示器與驅動器互動的 IoT 專案變容易。

Learn More
Yocto Poky Linux image from the Yocto Project
Intel System Studio IoT Edition User Guide for Java

譯者的告白
這篇文章真的很長,作者不但描述每個類別的目的,也接近以逐行說明的方式說明程式碼,也因此重複的部分蠻多的,不過整體來說很有意思,當初轉職就是想玩相關的東西,結果公司被併購後,變成沒機會玩了,只能說命運捉弄人... 不過命運這種事,長遠來看誰知道會怎樣呢?

會選擇這篇文章翻譯,除了自己想玩,還想起一件有趣的往事,想當初大三時一門課的作業是要寫個類嵌入式系統上的程式,那時我用 Java 寫個類似聊天室的東西,不過授課老師問為什麼用 Java 時,我那時說未來不能跑 Java 的裝置都該淘汰,如果以現在來看,手機、機上盒、手錶都能執行 Java (Android),甚至連 IoT 裝置都能執行 Java,當時說的話也許真的成真了,不過,現在想想,如果那時的產品執行 Java,效能及電池續航力的表現肯定奇差無比,選用任何語言或技術,最新的技術不一定適合當時的硬體,還是要斟酌很多面向才行。

路人旁白:因為本文很長,所以譯者的告白也變長了嗎? (笑)