CodeGym /課程 /Kotlin SELF /XSD:為什麼需要 schema 與一致性驗證

XSD:為什麼需要 schema 與一致性驗證

Kotlin SELF
等級 50, 課堂 3
開放

1. 「XML 可讀」vs「XML 正確」

當你剛開始使用 XML 時,常會覺得只要文件能「被解析(parse)」,就代表一切都沒問題。但現實中,「能被解析」大概就像「車子能發動」:不錯,但不代表輪子都有鎖好。XSD 的作用是形式化地描述什麼結構才算允許,並在你開始抽取資料、建立邏輯之前,先檢查輸入文件。

先把三個不同的「正確性」層級分清楚,因為它們被混淆的頻率,比新手在第一批程式裡把 == 和 = 搞混還高:

層級 代表什麼 誰會「報錯」
Well-formed XML 在語法上正確:標籤已關閉、巢狀關係正確 XML 解析器(DOM parser)
Valid(依 XSD) XML 符合schema:需要的元素/屬性存在,值的型別正確 XSD 驗證器
在商業語意上正確 值對你的程式邏輯是合理的(例如: amount > 0, 類別必須在允許的集合中) 你的 Kotlin 程式碼

這裡有個重要觀念:XSD 位在中間。它不處理「括號和引號是否正確」(那是 well-formed),也不處理「意義是否正確」(那是你的商業驗證)。

2. 什麼是 XSD,以及 schema 長什麼樣子

什麼是 XSD

XSD(XML Schema Definition)是一份用來描述 XML 結構契約的文件。 有趣的是,XSD 本身看起來也像 XML。是的,這就是那種「我們寫了一份用來描述文件的文件,而它本身也是文件」的情況。程式設計師經常做這種事:編譯器編譯編譯器、測試測試測試……很正常,我們並不孤單。

在實務上,XSD 會回答像這樣的問題:根元素是什麼、允許哪些子元素以及順序、哪些屬性必填哪些可選、值的型別是什麼 (string、int、decimal,等等)。

而且 XSD 在整合場景特別有用:你會遇到「從其他系統傳進來的 XML」。你無法阻止對方系統犯錯,但你可以學會快速且誠實地說:「文件不符合契約」。

Schema 的最小範例

假設我們延續教學用的主控台專案(姑且稱它為 ExpenseBook)——一個用來累積支出並產生報表的應用程式。之前我們把資料放在集合裡,後來學會序列化成 JSON,現在我們想支援從 XML 匯入(因為某些會計系統很愛 XML,而說服它們通常沒什麼用)。

假設 XML 格式如下:

<expenses>
  <expense id="1" amount="199" category="food">Lunch</expense>
  <expense id="2" amount="350" category="transport">Taxi</expense>
</expenses>

我們希望:

  • 根元素是 expenses;
  • 內部包含 expense;
  • 每個 expense 都必須有 id(整數)與 amount(整數);
  • category 必填且為字串;
  • 元素內文字是描述(可為空,但元素要存在)。

XSD schema(最小化版本,不是「企業級怪獸」)可以長這樣:

val expensesXsd = """
    <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
      <xs:element name="expenses">
        <xs:complexType>
          <xs:sequence>
            <xs:element name="expense" maxOccurs="unbounded">
              <xs:complexType>
                <xs:simpleContent>
                  <xs:extension base="xs:string">
                    <xs:attribute name="id" type="xs:int" use="required"/>
                    <xs:attribute name="amount" type="xs:int" use="required"/>
                    <xs:attribute name="category" type="xs:string" use="required"/>
                  </xs:extension>
                </xs:simpleContent>
              </xs:complexType>
            </xs:element>
          </xs:sequence>
        </xs:complexType>
      </xs:element>
    </xs:schema>
""".trimIndent()

對,看起來很囉唆。這是 XML。它就是這樣。它對「冗長」這件事有專門的工會負責。

3. JVM 上的驗證:SchemaFactory → Validator

Pipeline:Schema → Validator → validate(...)

接下來進入最實用的部分:在 Kotlin/JVM 裡怎麼做驗證。

整體流程大致如下:

flowchart TD
    A["XML (String/File)"] --> B["StreamSource"]
    C["XSD (String/File)"] --> D["StreamSource"]
    D --> E["SchemaFactory.newSchema(...)"]
    E --> F["Schema"]
    F --> G["Validator"]
    B --> H["validate(xmlSource)"]
    G --> H
    H -->|OK| I["可以解析 DOM/XPath"]
    H -->|Exception| J["回報:不符合 XSD"]

關鍵點是:在最小的實作裡,如果文件不符合 schema,驗證會丟出例外。這是正常的:驗證是一種「硬性控管」,不符合時通常不是回傳 false,而是直接「中斷流程」。而我們可以用 try/catch 來處理它。

順帶一提,這種「做不到就丟例外」的風格,和 fail-fast 以及前置條件 (require/check)的概念很搭——你在前面關於例外的主題中應該已經看過。

從字串做驗證的最小實作

這裡我們寫一個「用 XSD 驗證 XML」的函式,回傳 Boolean。這不是唯一的設計,但對新手最友善:要嘛 true,要嘛 false,不需要太多哲學。

import java.io.StringReader
import javax.xml.XMLConstants
import javax.xml.transform.stream.StreamSource
import javax.xml.validation.SchemaFactory

fun validateXmlAgainstXsd(xml: String, xsd: String): Boolean {
    return try {
        val schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI)
            .newSchema(StreamSource(StringReader(xsd)))

        schema.newValidator().validate(StreamSource(StringReader(xml)))
        true
    } catch (e: Exception) {
        false
    }
}

請注意兩點。第一,我們使用 StringReader 把字串轉成「字元流」,再交給 StreamSource。第二,我們用很寬鬆的方式捕捉 Exception,因為在這種概覽性講解中,「理解概念」比把例外分類到極致更重要。

示範:有效的 XML 與「幾乎一樣但壞掉」的 XML

現在做一個小小的 main,驗證兩份文件:一份正確,另一份有型別錯誤(例如把 id="oops" 放進原本應該是數字的位置)。

fun main() {
    val xsd = """
        <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
          <xs:element name="user">
            <xs:complexType>
              <xs:sequence>
                <xs:element name="name" type="xs:string"/>
              </xs:sequence>
              <xs:attribute name="id" type="xs:int" use="required"/>
            </xs:complexType>
          </xs:element>
        </xs:schema>
    """.trimIndent()

    val okXml = """<user id="42"><name>Ann</name></user>"""
    val badXml = """<user id="oops"><name>Ann</name></user>"""

    println(validateXmlAgainstXsd(okXml, xsd))  // true
    println(validateXmlAgainstXsd(badXml, xsd)) // false
}

這就是「最小且易懂」的例子:XSD 要求 id 必須是 xs:int,所以帶有 oops 的文件通不過 schema。

比 Boolean 更好一點:回傳錯誤訊息

當你在實際應用中使用驗證時,單純的 false 通常不夠。使用者(或你在除錯時)會想知道:「到底哪裡不對?」所以我們做一個小型的結果模型:

data class XsdValidationResult(
    val isValid: Boolean,
    val message: String?,
)

以及回傳帶訊息的函式:

import java.io.StringReader
import javax.xml.XMLConstants
import javax.xml.transform.stream.StreamSource
import javax.xml.validation.SchemaFactory

fun validateXmlAgainstXsdDetailed(xml: String, xsd: String): XsdValidationResult {
    return try {
        val schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI)
            .newSchema(StreamSource(StringReader(xsd)))

        schema.newValidator().validate(StreamSource(StringReader(xml)))
        XsdValidationResult(isValid = true, message = "OK: XML conforms to XSD")
    } catch (e: Exception) {
        XsdValidationResult(isValid = false, message = "XSD validation failed: ${e.message}")
    }
}

這裡做的事情一模一樣,但現在我們有機會把原因呈現給人看。例外訊息不一定很漂亮(XSD 驗證器不見得是「友善的心理師」),但用於診斷已經是巨大的進步。

4. 如何把 XSD 併入 XML pipeline

XSD 的最大實務價值,是把問題往 pipeline 的前面推。如果文件不符合 schema,我們不該嘗試去抓 //expense[@id='...']、生成報表,然後再驚訝為什麼取到空值。我們應該直接說:「文件不符合契約,後面不繼續」。

假設在之前的講解裡,我們已經有一個函式 parseExpensesFromXml(xml: String): List<Expense>,用 DOM/XPath 抽取資料。那麼匯入的「正確入口」可以長這樣(簡化版):

fun importExpensesXml(xml: String, xsd: String) {
    val validation = validateXmlAgainstXsdDetailed(xml, xsd)
    if (!validation.isValid) {
        println(validation.message) // XSD validation failed: ...
        return
    }

    println("XML structure is OK, you can parse DOM/XPath") // example output
}

注意這種風格:先做「守門檢查」並及早返回。這就是你在前置條件與檢查主題中看過的 guard clauses 思路,通常能讓程式更容易閱讀。

從檔案讀取 XML/XSD:同樣做法,只是來源不同

在真實世界裡,XSD 常常是獨立的檔案,和應用程式放在一起(或隨整合一起提供)。XML 也可能從檔案進來。整體概念不變:讀成文字,再交給同一套函式。

import java.io.File

fun main() {
    val xml = File("expenses.xml").readText()
    val xsd = File("expenses.xsd").readText()

    val result = validateXmlAgainstXsdDetailed(xml, xsd)
    println(result.isValid)          // true/false
    println(result.message ?: "-")   // message or "-"
}

這裡要記住一個老但實用的習慣:如果某段程式是處理字串,那麼字串的來源(檔案、網路、stdin)可以替換,但驗證邏輯本身不必改。

5. XSD 的邊界:它能檢查什麼、不能檢查什麼

很容易(尤其第一次驗證成功後)就開始以為:「太好了!現在 XSD 會保證資料都正確。」這裡其實有個陷阱。

XSD 非常擅長檢查結構與型別:元素是否存在、屬性是否存在、是不是數字、是不是字串、元素是否按需要重複。但 XSD 通常不會替你解決商業規則。

例如,XSD 可以說:「amount 是 int」,但不一定會保證「amount > 0」,除非你明確在 schema 裡描述(而在 XSD 中這可能沒有你想像的那麼直覺)。它不會檢查 id 在所有 <expense> 之間是否唯一。它也不會知道類別必須來自某個固定集合,除非你把集合寫進 schema。更不可能理解「taxi 不該每分鐘付款 37 次」……雖然有時候我們真的希望驗證器能介入生活,阻止一些可疑決策。

因此,一個好的「心智模型」是:XSD 是用來擋掉粗糙的結構性錯誤的輸入濾網,而商業驗證則是你的 Kotlin 程式碼的下一層。

為什麼這個工具被標註為 optional

XSD 是有用的工具,但不是萬用解。實務上很多系統在沒有 schema 的情況下,靠「約定」使用 XML;也有些系統甚至處在「來了什麼就用正則拆」的模式(是的,也會發生;是的,很可怕)。此外,XSD 可能很大且維護成本高:格式一變,schema 也要改,還要協調版本。

所以今天的目標不是教你寫巨大無比的 XSD 文件,而是讓你理解「依 schema 驗證」這個機制,並在你已經有 schema 時(或你想為匯入固定一個小契約時)能做最小可用的一致性檢查。

6. 使用 XSD 驗證時的典型錯誤

錯誤 1:混淆 well-formed 與 valid。
新手常會以為:「解析器沒掛掉,表示 XSD 也會過。」其實這是不同層級。XML 可以完全 well-formed(標籤都關閉),但依 schema 仍然不 valid(例如缺少必填屬性)。反過來說,如果 XML 連 well-formed 都不是,你根本走不到 XSD 這一步——解析器會更早阻止你。

錯誤 2:以為 validate(...) 會回傳 false,而不是丟例外。
Java API 的驗證設計是以例外為主:「不符合」就代表驗證流程失敗,會用例外訊號通知。因此,在 validate(...) 周圍寫 try/catch 不是裝飾,而是正常的工作情境。

錯誤 3:吞掉例外,導致失去原因。
如果你寫 catch (e: Exception) { return false },你就失去了診斷資訊。開發階段最好回傳訊息,或至少印出 e.message,否則你會像關掉螢幕找 bug 一樣「盲修格式」。

錯誤 4:把 XSD 當成商業驗證的替代品。
XSD 很擅長檢查結構,但不必負責檢查語意。就算你用了 xs:int,XSD 也不會保證這個數字對你的領域是「合理」的。因此,XSD 之後通常仍需要 Kotlin 的檢查:範圍、允許值、唯一性、欄位一致性。做這些檢查時,很適合使用 fail-fast 與前置條件(require、check),在資料不正確時就不讓程式繼續。

錯誤 5:想「快速」寫 XSD,卻不小心固定了錯誤的契約。
有時 schema 是靠「目測」寫出來的,然後就變成格式的官方文件,但裡面其實已經藏了錯誤:必填性不對、型別不對、巢狀結構不對。接著就會出現悖論:XML 在語意上是對的,但在 schema 上是錯的,然後整個團隊開始爭論到底是 XML 作者的錯還是 XSD 作者的錯。這時需要紀律:schema 是契約,應該像對待 API 一樣嚴肅對待。

錯誤 6:驗證做得太晚(在 DOM/XPath 之後)。
如果你先解析成 DOM、抽出片段、建立中間結構,然後才想到「對了,我們來驗證一下 XSD」——你就失去最大收益。依 schema 的檢查應該是早期步驟:輸入資料要嘛符合契約,要嘛後面就不走了。

1
任務
Kotlin SELF, 等級 50, 課堂 3
上鎖
通行檢查
通行檢查
1
任務
Kotlin SELF, 等級 50, 課堂 3
上鎖
驗證報告
驗證報告
1
任務
Kotlin SELF, 等級 50, 課堂 3
上鎖
依路徑驗證
依路徑驗證
1
任務
Kotlin SELF, 等級 50, 課堂 3
上鎖
先驗證架構
先驗證架構
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION