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 的檢查應該是早期步驟:輸入資料要嘛符合契約,要嘛後面就不走了。
GO TO FULL VERSION