java jdk 7 学习笔记


Java 学习笔记 林信良 编著 北 京 JDK 7 内 容 简 介 本书是作者多年来教学实践经验的总结,汇集了教学过程中学生在学习 Java 时遇到的 概念、操作、应用或认证考试等问题及解决方案。 本书针对 Java SE 7 新功能全面改版,无论是章节架构或范例程序代码,都做了重新编 写与全面翻新。并详细介绍了 JVM、JRE、Java SE API、JDK 与 IDE 之间的对照关系。必 要时从 Java SE API 的源代码分析,了解各种语法在 Java SE API 中如何应用。对于建议练 习的范例提供 Lab 文档,以突出练习重点。此外,本书还将 IDE 操作纳为教学内容之一, 让读者能与实践相结合,提供的教学视频让读者可以更清楚地掌握操作步骤。 本书适合 Java 的初中级读者,以及广大 Java 应用开发人员。 本书封面贴有清华大学出版社防伪标签,无标签者不得销售。 版权所有,侵权必究。侵权举报电话:010-62782989 13501256678 13801310933 图书在版编目(CIP)数据 Java JDK 7 学习笔记/林信良 编著. —北京:清华大学出版社,2012.4 ISBN 7-302-28208-2 Ⅰ.①J… Ⅱ.②林… Ⅲ.①JAVA 语言—程序设计 Ⅳ.①TP312 中国版本图书馆 CIP 数据核字(2012)第 035963 号 责任编辑:王 定 封面设计:久久度文化 版式设计:康 博 责任校对: 责任印制: 出版发行:清华大学出版社 网 址:http://www.tup.com.cn,http://www.wqbook.com 地 址:北京清华大学学研大厦 A 座 邮 编:100084 社 总 机:010-62770175 邮 购:010-62786544 投稿与读者服务:010-62776969, c-service@tup.tsinghua.edu.cn 质量反馈:010-62772015, zhiliang@tup.tsinghua.edu.cn 课件下载:http://www.tup.com.cn,010-62796865 印 刷 者: 装 订 者: 经 销:全国新华书店 开 本:185×260 印 张: 字 数: 千字 版 次:2012 年 4 月第 1 版 印 次:2012 年 4 月第 1 次印刷 印 数:1~ 定 价:59.90 元 ————————————————————————————————————————————— 产品编号: 导 读 这份导读让你可以更了解如何使用本书。 字型 本书正文中与程序代码相关的文字,都用固定大小字体来加以呈现,以与一般名词 相区别。例如,JDK 是一般名词,而 String 为程序代码相关文字,使用了固定大小 字体。 程序范例 本书许多的范例都使用完整程序操作来展现,当看到以下程序代码示范时: ClassObject Guess.java package cc.openhome; import java.util.Scanner; public class Guess { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); int number = (int) (Math.random() * 10); int guess; do { System.out.print("猜数字(0 ~ 9):"); guess = scanner.nextInt(); } while(guess != number); System.out.println("猜中了...XD"); } } 范例开始的左边名称为 ClassObject,表示可以在书附光盘的 samples 文件夹的各 章节文件夹中找到对应的 ClassObject 项目,而右边名称为 Guess.java,表示可以在 项目中找到 Guess.java 文件。如果程序代码中出现标号与提示文字,表示后续的正文 中,会有对应于标号及提示的更详细说明。 原则上,建议每个项目范例都亲手动作撰写,但如果由于教学时间或操作时 间上的考虑,本书有建议进行的练习。如果在范例开始前有个 图标,例如:  建立 Scanner 实例  取得下一个整数  告诉编译程序接下来想偷懒 IV Game1 SwordsMan.java package cc.openhome; public class SwordsMan extends Role { public void fight() { System.out.println("挥剑攻击"); } } 表示建议范例动手操作,而且在书附光盘的 labs 文件夹中会有练习项目的基 础,可以打开项目后,完成项目中遗漏或必须补齐的程序代码或设定。 如果使用以下的程序代码呈现,表示它是一个完整的程序内容,但不是项目 的一部分,主要用来展现一个完整文档如何撰写: public class Hello { public static void main(String[] args) { System.out.println("Hello!World!"); } } 如果使用以下的程序代码,则表示它是个代码段,主要展现程序撰写时需要 特别注意的片段: SwordsMan swordsMan = new SwordsMan(); ... System.out.printf("剑士 (%s, %d, %d)%n", swordsMan.getName(), swordsMan.getLevel(), swordsMan.getBlood()); Magician magician = new Magician(); ... System.out.printf("魔法师 (%s, %d, %d)%n", magician.getName(), magician.getLevel(), magician.getBlood()); 操作步骤 本书将 IDE 进行设定的相关操作步骤也作为练习的一部分,你会看到如下的 操作步骤说明: (1) 选择“文件”|“新建项目”命令,在弹出的“新建项目”对话框的“类别” 列表中选择 Java,在“项目”列表中选择“Java 应用程序”,接着单击“下一步” 按钮。 (2) 在“项目名称”文本框中输入项目名称 Hello2,在“项目位置”文本框中 输入 C:\workspace。注意,“项目文件夹”会储存至 C:\workspace\Hello2。 (3) 在“创建主类”文本框中输入 cc.openhome.Main,这表示会有个 Main 类放在 cc.openhome 包,当中会自动建立 main()程序进入点的方法,接着单击“完 成”按钮建立项目。 如果操作步骤旁有个 图标,表示书附光盘的 videos 文件夹中对应的章节文 件夹有操作步骤的视频,可观看它以更了解实际操作过程。 本书针对 JDK7 全新改版,如果发现页侧有 图标,表示是 JDK7 的新功能, 本书亦提供有 JDK7 新功能快速查询目录。 前 言 V 提示框 在本书中会出现以下提示框: 针对课程中所提到的观点,提供一些额外的资源或思考方向,暂时忽略这些提示对课 程进行并没有影响,但有时间的话,针对这些提示做阅读、思考或讨论是有帮助的。 针对课程中所提到的观点,以提示框方式特别呈现出必须注意的一些使用方式、陷阱 或避开问题的方法,看到这个提示框时请集中精神阅读。 附录 书附光盘包括本书中所有范例,提供 NetBeans 范例项目,附录 A 说明如何 使用这些范例项目,本书也说明了如何使用 JDBC 操作数据库,操作范例时使用 的数据库为 MySQL,附录 B 包括 MySQL 的入门简介。 关于认证 本书涵盖了 Oracle Certified Professional, Java SE Programmer 考试范围, 也就是原 Sun Certified Java Programmer(SCJP),不过 11.2 节之后不在考试范 围,而是为了 Java SE 相关技术范围完整性而做介绍。 关于 Java 认证介绍,建议直接参考 Oracle University 网站上的认证介绍: http://education.oracle.com/pls/web_prod-plq-dad/db_pages.getpage?page_id =140 每章最后都会有“重点复习”,为针对该章的重要提示,可作为考前复习时 使用。 联系作者 若有本书堪误反馈等相关书籍问题,可通过网站与作者联系。网址如下: http://openhome.cc 目 录 Chapter1 Java 平台概论 ....................... 1 1.1 Java 不只是语言 .......................... 2 1.1.1 前世今生 ................................... 2 1.1.2 三大平台 ................................... 5 1.1.3 JCP 与 JSR ................................ 6 1.1.4 建议的学习路径 ....................... 7 1.2 JVM/JRE/JDK ............................ 11 1.2.1 什么是 JVM ............................ 11 1.2.2 区分 JRE 与 JDK .................... 14 1.2.3 下载、安装 JDK ..................... 15 1.2.4 认识 JDK 安装内容 ................ 18 1.3 重点复习 .................................... 19 1.4 课后练习 .................................... 20 Chapter2 从 JDK 到 IDE ..................... 21 2.1 从 Hello World 开始 .................. 22 2.1.1 撰写 Java 原始码 .................... 22 2.1.2 PATH 是什么 .......................... 24 2.1.3 JVM(java)与 CLASSPATH .... 27 2.1.4 编译程序(javac)与 CLASSPATH .......................... 30 2.2 管理原始码与位码文档 ............ 31 2.2.1 编译程序(javac)与 SOURCEPATH ................................... 31 2.2.2 使用 package 管理类 .............. 33 2.2.3 使用 import 偷懒 .................... 36 2.3 使用 IDE ..................................... 38 2.3.1 IDE 项目管理基础 ................. 38 2.3.2 使用了哪个 JRE ..................... 43 2.3.3 类文档版本 ............................. 45 2.4 重点复习 .................................... 48 2.5 课后练习 .................................... 49 Chapter3 基础语法 .............................. 53 3.1 类型、变量与运算符 ................ 54 3.1.1 类型 ......................................... 54 3.1.2 变量 .........................................57 3.1.3 运算符 .....................................60 3.1.4 类型转换 .................................66 3.2 流程控制 .................................... 69 3.2.1 if...else 条件式 .........................69 3.2.2 switch 条件式 ..........................72 3.2.3 for 循环 ...................................74 3.2.4 while 循环 ...............................75 3.2.5 break、continue .......................77 3.3 重点复习 .................................... 78 3.4 课后练习 .................................... 79 Chapter4 认识对象 .............................. 83 4.1 类与对象 .................................... 84 4.1.1 定义类 .....................................84 4.1.2 使用标准类 .............................87 4.1.3 对象指定与相等性 .................90 4.2 基本类型打包器 ........................ 91 4.2.1 打包基本类型 .........................91 4.2.2 自动装箱、拆箱 .....................92 4.2.3 装箱的内幕 .............................93 4.3 数组对象 .................................... 96 4.3.1 数组基础 .................................96 4.3.2 操作数组对象 .........................99 4.3.3 数组复制 .............................. 105 4.4 字符串对象 .............................. 108 4.4.1 字符串基础 .......................... 108 4.4.2 字符串特性 .......................... 111 4.4.3 字符串编码 .......................... 115 4.5 查询 Java API 文件 .................. 117 4.6 重点复习 .................................. 119 4.7 课后练习 .................................. 120 Chapter5 对象封装 ............................ 125 5.1 何谓封装 .................................. 126 5.1.1 封装对象初始流程 .............. 126 VIII 5.1.2 封装对象操作流程 .............. 128 5.1.3 封装对象内部数据 .............. 131 5.2 类语法细节 .............................. 134 5.2.1 public 权限修饰 ................... 134 5.2.2 关于构造函数 ...................... 136 5.2.3 构造函数与方法重载 .......... 137 5.2.4 使用 this ............................... 139 5.2.5 static 类成员 ......................... 142 5.2.6 不定长度自变量 .................. 148 5.2.7 内部类 .................................. 150 5.2.8 传值调用 .............................. 151 5.3 重点复习 .................................. 154 5.4 课后练习 .................................. 155 Chapter6 继承与多态 ........................ 161 6.1 何谓继承 .................................. 162 6.1.1 继承共同行为 ...................... 162 6.1.2 多态与 is-a ........................... 166 6.1.3 重新定义行为 ...................... 170 6.1.4 抽象方法、抽象类 .............. 173 6.2 继承语法细节 .......................... 174 6.2.1 protected 成员 ...................... 174 6.2.2 重新定义的细节 .................. 176 6.2.3 再看构造函数 ...................... 178 6.2.4 再看 final 关键字 ................. 180 6.2.5 java.lang.Object .................... 181 6.2.6 关于垃圾收集 ...................... 186 6.2.7 再看抽象类 .......................... 189 6.3 重点复习 .................................. 191 6.4 课后练习 .................................. 192 Chapter7 接口与多态 ........................ 199 7.1 何谓接口 .................................. 200 7.1.1 接口定义行为 ...................... 200 7.1.2 行为的多态 .......................... 204 7.1.3 解决需求变化 ...................... 206 7.2 接口语法细节 .......................... 213 7.2.1 接口的默认 .......................... 213 7.2.2 匿名内部类 .......................... 217 7.2.3 使用 enum 枚举常数 ........... 221 7.3 重点复习 .................................. 224 7.4 课后练习 .................................. 224 Chapter8 异常处理 ............................ 231 8.1 语法与继承架构 ...................... 232 8.1.1 使用 try、catch .................... 232 8.1.2 异常继承架构 ...................... 235 8.1.3 要抓还是要抛 ...................... 238 8.1.4 认识堆栈追踪 ...................... 241 8.1.5 关于 assert ............................ 245 8.2 异常与资源管理 ...................... 247 8.2.1 使用 finally ........................... 247 8.2.2 自动尝试关闭资源 .............. 249 8.2.3 java.lang.AutoCloseable 接口 ...................................... 251 8.3 重点复习 .................................. 255 8.4 课后练习 .................................. 256 Chapter8 Collection 与 Map .............. 261 9.1 使用 Collection 收集对象 ........ 262 9.1.1 认识 Collection 架构 ............ 262 9.1.2 具有索引的 List ................... 263 9.1.3 内容不重复的 Set ................ 266 9.1.4 支持队列操作的 Queue ....... 270 9.1.5 访问对象的 Iterator .............. 273 9.1.6 排序收集的对象 .................. 276 9.1.7 使用泛型 .............................. 280 9.2 键值对应的 Map ...................... 284 9.2.1 常用 Map 操作类 ................. 284 9.2.2 访问 Map 键值 ..................... 288 9.3 重点复习 .................................. 291 9.4 课后练习 .................................. 292 Chapter10 输入输出 .......................... 299 10.1 InputStream 与 OutputStream .......................... 300 10.1.1 串流设计的概念 .............. 300 10.1.2 串流继承架构 .................. 303 10.1.3 串流处理装饰器 .............. 306 10.2 字符处理类 ............................ 311 IX 目 录 10.2.1 Reader 与 Writer 继承 架构 ................................. 311 10.2.2 字符处理装饰器 .............. 313 10.3 重点复习 ................................ 315 10.4 课后练习 ................................ 316 10.4.1 选择题 ............................. 316 10.4.2 操作题 ............................. 317 Chapter11 线程与并行 API ............... 319 11.1 线程 ........................................ 320 11.1.1 线程简介.......................... 320 11.1.2 Thread 与 Runnable ......... 323 11.1.3 线程生命周期 .................. 324 11.1.4 关于 ThreadGroup ........... 331 11.1.5 synchronized 与 volatile... 334 11.1.6 等待与通知 ...................... 345 11.2 并行 API ................................. 349 11.2.1 Lock、ReadWriteLock 与 Condition .......................... 349 11.2.2 使用 Executor .................. 357 11.2.3 并行 Collection 简介 ....... 370 11.3 重点复习 ................................ 373 11.4 课后练习 ................................ 375 Chapter12 通用 API .......................... 377 12.1 日志 ........................................ 378 12.1.1 日志 API 简介 ................. 378 12.1.2 指定日志层级 .................. 380 12.1.3 使用 Handler 与 Formatter .................................................. 382 12.1.4 自定义 Handler、Formatter 与 Filter ................................ 383 12.1.5 使用 logging.properties .... 385 12.2 国际化基础、日期 ................ 387 12.2.1 关于 i18n ......................... 387 12.2.2 使用 Date 与 DateFormat .. 390 12.2.3 使用 Calendar .................. 393 12.3 规则表示式 ............................ 395 12.3.1 定义规则表示式 .............. 396 12.3.2 Pattern 与 Matcher ........... 403 12.4 NIO2 文件系统 ...................... 405 12.4.1 API 架构概述 .................. 405 12.4.2 操作路径 .......................... 406 12.4.3 属性读取与设定 .............. 409 12.4.4 操作文档与目录 .............. 412 12.4.5 读取、访问目录 .............. 414 12.4.6 过滤、搜索文档 .............. 418 12.5 重点复习 ................................ 421 12.6 课后练习 ................................ 422 Chapter12 窗口程序设计 .................. 425 13.1 Swing 入门 ............................. 426 13.1.1 简易需求分析 .................. 426 13.1.2 Swing 组件简介 ............... 427 13.1.3 设计主窗口与菜单列 ...... 429 13.1.4 关于版面管理 .................. 433 13.1.5 事件处理 .......................... 436 13.2 文档打开、存储与编辑 ........ 442 13.2.1 操作打开文档 .................. 442 13.2.2 制作存储、关闭文档 ...... 445 13.2.3 文字区编辑、剪切、复制、 粘贴 .................................. 448 13.3 重点复习 ................................ 449 13.4 课后练习 ................................ 451 Chapter14 整合数据库 ...................... 444 14.1 JDBC 入门 .............................. 454 14.1.1 JDBC 简介 ....................... 454 14.1.2 连接数据库 ...................... 458 14.1.3 使用 Statement、 ResultSet .......................... 464 14.1.4 使用 PreparedStatement、 CallableStatement ............ 469 14.2 JDBC 进阶 .............................. 472 14.2.1 使用 DataSource 取得 联机 ................................. 472 14.2.2 使用 ResultSet 卷动、 更新数据 ......................... 476 14.2.3 批次更新 .......................... 479 14.2.4 Blob 与 Clob .................... 480 X 14.2.5 交易简介.......................... 481 14.2.6 metadata 简介 .................. 489 14.2.7 RowSet 简介 .................... 492 14.3 重点复习 ................................ 496 14.4 课后练习 ................................ 497 Chapter15 反射与类加载器 ............... 499 15.1 运用反射 ................................ 500 15.1.1 Class 与.class 文档 .......... 500 15.1.2 使用 Class.forName() ...... 502 15.1.3 从 Class 获得信息 ........... 503 15.1.4 从 Class 建立对象 ........... 506 15.1.5 操作对象方法与成员 ...... 509 15.1.6 动态代理.......................... 512 15.2 了解类加载器 ........................ 515 15.2.1 类加载器层级架构 .......... 515 15.2.2 建立 ClassLoader 实例 .... 518 15.3 重点复习 ................................ 520 15.4 课后练习 ................................ 521 Chapter16 自定义泛型、枚举与标注 523 16.1 自定义泛型 ............................ 524 16.1.1 定义泛型方法 .................. 524 16.1.2 使用 extends 与? .............. 525 16.1.3 使用 super 与? ................. 530 16.2 自定义枚举 ............................ 533 16.2.1 了解 java.lang.Enum 类 ..... 533 16.3 关于注释 ................................ 542 16.3.1 常用标准注释 .................. 542 16.3.2 自定义注释类型 .............. 545 16.3.3 执行时期读取注释信息 .. 549 16.4 重点复习 ................................ 551 16.5 课后练习 ................................ 551 AppendixA 如何使用本书项目 .......... 553 A.1 项目环境配置.......................... 554 A.2 打开案例 ................................. 554 AppendixB MySQL 入门 ................... 557 B.1 安装、设定 MySQL ................ 558 B.2 MySQL 的数据类型 ................ 560 B.3 建立数据库、数据表 .............. 561 B.4 进行 CRUD 操作 ..................... 562 学习目标  了解与设定 PATH  了解与指定 CLASSPATH  了解与指定 SOURCEPATH  使用 package 与 import 管理类别  初步认识 JDK 与 IDE 的对应关系 2 从 JDK 到 IDE 22 2.1 从 Hello World 开始 第一个 Hello World 的出现是在 Brian Kernighan 写的 A Tutorial Introduction to the Language B 一书中(B 语言是 C 语言的前身),用来将 Hello World 文字显示在计算机屏幕 上,自此之后,很多的程序语言教学文件或书籍上,已经无数次地将它当作第一个范例程 序。为什么要用 Hello World 来当作第一个程序范例?因为它很简单,初学者只要输入简 单几行程序(甚至一行),可以要求计算机执行指令并得到反馈:显示 Hello World。 本书也要从显示 Hello World 开始,然而,在完成这个简单的程序之后,千万要记得, 探索这个简单程序之后的种种细节。千万别过于乐观地以为,你想从事的程序设计工作就 是如此容易驾驭。 2.1.1 撰写 Java 原始码 在正式撰写程序之前,请先确定你可以看到文档的扩展名。在 Windows 下默认不显示 扩展名,这会造成重新命名文档时的困扰,如果目前在“资源管理器”下无法看到扩展名, 在 Windows XP 中请先执行工具栏上的“工具”|“文件夹选项”,在 Windows 7 下请执 行“组织”|“文件夹和搜索选项”,并切换至“查看”选项卡,取消选择“隐藏已知文件 类型的扩展名”复选框,如图 2.1 所示。 图 2.1 取消选择“隐藏已知文件类型的扩展名”复选框 从 JDK 到 IDE 2 1 23 接着选择一个文件夹来撰写 Java 原始码文档。本书都是在 C:\workspace 文件夹中撰 写程序,请新创建一个“文本文件”(也就是.txt 文件),并重新命名文件为 HelloWorld.java。 由于将文字文件的扩展名从.txt 改为.java,系统会询问是否更改扩展名,请确定更改,接 着在 HelloWorld.java 上右击,从弹出的快捷菜单中选择“编辑”命令,并撰写程序,如图 2.2 所示。 图 2.2 第一个 Java 程序 Windows 中内建的记事本编辑器并不是很好用,建议可以使用 NotePad++: http://notepad-plus-plus.org/ 这个文档撰写时有几点必须注意:  扩展名是 .java:这也就是你必须让“资源管理器”显示扩展名的原因。  主文档名与类名称必须相同。类名称是指 class 关键词(Keyword)后的名称,这个 范例就是 HelloWorld 这个名称,这个名称必须与 HelloWorld.java 的主文档名 (HelloWorld)相同。  注意每个字母大小写。Java 程序区分字母大小写,System 与 system 对 Java 程序 来说是不同的名称。  空格只能是半角空格符或 Tab 字符:有些初学者可能不小心输入了全角空格符, 这很不容易检查出来。 老实说,要对新手解释第一个 Java 程序并不容易,这个简单的程序就涉及文档管理、 类(Class)定义、程序进入点、命令行自变量(Command line argument)等概念。以下先针 对这个范例做基本说明。 1. 定义类 class 是用来定义类的关键词,之后接上类名称(HelloWorld)。Java 程序规定,所有程序 代码都要定义在“类”中。class 前有个 public 关键词,表示 HelloWorld 类是公开类,就 目前为止你只要知道,一个.java 文档可定义多个类,但是只能有一个公开类,而且主文档 名必须与公开类名称相同。 24 2. 定义区块(Block) 在程序中使用大括号“{”与“}”定义区块,大括号两两成对,目的在于区别程序代 码范围。例如,程序中 HelloWorld 类的区块包括了 main()方法(Method),而 main()方法的 区块包括了一句显示信息的程序代码。 3. 定义 main()方法 程序执行的起点就是程序进入点(Entry point),Java 程序执行的起点是 main()方法。 规格书中规定 main()方法的形式一定得是: public static void main(String[] args) 虽然说是规格书中的规定,不过其实日后你理解每个关键词的意义,还是可以就每个元素 加以解释。main()方法是 public 成员,表示可以被 JVM 公开执行,static 表示 JVM 不 用生成类实例就可以调用,Java 程序执行过程的错误,都是以例外方式处理,所以 main() 不用传回值,声明为 void 即可,String[] args 可以在执行程序时,取得用户指定的命 令行自变量。 4. 撰写描述(Statement) 来看 main()中的一行描述: System.out.println("Hello World"); 描述是程序语言中的一行指令,简单地说,就是程序语言中的“一句话”。注意每句 描述的结束要用分号(;),这句描述的作用,就是请系统的输出装置显示一行文字 Hello World。 其实你使用了 java.lang 包(package)中 System 类的 public static 成员 out,out 参考至 PrintStream 实例,你使用 PrintStream 定义的 println()方法,将指定的字符串(String) 输出至文本模式上,println()表示输出字符串后换行,如果使用 print(),输出字符串 后不会换行。 其实我真正想说的是:一个基本的 Java 程序这么写就对了。一下子要接受如此多概 念确实不容易,如果现阶段无法了解,就先当这些是 Java 语法规范,相关元素在本书之 后各章节还会详细解释,届时自然就会了解第一个 Java 程序是怎么一回事了。 2.1.2 PATH 是什么 第 1 章谈过,*.java 必须编译为*.class,才可以在 JVM 中执行,Java 的编译程序工 具程序是 javac。装好 JDK 之后,工具程序就会放在 JDK 安装文件夹的 bin 文件夹中,你 必须按照第 1 章打开“命令提示符”模式,切换至 C:\workspace,并执行 javac 指令,如 图 2.3 所示。 从 JDK 到 IDE 2 1 25 失败了?为什么?这是操作系统 Windows 在跟你抱怨,它找不到 javac 放在哪里!当 要执行一个工具程序,那个指令放在哪,系统默认是不晓得的,除非你跟系统说工具程序 存放的位置,如图 2.4 所示。 图 2.3 喔喔!执行失败 图 2.4 指定工具程序位置 javac 编译成功后会静悄悄地结束,所以没看到信息就是好消息,但是这样下指令实 在太麻烦了,而且你会有疑问:第 1 章安装 JDK 最后示范执行 java 指令时,为什么不用 指定位置? 当你输入一个指令而没有指定路径信息时,操作系统会依照 PATH 环境变量中设定的 路径顺序,依次寻找各路径下是否有这个指令。可以执行 echo %PATH%来看看目前系统 PATH 环境变量中包括哪些路径信息,如图 2.5 所示。 图 2.5 查看 PATH 信息 根据图 2.5 中的 PATH 信息,如果输入 java 指令,系统会从第一个路径开始找有无 java(.exe)工具程序,如果没有,再找下一个路径有无 java(.exe)工具程序……找到的话就 执行。若查看 C:\Windows\system32,会发现其中确实有 java(.exe),这是因为安装 JDK(JRE) 时,Windows 的 JDK(JRE) 安装程序会自动放一份 java(.exe) 到 C:\Windows\system32,这就是为何第 1 章安装 JDK(JRE)后,就可以直接执行 java 指令 的原因。 然而按照图 2.5 中的 PATH 信息,如果输入 javac 指令,系统找完 PATH 中所有路径 后,都不会找到 javac (.exe)工具程序,当所有路径都找不到指定的工具程序时,就会出现 图 2.3 所示的错误信息。 你要在 PATH 中设定工具程序的路径信息,系统才可以在 PATH 中找到你要执行的指 令。如果要设定 PATH,可以使用 SET 指令来设定,设定方式为 SET PATH=路径,如图 2.6 所示。 26 设定时若有多个路径,会使用分号(;)作分隔,通常会将原有 PATH 附加在设定值后面, 这样寻找其他指令时,才可以利用原有的 PATH 信息。设定完成之后,就可以执行 javac 而不用额外指定路径。 图 2.6 设定 PATH 环境变量 不过在“命令提示符”模式中设定,关掉这个“命令提示符”模式后,下次要开启“命 令提示符”模式又要重新设定。为了方便,可以在“用户环境变量”或“系统环境变量” 中设定 PATH。在 Windows XP 中可以右击桌面上的“我的电脑”,在弹出的快捷菜单中 选择“属性”命令。在 Windows 7 中可以右击“计算机”,在弹出的快捷菜单中选择“属 性”命令,在打开的窗口中单击“高级系统设置”,进入“系统属性”对话框,接着切换 至“高级”选项卡,单击“环境变量”按钮,在“环境变量”对话框的“USER 的用户变 量”或“系统变量”列表中编辑 PATH 变量,如图 2.7 所示。 图 2.7 设定用户变量或系统变量 在一个可以允许多人共享的系统中,系统环境变量的设定,会套用至每个登录的用户, 而用户环境变量只影响个别用户。开启一个“命令提示符”模式时,获得的环境变量会是 系统环境变量再“附加”用户环境变量。如果使用 SET 指令设定环境变量,则以 SET 设 定的结果决定。 以设定系统变量为例,可在图 2.7 中选取 Path 变量后,单击“编辑”按钮,在“变量 值”文本框的最前方输入 JDK 的 bin 目录的路径(C:\Program Files\Java\jdk1.7.0\bin),然 后紧跟着一个分号,作为路径设定分隔,接着单击“确定”按钮完成设定,如图 2.8 所示。 从 JDK 到 IDE 2 1 27 图 2.8 编辑 Path 系统变量 建议将 JDK 的 bin 路径放在 Path 变量的最前方,是因为系统搜索 Path 路径时,会从 最前方开始,如果在路径下找到指定的工具程序就会直接执行。当系统中安装两个以上 JDK 时,Path 路径中设定的顺序,将决定执行哪个 JDK 下的工具程序,在安装了多个 JDK 或 JRE 的计算机中,确定执行了哪个版本的 JDK 或 JRE 非常重要,确定 PATH 信息是一 定要做的动作。 由于打开“命令提示符”模式时获得的环境变量会是系统环境变量附加用户环境变量,若 系统环境变量 PATH 中已经设定好某个 JDK,即使你在用户环境变量 PATH 中将想要的 JDK 路径设在最前头,也会执行到系统环境变量 PATH 中设定的 JDK。如果你有足够权 限修改系统环境变量,建议修改系统环境变量 PATH。如果没有权限变更,那就使用 SET 指令,因为使用 SET 指令设定环境变量,会以 SET 设定的结果决定。 2.1.3 JVM(java)与 CLASSPATH 在图 2.6 中完成编译 HelloWorld.java 之后,相同文件夹下就会出现 HelloWorld.class。 第 1章说过,JVM 的可执行文件扩展名是.class,接下来要启动 JVM,要求 JVM 执行HelloWorld 指令,如图 2.9 所示。 图 2.9 第一个 Hello World 出现了 在图 2.9 中,启动 JVM 的指令是 java,而要求 JVM 执行 HelloWorld 时,只要指定类 名称(就像执行 javac.exe 工具程序,只要输入 javac 就可以了),不用附加.class 扩展名, 附加.class 反而会有错误信息。 接下来,请试着切换至 C:\,想想看,如何执行 HelloWorld?以下几个方式都是行不 通的,如图 2.10 所示。 28 图 2.10 怎么执行 HelloWorld 你要知道 java 这个指令是做什么用的?正如前面所讲,执行 java 指令目的在于启动 JVM,之后接着类名称,表示要求 JVM 执行指定的可执行文件(.class)。 在 2.1.2 中提过,实体操作系统下执行某个指令时,会根据 PATH 中的路径信息,试 图找到可执行文件(例如对 Windows 来说,就是.exe、.bat 扩展名的文档,对 Linux 等就 是有执行权限的文档)。 从第 1 章就一直强调,JVM 是 Java 程序唯一识别的操作系统,对 JVM 来说,可执行 文件就是扩展名为.class 的文档。想在 JVM 中执行某个可执行文件(.class),就要告诉 JVM 这个虚拟操作系统到哪些路径下寻找文档,方式是通过 CLASSPATH 指定其可执行文件 (.class)的路径信息。 用 Windows 与 JVM 做个简单的对照,就可以很清楚地对照 PATH 与 CLASSPATH, 如表 2.1 所示。 表 2.1 PATH 与 CLASSPATH 操 作 系 统 搜 索 路 径 可执行文件 Windows PATH .exe、.bat JVM CLASSPATH .class PATH 与 CLASSPATH 根本就是不同层次的环境变量,实际操作系统搜索可执行文件是 看 PATH,JVM 搜索可执行文件(.class)只看 CLASSPATH。 如何在启动 JVM 时告知可执行文件(.class)的位置?可以使用-classpath 自变量来指 定,如图 2.11 所示。 图 2.11 启动 JVM 时指定 CLASSPATH 从 JDK 到 IDE 2 1 29 图 2.11 中,-classpath 有个缩写形式-cp,这比较常用。如果有多个路径信息,则可 以用分号分隔。例如: java -cp C:\workspace;C:\classes HelloWorld JVM 会依 CLASSPATH 路径顺序,搜索是否有对应的类文档,先找到先载入。如果 在 JVM 的 CLASSPATH 路径信息中都找不到指定的类文档,JDK7 前的版本会出现 java.lang.NoClassDefFoundError 信息,而 JDK7 之后的版本会有比较友善的中文错误信息, 如图 2.10 所示。 为什么图 2.9 不用特别指定 CLASSPATH 呢?JVM 预设的 CLASSPATH 就是读取目前 文件夹中的.class,如果自行指定 CLASSPATH,则以你指定的为主,如图 2.12 所示。 图 2.12 指定的 CLASSPATH 中找不到类文档 图 2.12 中,虽然工作路径是在 C:\workspace(其中有 HelloWorld.class),你启动 JVM 时 指定到 C:\xyz 中搜索类文档,JVM 还是老实地到指定的 C:\xyz 中找寻,结果当然就是找 不到而显示错误信息。有的时候希望也从当前文件夹开始寻找类文档,则可以使用“.”指 定,如图 2.13 所示。 图 2.13 指定“.”表示搜索类文档时包括目前文件夹 如果使用 Java 开发了链接库,这些链接库中的类文档会封装为 JAR(Java Archive)文 档,也就是扩展名为.jar 的文档。JAR 文档实际使用 ZIP 格式压缩,当中包含一堆.class 文档,那么,如果你有个 JAR 文档,如何在 CLASSPATH 中设定? 答案是将 JAR 文档当作特别的文件夹,例如,有 abc.jar 与 xyz.jar 放在 C:\lib 下,执 行时若要使用 JAR 文档中的类文档,可以如下: java -cp C:\workspace;C:\lib\abc.jar;C:\lib\xyz.jar SomeApp 如果有些类路径经常使用,其实也可以通过环境变量设定。例如: SET CLASSPATH=C:\classes;C:\lib\abc.jar;C:\lib\xyz.jar 30 在启动 JVM 时,也就是执行 java 时,若没使用-cp 或-classpath 指定 CLASSPATH, 就会读取 CLASSPATH 环境变量。同样地,“命令提示符”模式中的设定在关闭该模式之 后就会失效。如果希望每次开启“命令提示符”模式都可以套用某个 CLASSPATH,也可 以设定在系统变量或用户变量中。如果执行时使用了-cp 或-classpath 指定 CLASSPATH, 则以-cp 或-classpath 的指定为主。 如果某个文件夹中有许多.jar 文档,从 Java SE 6 开始,可以使用“*”表示使用文件 夹中所有.jar 文档。例如,指定使用 C:\jars 下所有 JAR 文档: java –cp .;C:\jars\* cc.openhome.JNotePad Java SE 6 中 CLASSPATH 新的指定方式也适用在系统环境变量的设定上。 CLASSPATH 其实是给应用程序类加载器(AppClassLoader)使用的信息,想要了解类加 载方式,则要了解类加载器机制。这是高级议题,本书第 15 章才会谈到。 2.1.4 编译程序(javac)与 CLASSPATH 在光盘 labs/CH2 文件夹中有个 classes 文件夹,请将之复制至 C:\workspace 中,确认 C:\workspace\classes中有个已编译的Console.class。可以在C:\workspace中开个Main.java, 使用 Console 类,如图 2.14 所示。 图 2.14 使用已编译好的.class 文档 如果按照图 2.15 所示编译,将会出现错误信息。 图 2.15 找不到 Console 类的编译错误 编译程序在抱怨,它找不到 Console 类在哪里(找不到符号)。事实上,在使用 javac 编 译程序时,如果要使用到其他类链接库,也必须指定 CLASSPATH,告诉 javac 编译程序到 哪里寻找.class 文档,如图 2.16 所示。 从 JDK 到 IDE 2 1 31 图 2.16 编译成功,但执行时找不到 Console 类的错误 这一次编译成功了,但无法执行,原因是执行时找不到 Console 类。因为你执行时忘 了为 JVM 指定 CLASSPATH,所以 JVM 找不到 Console 类。如果按照图 2.17 执行就可以了。 图 2.17 找到 Console 与 Main 执行成功 别忘了,如果执行 JVM 时指定了 CLASSPATH,就只会在指定的 CLASSPATH 中寻 找使用到的类,所以图 2.17 指定 CLASSPATH 时,是指定.;classes,注意一开始的“.”, 这表示当前文件夹,这样才可以找到当前文件夹下的 Main.class 及 classes 下的 Console.class。 你知道吗?javac 等工具程序,大多也是 Java 撰写的,执行于 JVM 之上,这也就是为何 javac 需要相关.class 文档路径信息时,也是用 CLASSPATH 指定的原因。以下网址有进 一步探讨: http://caterpillar.onlyfun.net/Gossip/JavaEssence/ChickenOrEggFirst.html 2.2 管理原始码与位码文档 来观察一下目前你的 C:\workspace,原始码(.java)文档与位码文档(.class)都放在一 起,想象一下,如果程序规模稍大,一堆.java 与.class 文档还放在一起,会有多么混乱, 你需要有效率地管理原始码与位码文档。 2.2.1 编译程序(javac)与 SOURCEPATH 首先必须解决原始码文档与位码文档都放在一起的问题。请将光盘中 labs 文件夹的 Hello1 文件夹复制至 C:\workspace 中,Hello1 文件夹中有 src 与 classes 文件夹,src 文 件夹中有 Console.java 与 Main.java 两个文档,其中 Console.java 就是 2.1.4 节中 Console 类的原始码(目前你不用关心它如何撰写),而 Main.java 的内容与图 2.14 相同。 32 简单地说,src 文件夹将用来放置原始码文档,而编译好的位码文档,希望能指定存 放至 classes 文件夹。可以在“命令提示符”模式下,切换至 Hello1 文件夹,然后进行编 译,如图 2.18 所示。 图 2.18 指定-sourcepath 与-d 进行编译 在编译 src/Main.java 时,由于程序代码中要使用到 Console 类,你必须告诉编译程序, Console 类的原始码文档存放位置。这里使用-sourcepath 指定从 src 文件夹中寻找原始码文 档,而-d 指定了编译完成的位码存放文件夹,编译程序会将使用到的相关类原始码也一并 进行编译,编译完成后,会在 classes 文件夹中看到 Console.class 与 Main.class 文档。可 以执行图 2.19 所示的程序。 图 2.19 指定执行 classes 中的 Main 类 可以在编译时指定-verbose 自变量,看到编译程序进行编译时的过程,有助于了解 SOURCEPATH 与 CLASSPATH 的差别,如图 2.20 所示。 从 JDK 到 IDE 2 1 33 图 2.20 编译时指定-verbose 就初学者而言,最主要看看圈住的部分,在编译时,会先搜索-sourcepath 指定的文 件夹(上例指定 src)是不是有使用到的类原始码,然后会搜索 CLASSPATH 中,是否有 已编译的类位码。你可以发现,其实默认搜索位码的路径包括许多默认的 JAR 文档,像 是 rt.jar 等,留意最后那个“.”,由于没有指定-classpath(-cp),默认会搜索目前路径。 确认原始码与位码搜索路径之后,接着检查 CLASSPATH 中是否已经有编译完成的 Main 类,如果存在且从上次编译后,Main 类的原始码并没有改变,则无须重新编译,如果 不存在,则重新编译 Main 类。就上例而言,由于 CLASSPATH 并不包括 classes 文件夹, 所以找不到 Main 类位码,因此重新编译出 Main.class 并存放至 classes 中。 接着检查 CLASSPATH 中是否已经有编译完成的 Console 类,如果存在且从上次编译 后,Console 类的原始码并没有改变,则无须重新编译,如果不存在,则重新编译 Console 类。就上例而言,由于 CLASSPATH 并不包括 classes 文件夹,所以找不到 Console 类位 码,因此重新编译出 Console.class 并存放至 classes 中。 实际项目中会有数以万计的类,如果每次都要重新将.java 编译为.class,那会是非常 费时的工作,所以编译时若类路径中已存在位码,且上次编译后,原始码并没有修改,无 须重新编译会比较节省时间。因此,就上例而言,应该指定-cp 为 classes,如图 2.21 所示。 34 图 2.21 编译时指定-sourcepath 与-cp 注意,这次指定了-sourcepath 为 src,而-cp 为 classes,所以会在 src 中搜索位原始 码文档,在 classes 中搜索位码文档(注意最后的 classes)。由于 CLASSPATH 中包括 classes 文件夹,所以找到 Console 类位码,因此无须重新编译 Console.class,而只编译 javac 指定的 Main.java 为 Main.class。 JVM 默认的类搜索路径,也就是那些 JAR 文档的搜索路径,其实与类加载器有关,这是 个进阶议题,第 15 章会加以讨论。 2.2.2 使用 package 管理类 现在所撰写的类,.java 放在 src 文件夹中,编译出来的.class 放置在 classes 文件夹 下,就文档管理上比较好一些了,但还不是很好,就如同你会分不同文件夹来放置不同作 用的文档,类也应该分门别类加以放置。 举例来说,一个应用程序中会有多个类彼此合作,也有可能由多个团队共同分工,完 成应用程序的某些功能块,再组合在一起。如果你的应用程序是多个团队共同合作,若不 分门别类放置.class,那么若 A 部门写了个 Util 类并编译为 Util.class,B 部门写了个 Util 类并编译为 Util.class,当他们要将应用程序整合时,就会发生文档覆盖的问题,而如果现 在要统一管理原始码,也许原始码也会发生彼此覆盖的问题。 你要有个分门别类管理类的方式,无论实体文档上的分类管理,还是类名称上的分类 管理,在 Java 语法中,有个 package 关键词,可以协助你达到这个目的。 请用“记事本”打开 2.2.2 中 Hello1/src 文件夹中的 Console.java,在开头输入图 2.22 所示的反白文字。 从 JDK 到 IDE 2 1 35 图 2.22 将 Console 类放在 cc.openhome.util 类下 这表示,Console 类将放在 cc.openhome.util 类下,用 Java 的术语来说,Console 这个 类将放在 cc.openhome.util 包(package)。 请再用“记事本”打开 2.2.2 中 Hello1/src 文件夹中的 Main.java,在开头输入图 2.23 所示的反白文字,这表示 Console 类将放在 cc.openhome 类下。 图 2.23 将 Main 类放在 cc.openhome 类下 包通常会用组织或单位的网址命名。举例来说,我的网址是 openhome.cc,包就会反过 来命名为 cc.openhome,由于组织或单位的网址是独一无二的,这样的命名方式,比较 不会与其他组织或单位的包名称发生同名冲突。 当类原始码开始使用 package 进行分类时,就会具有 4 种管理上的意义:  原始码文档要放置在与 package 所定义名称层级相同的文件夹层级中。  package 所定义名称与 class 所定义名称,会结合而成类的完全吻合名称(Fully qualified name)。  位码文档要放置在与 package 所定义名称层级相同的文件夹层级中。  要在包间可以直接使用的类或方法(Method)必须声明为 public。 关于第 4 点,牵涉包间的权限管理,将在 5.2.1 节介绍,本章先不予讨论,以下针对 前三点分别做说明。 1. 原始码文档与包管理 目前计划将所有原始码文档放在 src 文件夹中管理,由于 Console 类使用 package 定义 在 cc.openhome.util 包下,所以 Console.java 必须放在 src 文件夹中的 cc/openhome/util 文件夹,在没有工具辅助下,必须手动建立出文件夹。Main 类使用 package 定义在 cc.openhome 包下,所以 Main.java 必须放在 src 文件夹的 cc/openhome 文件夹中。 这么做的好处很明显,日后若不同组织或单位的原始码要放置在一起管理,就不容易 发生原始码文档彼此覆盖的问题。 36 2. 完全吻合名称 由于 Main 类是位于 cc.openhome 包分类中,其完全吻合名称是 cc.openhome.Main,而 Console 类是位于 cc.openhome.util 分类中,其完全吻合名称为 cc.openhome.util.Console。 在原始码中指定使用某个类时,如果是相同包中的类,只要使用 class 所定义的名称 即可,而不同包的类,必须使用完全吻合名称。由于 Main 与 Console 类是位于不同的包中, 在 Main 类中要使用 Console 类,就必须使用 cc.openhome.util.Console。也就是说,Main.java 现在必须修改,如图 2.24 所示。 图 2.24 使用完全吻合名称 这么做的好处在于,若另一个组织或单位也使用 class 定义了 Console,但其包定义为 com.abc,则其完全吻合名称为 com.abc.Console,也就不会与你的 cc.openhome.util.Console 发生名称冲突问题。 3. 位码文档与包管理 目前计划将所有位码文档放在 class 文件夹中管理,由于 Console 类使用 package 定义 在 cc.openhome.util 包下,所以编译出来的 Console.class 必须放在 classes 文件夹的 cc/openhome/util 文件夹中,Main 类使用 package 定义在 cc.openhome 包下,所以 Main.class 必须放在 classes 文件夹的 cc/openhome 文件夹中。 不用手动建立对应包层级的文件夹,在编译时若有使用-d 指定位码的存放位置,就会 自动建立出对应包层级的文件夹,并将编译出来的位码文档放置至应有的位置,如图 2.25 所示。 图 2.25 指定-d 自变量,会建立对应包的文件夹层级 注意,由于 Main 类位于 cc.openhome 包中,所以图 2.25 使用 java 执行程序时,必须指 定完全吻合名称,也就是指定 cc.openhome.Main 这个名称。 从 JDK 到 IDE 2 1 37 2.2.3 使用 import 偷懒 使用包管理,解决了实体文档与撰写程序时类名称冲突的问题,但若每次撰写程序时, 都得输入完全吻合名称,却也是件麻烦的事。想想看,有些包定义的名称很长时,单是要 输入完全吻合名称得花多少时间。 可以用 import 偷懒一下,如图 2.26 所示。 图 2.26 使用 import 减少打字麻烦 编译与执行时的指令方式与图 2.25 相同。当编译程序剖析 Main.java 看到 import 声明 时,会先记得 import 的名称,后续剖析程序时,若看到 Console 名称,原本会不知道 Console 是什么东西,但编译程序记得你用 import 告诉过它,如果遇到不认识的名称,可以比对一 下 import 过的名称,编译程序试着使用 cc.openhome.util.Console,结果可以在指定的类路 径中,cc/openhome/util 文件夹下找到 Console.class,于是可以进行编译。 所以 import 只是告诉编译程序,遇到不认识的类名称,可以尝试使用 import 过的名称, import 让你少打一些字,让编译程序多为你做一些事。 如果同一包下会使用到多个类,你也许会多次使用 import: import cc.openhome.Message; import cc.openhome.User; import cc.openhome.Address; 可以更偷懒一些,用以下的 import 语句: import cc.openhome.*; 图 2.26 也可以使用以下的 import 语句,而编译与执行结果相同: import cc.openhome.util.*; 当编译程序剖析 Main.java 看到 import 的声明时,会先记得有 cc.openhome.util 包名 称,在后续剖析到 Console 名称时,发现它不认识 Console 是什么东西,但编译程序记得你 用 import 告诉过它。若遇到不认识的名称,可以比对一下 import 过的名称,编译程序试着 将 cc.openhome.util 与 Console 结合为 cc.openhome.util.Console,结果可以在指定的类路径 中,cc/openhome/util 文件夹下找到 Console.class,于是可以进行编译。 偷懒也是有个限度,如果自己写了一个 Arrays: package cc.openhome; public class Arrays { 38 ... } 若在某个类中撰写有以下的程序代码: import cc.openhome.*; import java.util.*; public class Some { public static void main(String[] args) { Arrays arrays; ... } } 那么编译时,会发现有图 2.27 所示的错误信息。 图 2.27 到底是用哪个 Arrays? 当编译程序剖析 Some.java 看到 import 的声明时,会先记得有 cc.openhome 包名称, 在继续剖析至 Arrays 该行时,发现它不认识 Arrays 是什么东西,但编译程序记得你用 import 告诉过他。若遇到不认识的名称,可以比对 import 过的名称,编译程序试着将 cc.openhome 与 Arrays 结合在一起为 cc.openhome.Arrays,结果可以在类路径中,cc/openhome 文件夹 下找到 Arrays.class。 然而,编译程序试着将 java.util 与 Arrays 结合在一起为 java.util.Arrays,发现也可 以在 Java SE API 的 rt.jar 中(默认的类加载路径之一,参考图 2.21),对应的 java/util 文件 夹中找到 Arrays.class,于是编译程序困惑了,到底该使用 cc.openhome.Arrays 还是 java.util.Arrays? 遇到这种情况时,就不能偷懒了,要使用哪个类名称,就得明确地逐字打出来: import cc.openhome.*; import java.util.*; public class Some { public static void main(String[] args) { 从 JDK 到 IDE 2 1 39 cc.openhome.Arrays arrays; ... } } 这个程序就可以通过编译了。简单地说,import 是偷懒工具,不能偷懒就回归最保守的 写法。 学过 C/C++的读者请注意,import 跟#include 一点都不像,无论原始码中有无 import, 编译过后的.class 都是一样的,不会影响执行效能。import 顶多只会让编译时的时间拉 长一些而已。 在 Java SE API 中有许多常用类,像是写第一个 Java 程序时使用的 System 类,其实 也有使用包管理,完整名称其实是 java.lang.System,在 java.lang 包下的类由于很常用, 不用撰写 import 也可以直接使用 class 定义的名称,这也就是为何不用如下撰写程序的原 因(写了当然也没关系,只是自找麻烦): java.lang.System.out.println("Hello!World!"); 如果类位于同一包,彼此使用并不需要 import,当编译程序看到一个没有包管理的类名 称,会先在同一包中寻找类,如果找到就使用,若没找到,再试着从 import 描述进行比对。 java.lang 可视为预设就有 import,没有写任何 import 描述时,也会试着比对 java.lang 的组 合,看看是否能找到对应类。 原始码文档或位码文档都可以使用 JAR 文档封装,在“命令提示符”模式下,可以使用 JDK 的 jar 工具程序来制作 JAR 文档。可以参考以下文件: http://caterpillar.onlyfun.net/Gossip/JavaEssence/SourceClassInJAR.html 2.3 使用 IDE 在开始使用包管理原始码文档与位码文档之后,必须建立与包对应的实体文件夹层 级,编译时必须正确指定-sourcepath、-cp 等自变量,执行程序时必须使用完全吻合名称, 这实在是很麻烦。可以考虑开始使用 IDE(Integrated Development Environment),由 IDE 代劳你一些原始码文档与位码文档等资源管理工作,提升你的效率。 除了 IDE 之外,也可以考虑使用 Ant 或 Maven 等工具提高效率,可以参考以下文件中有 关 Ant 或 Maven 的介绍: http://caterpillar.onlyfun.net/Gossip/JUnit/ 2.3.1 IDE 项目管理基础 在 Java 领域中,有为数不少的 IDE,其中有不少是优秀的开放原始码产品,最为人 熟知的 IDE 有 NetBeans、Eclipse、Intellij IDEA、JDeveloper 等,不同的 IDE 会有不同 40 的特色,但基本概念通常相同。最重要的是,只要你了解 JDK 与相关指令操作,就不容易 被特定的 IDE 给限制住。 在本书中,将选择 NetBeans IDE 7 进行基本介绍,必要时提示 Eclipse 对应的功能, 选择 NetBeans 的原因在于,NetBeans 直接使用你安装的 JDK,而 IDE 上显示的编译错 误信息就是 JDK 实际显示的信息,这对初学者了解 JDK 与 IDE 功能对应有帮助。提示 Eclipse对应功能的原因在于,它有许多外挂(plugin)可以使用,可让你打造属于自己的IDE, 许多商业用 IDE 也多以 Eclipse 作为基础。 可以到以下网址下载 NetBeans IDE,就本书范围而言,只要下载 Java SE 版本即可: http://netbeans.org/downloads/index.html 对于 Windows 用户,可以直接使用光盘中 tools 文 件 夹 中 的 netbeans-7.0-ml-javase-windows.exe,双击可执行文件并同意授权后,出现图 2.28 所示 画面时,选择 JDK7 安装目录,之后逐步按照指示进行安装即可。 图 2.28 NetBeans IDE 直接使用你安装的 JDK Eclipse 的下载地址如下,对本书范围而言,只要下载 Eclipse IDE for Java Developers 即可: http://www.eclipse.org/downloads/ Eclipse 无须安装,只要安装有 JRE,将下载的文档解压缩后,单击其中的 eclipse 就可 以运行。Eclipse 不使用 JDK 的开发工具,它有自己的 Java 开发工具(Java Development Tools,JDT),详细信息可参考: http://www.eclipse.org/jdt/ 在程序规模步入必须使用包管理之后,就等于初步开始了项目资源管理。在 IDE 中要 撰写程序,通常也是从建立项目开始。在 NetBeans 中,可以这样建立项目: (1) 选择“文件”|“新建项目”命令,在弹出的“新建项目”对话框的“类别”列表 中选择 Java,在“项目”列表中选择“Java 应用程序”,接着单击“下一步”按钮,如 图 2.29 所示。 从 JDK 到 IDE 2 1 41 图 2.29 新建项目 (2) 在“项目名称”文本框中输入项目名称 Hello2,在“项目位置”文本框中输入 C:\workspace,注意,“项目文件夹”会保存至 C:\workspace\Hello2,如图 2.30 所示。 图 2.30 设置项目名称和位置 (3) 在“创建主类”文本框中输入 cc.openhome.Main,这表示会有个 Main 类放在 cc.openhome 包,其中会自动建立 main()程序进入点方法,接着单击“完成”按钮建立项目。 项目建立后,IDE 通常会提供默认的项目检查窗格,方便你检视一些项目资源。若是 NetBeans,会提供图 2.31 所示的“项目”窗格。 图 2.31 NetBeans IDE 的“项目”窗格 在图 2.31 所示的“项目”窗格中,可以看到“源包”中包含 cc.openhome,其中放 置了 Main.java,这方便你以包为单位查看原始码文档,而在“库”中可看到使用了 JDK 1.7 42 中的一些 JAR 文档,你可以回顾图 2.20 中的 CLASSPATH,就有这些 JAR 文档。“库” 中出现的 JAR 文档,表示 IDE 管理的 CLASSPATH 会包括这些 JAR 文档。 可以在 Main.java 中这样撰写,然后执行“运行”|“生成主项目”命令,这会要求 NetBeans 进行程序编译: System.out.println("Hello World"); “任务”窗格提供方便的项目资源查看,但不等于实体文档管理,有的 IDE 必须自行 打开“资源管理器”来查看项目文件夹内容,NetBeans 则可以切换至“文件”窗格直接 查看实际文档管理,如图 2.32 所示。 图 2.32 NetBeans IDE 的“文件”窗格 图 2.32 就是目前项目文件夹 C:\workspace\Hello2 的实体内容,就目前来说,可先注 意“文件”窗格中的 build/classes、dist 与 src 文件夹。build/classes 就是编译出来的位 码文档(所以也是执行时会用到的 CLASSPATH),当中会自动根据 package 定义名称分门别 类放置.class 文档,dist 文件夹就是封装了位码文档的 JAR 文档,src 文件夹就是原始码文 档,当中会自动根据 package 定义名称分门别类放置.java 文档。这一切都是 IDE 代劳,毕 竟 IDE 是生产(Productivity)工具。 如果要使用 NetBeans 执行程序进入 main()的类,可右击 Main.java 文件,在弹出的 快捷菜单中选择“运行文件”命令,会有个“输出”窗格显示执行结果,如图 2.33 所示。 图 2.33 NetBeans IDE 的“输出”窗格 在 IDE 中编辑程序代码,若出现红色虚线,通常表示那是导致编译错误的语法。如果 看到红色虚线千万别发愣,把光标移至红色虚线上,就会显示编译错误信息,如果是 从 JDK 到 IDE 2 1 43 NetBeans,会直接显示 JDK 编译工具提供的错误信息。例如,图 2.34 所示是 Main.java 中,public class 定义的名称不等于 Main(主档名)而产生的编译错误与信息。 图 2.34 在 IDE 中,红色虚线通常表示编译错误 对于一些编译错误,如果 IDE 够聪明,也许会提示一些改正方式。以 NetBeans 来说, 会出现一个小电灯炮图示,这时可以单击图标显示改正提示,看看是否有合用的选项,如 图 2.35 所示。 图 2.35 编译错误时的改正提示 以图 2.35 为例,是因为编译程序不认得 Scanner 类,第一个选项是因为,编译程序发 现有个 java.util.Scanner 也许是你想要的,看你是不是要 import。你也可以看到,在另一 个包分类中,也有个 Scanner。另外,还有建立类的两个选项,编译程序有提示是好事,但 你还是要判断哪个选项才是你想要的,不是单击第一个选项就没事了。 图 2.36 Eclipse 编译错误时的改正提示 Eclipse 有许多操作是类似的,不过 Eclipse 使用自己的 JDT,所以编译的错误信息不是 JDK 提供的信息。图 2.36 显示了 Eclipse 的一些基本画面。 44 以上简单解释了 CLASSPATH、JDK 工具使用、编译相关错误信息、包管理等概念, 对应到 IDE 中哪些操作或设定,其他的功能会在之后说明相关内容时,一并说明在 IDE 中 如果操作或设定。 2.3.2 使用了哪个 JRE 因为各种原因,你的计算机中可能不只存在一套 JRE。可以试着搜索计算机中的文档, 例如在 Windows 中搜索 java.exe,可能会发现有多个 java.exe 文档,某些程度上,可以 将找到一个 java.exe 视作就是有一套 JRE。 在安装好 JDK 后,如果选择一并安装 Public JRE,至少会有两套 JRE 存在计算机中, 一个是 JDK 本身的 Private JRE,一个是选择安装的 Public JRE。 既然计算机中有可能同时存在多套 JRE,那么你到底执行了哪一套 JRE?在文本模式 下输入 java 指令,如果设定了 PATH,会执行 PATH 顺 序 下 找 到的 第一个 java 可执行文件,这个可执行文件所启动的是哪套 JRE? 当找到 java 可执行文件并执行时,会依照以下规则来寻找可用的 JRE:  可否在 java 可执行文件的文件夹下找到相关原生(Native)链接库。  可否在上一层目录中找到 jre 目录。 如果设定 PATH 包括 JDK 的 bin 目录,执行 java 指令时,因为在 JDK 的 bin 中找不 到相关原生链接库,因此找上一层文件夹的 jre 文件夹中有无原生链接库,于是找到的是 JDK 的 Private JRE。 如果将 PATH 设定包括 C:\Program Files\Java\jre7\bin,则执行 java 指令时,因为同 一文件夹下可以找到相关原生链接库,于是就使用 C:\Program Files\Java\jre7\这个 Public JRE。 在执行 java 指令时,可以附带一个-version 自变量,这可以显示执行的 JRE 版本,这 是确认所执行 JRE 版本的一个方式,如图 2.37 所示。 图 2.37 使用-version 确认版本 在刚到一个新开发环境时(例如到客户那边去时),先确认版本是很重要的一件事。文 本模式下若要确认 JRE,可先检查 PATH 路径中的顺序,再查看 java -version 的信息,这 些都是基本的检查动作。 从 JDK 到 IDE 2 1 45 如果有个需求是切换 JRE,文本模式下必须设定 PATH 顺序中,找到的第一个 JRE 之 bin 文件夹是你想要的 JRE,而不是设定 CLASSPATH。 那么,如果使用 IDE 新建项目,你使用了哪个 JRE 呢?如果是 NetBeans,会以你安装 时设定的 JDK(参考图 2.28)中 Private JRE 为默认 JRE。在 NetBeans 中,如果想切换所使用 的 JDK(JRE),可以先新建 Java 平台。 (1) 选择“工具”|“Java 平台”命令,打开“Java 平台管理器”对话框,单击“添加 平台”按钮。 (2) 在打开的“添加 Java 平台”对话框中,选择想要的 JDK 目录,单击“下一步” 按钮。 (3) 确认预设的“平台名称”、“平台源”和“平台 Javadoc”是你想要的设定值后, 单击“完成”按钮完成平台添加。 (4) 在“Java 平台管理器”对话框中单击“关闭”按钮完成设定。 如果是 Eclipse,可以执行 Windows | Preferences 命令,在 Preferences 对话框中选择 Java | Installed JREs 选项,进行 JRE 版本的添加与新建项目时默认使用的 JRE,如图 2.38 所示。 图 2.38 在 Eclipse 中添加平台 完成 Java 平台建立后,接下来可根据以下操作,改变项目想使用的 JDK(JRE)。 (1) 在“项目”窗口中选择项目(如 Hello2),右击,在弹出的快捷菜单中选择“属性” 命令。 (2) 打开“项目属性”对话框,选择“库”选项,在右边的“Java 平台”下拉列表框 中选择要使用的 JDK 版本后,单击“确定”按钮就完成设定。 (3) 在“项目”窗口中的“库”选项下,已设定为你想要的 JDK 版本。 在 Eclipse 中若有项目要改用别的 JRE,可以在项目上右击,在弹出的快捷菜单中选择 Properties 命令,在打开的 Properties 对话框中,选择 Java Build Path 选项,选择 Libraries 选项卡中的 JRE,单击 Edit 按钮,在 Alternate JRE 中选择要用的 JRE 版本,如图 2.39 所示。 46 图 2.39 在 Eclipse 下改变项目的 JDK(JRE) 2.3.3 类文档版本 如果使用新版本 JDK 编译出位码文档,在旧版本 JRE 上执行,有可能会发生图 2.40 所示的错误信息。 图 2.40 不支持此版本 图 2.40 所示是在 JDK7 下编译出位码,切换 PATH 至 JDK6,使用 Private JRE6 执 行位码,结果出现 UnsupportedClassVersionError,并指出这个位码的主版本号与次版本号 (major.minor)为 51.0。 编译程序会在位码文档中标示主版本号与次版本号,不同的版本号,位码文件格式可 能有所不同。JVM 在加载位码文档后,会确认其版本号是否在可接受的范围,否则就不会 处理该位码文档。 可以使用 JDK 工具程序 javap,确认位码文档的版本号,如图 2.41 所示。 可以使 System.getProperty("java.class.version")取得 JRE 支持的位码版本号,使用 System.getProperty("java.runtime.version")取得 JRE 版本信息。 从 JDK 到 IDE 2 1 47 图 2.41 使用 javap 剖析位码文档 在 The JavaTM Virtual Machine Specification 中 The class File Format 说明了位码基本格式: http://java.sun.com/docs/books/jvms/second_edition/html/ClassFile.doc.html 文件底部的注释 1 中指出,Sun JDK 1.0.2 的 JVM 运行支持的位码文档版本号为 45.0~45.3。1.1.X 支持 45.0~45.65535(向前兼容),Java 2 平台支持 45.0~46.0,Java SE 5 与 6 支持 49.0~50.0,Java SE 7 则支持 51.0。 在编译的时候,可以使用-target 指定编译出来的位码,必须符合指定平台允许的版本 号,使用-source 要求编译程序检查使用的语法,不超过指定的版本,如图 2.42 所示。 图 2.42 指定-source 与-target 选项 上面这个例子指定编译出来的位码文档必须是 1.6 平台可接受的版本号,并检查使用 语法必须是 1.6 语法。在不指定-target 与-source 的情况下,编译程序会有默认的-target 值。例如,JDK7 默认的-target 与-source 都是 1.7,-target 在指定时,值必须大于或等于-source, 所以在图 2.42 中,若只指定-target 为 1.6,就会无法通过编译,因为-source 仍是默认值 1.7。 JDK7 与 JDK6 相比,有语法上的新增,所以-source 默认为 1.7(-target 默认为 1.7)。 JDK6(-target 默认为 1.6)与 JDK5(-target 默认为 1.5)则没有语法上的新增,所以-source 都默认为 1.5。 48 从图 2.42 中可看到,如果只指定-source 与-target 进行编译,会出现警示信息,这 是因为编译时默认的 Bootstrap 类加载器(Class loader),第 15 章会介绍 Bootstrap 类加 载器是什么。简单来说,系统默认的类加载器仍参考至 1.7 的 rt.jar(也就是 Java SE 7 API 的 JAR 文档),如果引用到一些旧版 JRE 没有的新 API,就会造成在旧版 JRE 上无法执 行,最好是编译时指定-bootclasspath,参考至旧版的 rt.jar,这样在旧版 JRE 执行时比较 不会发生问题。 事实上,并非一定得切换 PATH 至较低版本的 JDK 或 JRE,才能测试具较低版本号 的类文档,如果已经安装有旧版 JDK 或 JRE,可以在执行时使用-version 自变量并指定版 本,如图 2.43 所示。 图 2.43 使用-version 指定执行版本 图 2.43 中第一次编译时没有指定版本,也就是使用默认的 1.7 位码文档版本号,执行 时指定 1.6 版本就出现了 UnsupportedClassVersionError。第二次编译时指定编译为 1.6 位码 版本号,执行时指定 1.6 版本就没有问题。 如果使用-version 指定的版本,实际上无法在系统上找到已安装的 JRE,则会出现图 2.44 所示的错误。 图 2.44 使用-version 指定时无法找到对应版本 那么在 IDE 中如何设定-source 与-target 对应的选项呢?不同版本 IDE 中建立的项目, 默认的-source 与-target 选项并不相同。以 NetBeans 7.0 为例,如果默认使用 JDK6 的 -source 与-target,要想改变为 JDK7,可以在项目上右击,在弹出的快捷菜单中选择“属 性”命令,打开“项目属性”对话框,在“类别”列表中选择“源”,在“源代码/二进制 格式”列表框中选择 JDK7,如图 2.45 所示。 从 JDK 到 IDE 2 1 49 图 2.45 NetBeans 中设定 JDK 的-source 与-target 如果是 Eclipse,则可以在项目上右击,在弹出的快捷菜单中选择 Properties 命令,在 Java Compiler 中进行设定,如图 2.46 所示。 图 2.46 Eclipse 中设定 JDK 的-source 与-target 2.4 重点复习 在正式撰写程序之前,请先确定你可以看到文档的扩展名。撰写 Java 程序时有几点 必须注意:  扩展名是.java。  主文档名与类名称必须相同。  注意每个字母大小写。  空格只能是半角空格符或 Tab 字符。 一个.java 文档可定义多个类,但是只能有一个公开(public)类,而且主文档名必须与 公开类名称相同。规格书中规定 main()方法的形式一定得是: public static void main(String[] args) 50 当你输入一个指令而没有指定路径信息时,操作系统会依照 PATH 环境变量中设定的 路径顺序,依序寻找各路径下是否有这个指令。若系统中安装两个以上 JDK,Path 路径中 设定的顺序,将决定执行哪个 JDK 下的工具程序,在安装了多个 JDK 或 JRE 的计算机中, 确定执行了哪个版本的 JDK 或 JRE 非常重要,确定 PATH 信息是一定要做的动作。 在 JVM 中执行某个可执行文件(.class),就要告诉 JVM 这个虚拟操作系统到哪些路径 下寻找文档,方式是通过 CLASSPATH 指定可执行文件(.class)的路径信息。在启动 JVM 时要告知可执行文件(.class)的位置,可以使用-classpath 或-cp 自变量来指定。有的时候, 希望也从目前文件夹开始寻找类文档,则可以使用“.”指定。 JAR 文档实际使用 ZIP 格式压缩,当中包含一堆.class 文档,设定 CLASSPATH 时可 将 JAR 文档当作特别的文件夹。如果某个文件夹中有许多.jar 文档,从 Java SE 6 开始, 可以使用“*”表示使用文件夹中所有.jar 文档。 在使用 javac 编译程序时,如果要使用到其他类链接库时,也必须使用-cp 指定 CLASSPATH,使用-sourcepath 指定寻找原始码文档的文件夹,使用-d 指定编译完成的 位码存放文件夹,指定-verbose 自变量可看到编译程序进行编译时的过程。 当类原始码开始使用 package 进行分类时,就会具有以下管理上的意义:  原始码文档要放置在与 package 所定义名称层级相同的文件夹层级。  package 所定义名称与 class 所定义名称,会结合而成类的完全吻合名称(Fully qualified name)。  位码文档要放置在与 package 所定义名称层级相同的文件夹层级。  要在包间可以直接使用的类或方法(Method)必须声明为 public。  import 只是偷懒工具,让你在原始码中不用使用完全吻合名称。 当找到 java 可执行文件并执行时,会依照以下规则来寻找可用的 JRE:  可否在 java 可执行文件文件夹下找到相关原生(Native)链接库。  可否在上一层目录中找到 jre 目录。 在执行 java 指令时,可以附带一个-version 自变量显示执行的 JRE 版本。在编译的时 候,可以使用-target 指定编译出来的位码,必须符合指定平台所允许的版本号,使用-source 要求编译程序检查使用的语法,不超过指定的版本。JDK7 默认的-target 与-source 都是 1.7,-target 在指定时,值必须大于或等于-source。 2.5 课后练习 1. 如果在 hello.java 中撰写以下程序代码: public class Hello { public static void main(String[] args) { System.out.println("Hello World"); } } 从 JDK 到 IDE 2 1 51 以下描述正确的是( )。 A. 执行时显示 Hello World B. 执行时出现 NoClassDefFoundError C. 执行时出现找不到主要方法的错误 D. 编译失败 2. 如果在 Main.java 中撰写以下程序代码: public class Main { public static main(String[] args) { System.out.println("Hello World"); } } 以下描述正确的是( )。 A. 执行时显示 Hello World B. 执行时出现 NoClassDefFoundError C. 执行时出现找不到主要方法的错误 D. 编译失败 3. 如果在 Main.java 中撰写以下程序代码: public class Main { public static void main() { System.out.println("Hello World"); } } 以下描述正确的是( )。 A. 执行时显示 Hello World B. 执行时出现 NoClassDefFoundError C. 执行时出现找不到主要方法的错误 D. 编译失败 4. 如果在 Main.java 中撰写以下程序代码: public class Main { public static void main(string[] args) { System.out.println("Hello World"); } } 以下描述正确的是( )。 A. 执行时显示 Hello World B. 执行时出现 NoClassDefFoundError C. 执行时出现找不到主要方法的错误 D. 编译失败 5. 如果 C:\workspace\Hello\classes 中有以下原始码编译而成的 Main.class: public class Main { public static void main(String[] args) { System.out.println("Hello World"); } } 52 “命令行提示符”模式下你的工作路径是 C:\workspace,那么执行 Main 类正确的是 ( )。 A. java C:\workspace\Hello\classes\Main B. java Hello\classes Main C. java –cp Hello\classes Main D. 以上皆非 6. 如果 C:\workspace\Hello\classes 中有以下原始码编译而成的 Main.class: package cc.openhome; public class Main { public static void main(String[] args) { System.out.println("Hello World"); } } “命令行提示符”模式下你的工作路径是 C:\workspace,那么执行 Main 类正确的是 ( )。 A. java C:\workspace\Hello\classes\Main B. java Hello\classes Main C. java –cp Hello\classes Main D. 以上皆非 7. 如果有个 Console 类的原始码开头定义如下: package cc.openhome; public class Console { ... } 其完全吻合名称的是( )。 A. cc.openhome.Console B. package.cc.openhome.Console C. cc.openhome.class.Console D. 以上皆非 8. 如果 C:\workspace\Hello\src 中有 Main.java 如下: package cc.openhome; public class Main { public static void main(String[] args) { System.out.println("Hello World"); } } “命令行提示符”模式下你的工作路径是 C:\workspace\Hello,那么编译与执行 Main 类正确的是( )。 A. javac src\Main.java B. javac –d classes src\Main.java java C:\workspace\Hello\classes\Main java –cp classes Main 从 JDK 到 IDE 2 1 53 C. javac –d classes src\Main.java D. javac –d classes src\Main.java java –cp classes cc.openhome.Main java –cp classes/cc/openhome Main 9. 如果有个 Console 类的原始码开头定义如下: package cc.openhome; public class Console { ... } 在另一个类中撰写 import 正确的是( )。 A. import cc.openhome.Console; B. import cc.openhome; C. import cc.openhome.*; D. import Console; 10. 关于包以下描述正确的是( )。 A. 要使用 Java SE API 的 System 类必须 import java.lang.System; B. 在程序中撰写 import java.lang.System;会发生编译错误,因为 java.lang 中的类不 用 import C. import 并不影响执行效能 D. 程序中撰写了 import cc.openhome.Main,执行 java 指令时只要下 java Main 就可 以了 学习目标  了解继承的目的  了解继承与多态的关系  知道如何重新定义方法  认识 java.lang.Object  简介垃圾收集机制 6 继承与多态 162 6.1 何谓继承 面向对象中,子类继承(Inherit)父类,避免重复的行为定义,不过并非为了避免重复定 义行为就使用继承,滥用继承而导致程序维护上的问题时有所闻。如何正确判断使用继承 的时机,以及继承之后如何活用多态,才是学习继承时的重点。 6.1.1 继承共同行为 继承基本上就是避免多个类间重复定义共同行为。以实际的例子来说明比较清楚,假 设你正在开发一款 RPG(Role-playing game)游戏,一开始设定的角色有剑士与魔法师。首 先你定义了剑士类: public class SwordsMan { private String name; // 角色名称 private int level; // 角色等级 private int blood; // 角色血量 public void fight() { System.out.println("挥剑攻击"); } public int getBlood() { return blood; } public void setBlood(int blood) { this.blood = blood; } public int getLevel() { return level; } public void setLevel(int level) { this.level = level; } public String getName() { return name; } public void setName(String name) { this.name = name; } } 接着你为魔法师定义类: 继承与多态 6 163 public class Magician { private String name; // 角色名称 private int level; // 角色等级 private int blood; // 角色血量 public void fight() { System.out.println("魔法攻击"); } public void cure() { System.out.println("魔法治疗"); } public int getBlood() { return blood; } public void setBlood(int blood) { this.blood = blood; } public int getLevel() { return level; } public void setLevel(int level) { this.level = level; } public String getName() { return name; } public void setName(String name) { this.name = name; } } 你注意到什么呢?因为只要是游戏中的角色,都会具有角色名称、等级与血量,类中 也都为名称、等级与血量定义了取值方法与设值方法,Magician 中粗体字部分与 SwordsMan 中相对应的程序代码重复了。重复在程序设计上,就是不好的信号。举个例子来说,如果要 将 name、level、blood 改为其他名称,那就要修改 SwordsMan 与 Magician 两个类,如果有更 多类具有重复的程序代码,那就要修改更多类,造成维护上的不便。 如果要改进,就可以把相同的程序代码提升(Pull up)为父类: Game1 Role.java package cc.openhome; 164 public class Role { private String name; private int level; private int blood; public int getBlood() { return blood; } public void setBlood(int blood) { this.blood = blood; } public int getLevel() { return level; } public void setLevel(int level) { this.level = level; } public String getName() { return name; } public void setName(String name) { this.name = name; } } 这个类在定义上没什么特别的新语法,只不过是将 SwordsMan 与 Magician 中重复的程序 代码复制过来。接着 SwordsMan 可以如下继承 Role: Game1 SwordsMan.java package cc.openhome; public class SwordsMan extends Role { public void fight() { System.out.println("挥剑攻击"); } } 在这里看到了新的关键字 extends,这表示 SwordsMan 会扩充 Role 的行为,也就是继承 Role 的行为,再扩充 Role 原本没有的 fight()行为。从程序面上来说,Role 中有定义的程序 继承与多态 6 165 代码,SwordsMan 中都继承而拥有了,并定义了 fight()方法的程序代码。类似地,Magician 也可以如下定义继承 Role 类: Game1 Magician.java package cc.openhome; public class Magician extends Role { public void fight() { System.out.println("魔法攻击"); } public void cure() { System.out.println("魔法治疗"); } } Magician 继承 Role 的行为,再扩充了 Role 原本没有的 fight()与 cure()行为。 在图 6.1 所示的这个类图中,第一格中 Role 表示类名称;第二格中 name、level、blood 表示数据成员;:号之后为各成员类型,-号表示 private;第三格表示方法名称,+号表示 public,:号之后表示返回类型,继承则以空心箭头表示。 图 6.1 类图 如何看出确实有继承了呢?从以下简单的程序可以看出: Game1 RPG.java package cc.openhome; public class RPG { public static void main(String[] args) { SwordsMan swordsMan = new SwordsMan(); swordsMan.setName("Justin"); 166 swordsMan.setLevel(1); swordsMan.setBlood(200); System.out.printf("剑士:(%s, %d, %d)%n", swordsMan.getName(), swordsMan.getLevel(), swordsMan.getBlood()); Magician magician = new Magician(); magician.setName("Monica"); magician.setLevel(1); magician.setBlood(100); System.out.printf("魔法师:(%s, %d, %d)%n", magician.getName(), magician.getLevel(), magician.getBlood()); } } 虽然 SwordsMan 与 Magician 并没有定义 getName()、getLevel()与 getBlood()等方法,但 从 Role 继承了这些方法,所以就如范例中可以直接使用。执行的结果如下: 剑士:(Justin, 1, 200) 魔法师:(Monica, 1, 100) 继承的好处之一,就是若你要将 name、level、blood 改为其他名称,那就只要修改 Role.java 就可以了,只要是继承 Role 的子类都无须修改。 有的书籍或文件会说,private 成员无法继承,那是错的。如果 private 成员无法继承, 那为什么上面的范例 name、level、blood 记录的值会显示出来呢?private 成员会被继承,只 不过子类无法直接存取,必须通过父类提供的方法来存取(如果父类愿意提供访问方法的话)。 6.1.2 多态与 is-a 在 Java 中,子类只能继承一个父类,继承除了可避免类间重复的行为定义外,还有个 重要的关系,那就是子类与父类间会有 is-a 的关系,中文称为“是一种”的关系,这是什 么意思?以前面范例来说,SwordsMan 继承了 Role,所以 SwordsMan 是一种 Role(SwordsMan is a Role),Magician 继承了 Role,所以 Magician 是一种 Role(Magician is a Role)。 为何要知道继承时,父类与子类间会有“是一种”的关系?因为要开始理解多态 (Polymorphism),必须先知道你操作的对象是“哪一种”东西。 来看实际的例子,以下的代码段,相信你现在可以没有问题地看懂,而且知道可以通 过编译: SwordsMan swordsMan = new SwordsMan(); Magician magician = new Magician(); 那你知道以下的程序片段也可以通过编译吗? Role role1 = new SwordsMan(); Role role2 = new Magician(); 那你知道以下的程序片段为何无法通过编译呢? 继承与多态 6 167 SwordsMan swordsMan = new Role(); Magician magician = new Role(); 编译程序就是语法检查器,要知道以上程序片段为何可以通过编译,为何无法通过编 译,就是将自己当作编译程序,检查语法的逻辑是否正确,方式是从=号右边往左读:右边 是不是一种左边呢(右边类是不是左边类的子类)?如图 6.2 所示。 是一种 图 6.2 运用 is a 关系判断语法正确性 从右往左读,SwordsMan 是不是一种 Role 呢?是的,所以编译通过。Magician 是不是一 种 Role 呢?是的,所以编译通过。同样的判断方式,可以知道为何以下编译失败: SwordsMan swordsMan = new Role(); // Role 是不是一种 SwordsMan? Magician magician = new Role(); // Role 是不是一种 Magician? 编译程序认为第一行,Role 不一定是一种 SwordsMan,所以编译失败,对于第二行,编 译程序认为 Role 不一定是一种 Magician,所以编译失败。继续把自己当成编译程序,再来 看看以下的程序片段是否可以通过编译: Role role1 = new SwordsMan(); SwordsMan swordsMan = role1; 这个程序片段最后会编译失败,先从第一行看,SwordsMan 是一种 Role,所以这行可以 通过编译。编译程序检查这类语法,一次只看一行,就第二行而言,编译程序看到 role1 为 Role 声明的名称,于是检查 Role 是不是一种 SwordsMan,答案是不一定,所以编译失败 在第二行。 编译程序会检查父子类间的“是一种”关系,如果你不想要编译程序啰唆,可以叫它 住嘴: Role role1 = new SwordsMan(); SwordsMan swordsMan = (SwordsMan) role1; 对于第二行,原本编译程序想啰唆地告诉你,Role 不一定是一种 SwordsMan,但你加上 了(SwordsMan)让它住嘴了,因为这表示,你就是要让 Role 扮演(CAST)SwordsMan,既然你都 明确要求编译程序别啰唆了,编译程序就让这段程序代码通过编译了,不过后果得自行负责。 以上面这个程序片段来说,role1 确实参考至 SwordsMan 实例,所以在第二行让 SwordsMan 实例扮演 SwordsMan 并没有什么问题,所以执行时期并不会出错,如图 6.3 所示。 168 图 6.3 判断是否可扮演(CAST)成功 以下的程序片段,编译可以成功,但执行时期会出错: Role role2 = new Magician(); SwordsMan swordsMan = (SwordsMan) role2; 对于第一行,Magician 是一种 Role,可以通过编译,对于第二行,role2 为 Role 类型, 编译程序原本认定 Role 不一定是一种 SwordsMan 而想要啰唆,但是你明确告诉编译程序, 就是要让 Role 扮演为 SwordsMan,所以编译程序就让你通过编译了,不过后果自负。实际 上,role2 参考的是 Magician,你要让魔法师假扮为剑士,这在执行上会是个错误,JVM 会 抛出 java.lang.ClassCastException,如图 6.4 所示。 图 6.4 扮演(CAST)失败,执行时抛出 ClassCastException 使用是一种(is-a)原则,就可以判断何时编译成功,何时编译失败,以及将扮演(CAST) 看做叫编译程序住嘴语法,并留意参考的对象实际类型,就可以判断何时扮演成功,何时 会抛出 ClassCastException。例如以下编译成功,执行也没问题: SwordsMan swordsMan = new SwordsMan(); Role role = swordsMan; // SwordsMan 是一种 Role 以下程序片段会编译失败: SwordsMan swordsMan = new SwordsMan(); Role role = swordsMan; // SwordsMan 是一种 Role,这行通过编译 SwordsMan swordsMan = role; // Role 不一定是一种 SwordsMan,编译失败 以下程序片段编译成功,执行时也没问题: SwordsMan swordsMan = new SwordsMan(); Role role = swordsMan; // SwordsMan 是一种 Role,这行通过编译 // 你告诉编译程序要让 Role 扮演 SwordsMan,以下这行通过编译 SwordsMan swordsMan = (SwordsMan) role; // role 参考 SwordsMan 实例,执行成功 以下程序片段编译成功,但执行时抛出 ClassCastException: SwordsMan swordsMan = new SwordsMan(); Role role = swordsMan; // SwordsMan 是一种 Role,这行通过编译 继承与多态 6 169 // 你告诉编译程序要让 Role 扮演 Magician,以下这行通过编译 Magician magician = (Magician) role; // role 参考 Magician 实例,执行失败 经过以上这一连串的语法测试,好像只是在玩弄语法,不!你懂不懂以上这些东西, 将牵涉写出来的东西有没有弹性、好不好维护的问题。 有这么严重吗?来出个题目给你吧。请设计 static 方法,显示所有角色的血量。OK! 上一章刚学过如何定义方法,有的人会撰写以下的方法定义: public static void showBlood(SwordsMan swordsMan) { System.out.printf("%s 血量 %d%n", swordsMan.getName(), swordsMan.getBlood()); } public static void showBlood(Magician magician) { System.out.printf("%s 血量 %d%n", magician.getName(), magician.getBlood()); } 分别为 SwordsMan 与 Magician 设计 showBlood()同名方法,这是重载方法的运用,如此 就可以如下调用: showBlood(swordsMan); // swordsMan 是 SwordsMan 类型 showBlood(magician); // magician 是 Magician 类型 现在的问题是,目前你的游戏中是只有 SwordsMan 与 Magician 两个角色,如果有 100 个角色呢?重载出 100 个方法?这种方式显然不可能。如果所有角色都是继承自 Role,而 且你知道这些角色都是一种 Role,你就可以如下设计方法并调用: Game2 RPG.java package cc.openhome; public class RPG { public static void showBlood(Role role) { System.out.printf("%s 血量 %d%n", role.getName(), role.getBlood()); } public static void main(String[] args) { SwordsMan swordsMan = new SwordsMan(); swordsMan.setName("Justin"); swordsMan.setLevel(1); swordsMan.setBlood(200); Magician magician = new Magician(); magician.setName("Monica"); magician.setLevel(1); magician.setBlood(100);  声明为 Role 类 170 showBlood(swordsMan); showBlood(magician); } } 在这里仅定义了一个 showBlood()方法,参数声明为 Role 类型。第一次调用 showBlood() 时传入了 SwordsMan 实例,这是合法的语法,因为 SwordsMan 是一种 Role。第一次调用 showBlood()时传入了 Magician 实例也是可行,因为 Magician 是一种 Role 。执行的结果如下: Justin 血量 200 Monica 血量 100 这样的写法好处为何?就算有 100 种角色,只要它们都是继承 Role,都可以使用这个 方法显示角色的血量,而不需要像前面重载的方式,为不同角色写 100 个方法,多态的写 法显然具有更高的可维护性。 什么叫多态?以抽象讲法解释,就是使用单一接口操作多种类型的对象。若用以上的范 例来理解,在 showBlood()方法中,既可以通过 Role 类型操作 SwordsMan 对象,也可以通过 Role 类型操作 Magician 对象。 稍后会学到 Java 中 interface 的使用,在多态定义中,使用单一接口操作多种类型的对 象,这里的接口并不是专指 Java 中的 interface,而是指对象上可操作的方法。 6.1.3 重新定义行为 现在有个需求,请设计 static()方法,可以播放角色攻击动画。你也许会这么想,学 刚刚学过的多态的写法,设计个 drawFight()方法如何?如图 6.5 所示。 图 6.5 Role 没有定义 fight()方法 对 drawFight()方法而言,只知道传进来的会是一种 Role 对象,所以编译程序也只能检 查你调用的方法,Role 是不是有定义,显然地,Role 目前并没有定义 fight()方法,因此编 译错误。 然而仔细观察一下 SwordsMan 与 Magician 的 fight()方法,它们的方法签署(method signature)都是: public void fight() 也就是说,操作接口是相同的,只是方法操作内容不同。可以将 fight()方法提升至 Role 类中定义:  magician 是一种 Role  SwordsMan 是一种 Role 继承与多态 6 171 Game3 Role.java package cc.openhome; public class Role { ... public void fight() { // 子类要重新定义 fight()的实际行为 } } 在 Role 类中定义了 fight()方法,由于实际上角色如何攻击,只有子类才知道,所以 这里的 fight()方法内容是空的,没有任何程序代码执行。SwordsMan 继承 Role 之后,再对 fight()的行为进行定义: Game3 SwordsMan.java package cc.openhome; public class SwordsMan extends Role { public void fight() { System.out.println("挥剑攻击"); } } 在继承父类之后,定义与父类中相同的方法部署,但执行内容不同,这称为重新定义 (Override)。因为对父类中已定义的方法执行不满意,所以在子类中重新定义执行。Magician 继承 Role 之后,也重新定义了 fight()的行为: Game3 Magician.java package cc.openhome; public class Magician extends Role { public void fight() { System.out.println("魔法攻击"); } ... } 由于 Role 现在定义了 fight()方法(虽然方法区块中没有程序代码运行),所以编译程序 不会找不到 Role 的 fight(),因此可以如下撰写: Game3 RPG.java package cc.openhome; public class RPG { 172 public static void drawFight(Role role) { System.out.print(role.getName()); role.fight(); } public static void main(String[] args) { SwordsMan swordsMan = new SwordsMan(); swordsMan.setName("Justin"); swordsMan.setLevel(1); swordsMan.setBlood(200); Magician magician = new Magician(); magician.setName("Monica"); magician.setLevel(1); magician.setBlood(100); drawFight(swordsMan); drawFight(magician); } } 在 fight()方法声明了 Role 类型的参数,那方法中调用的,到底是 Role 中定义的 fight(),还是个别子类中定义的 fight()呢?如果传入 fight()的是 SwordsMan,role 参数参 考的就是 SwordsMan 实例,操作的就是 SwordsMan 上的方法定义,如图 6.6 所示。 挥剑攻击 图 6.6 role 牌子挂在 SwordsMan 实例 这就好比 role 牌子挂在 SwordsMan 实例身上,你要求有 role 牌子的对象攻击,发动攻 击的对象就是 SwordsMan 实例。同样地,如果传入 fight()的是 Magician,role 参数参考的 就是 Magician 实例,操作的就是 Magician 上的方法定义,如图 6.7 所示。 "魔法攻击" 图 6.7 role 牌子挂在 Magician 实例 所以范例最后的执行结果是:  声明为 Role 类型  实际操作的是 SwordsMan 实例  实际操作的是 Magician 实例 继承与多态 6 173 Justin 挥剑攻击 Monica 魔法攻击 在重新定义父类中某个方法时,子类必须撰写与父类方法中相同的签署,然而如果疏 忽打错字了: public class SwordsMan extends Role { public void Fight() { System.out.println("挥剑攻击"); } } 以这里的例子来说,父类中定义的是 fight(),但子类中定义了 Fight(),这就不是重 新定义 fight()了,而是子类新定义了一个 Fight()方法。这是合法的方法定义,编译程序 并不会发出任何错误信息,你只会在运行范例时,发现为什么 SwordsMan 完全没有攻击。 在 JDK5 之后支持标注(Annotation),其中一个内建的标准标注就是@Override,如果在 子类中某个方法前标注@Override,表示要求编译程序检查,该方法是不是真的重新定义了 父类中某个方法,如果不是的话,就会引发编译错误,如图 6.8 所示。 图 6.8 编译程序检查是否真的重新定义父类某方法 如果要重新定义某方法,加上@Override,就不用担心打错字的问题了。关于标注详细 语法,会在第 16 章说明。 6.1.4 抽象方法、抽象类 上一个范例中 Role 类的定义中,fight()方法区块中实际上没有撰写任何程序代码,虽 然满足了多态需求,但会引发的问题是,你没有任何方式强迫或提示子类一定要操作 fight() 方法,只能口头或在文件上告知,不过如果有人没有传达到、没有看文件或文件看漏了呢? 如果某方法区块中真的没有任何程序代码操作,可以使用 abstract 标示该方法为抽象 方法(Abstract method),该方法不用撰写{}区块,直接“;”结束即可。例如: Game4 Role.java package cc.openhome; public abstract class Role { ... public abstract void fight(); } 174 类中若有方法没有操作,并且标示为 abstract,表示这个类定义不完整,定义不完整的 类就不能用来生成实例,这就好比设计图不完整,不能用来生产成品一样。Java 中规定内含 抽象方法的类,一定要在 class 前标示 abstract,如上例所示,它表示这是一个定义不完整 的抽象类(Abstract class)。如果尝试用抽象类创建实例,就会引发编译错误,如图 6.9 所示。 图 6.9 不能实例化抽象类 子类如果继承抽象类,对于抽象方法有两种做法,一种做法是继续标示该方法为 abstract(该子类因此也是个抽象类,必须在 class 前标示 abstract);另一种做法就是操作 抽象方法。如果两种做法都没有实施,就会引发编译错误,如图 6.10 所示。 图 6.10 没有操作抽象方法 6.2 继承语法细节 上一节介绍了继承的基础概念与语法,然而结合 Java 的特性,继承还有许多细节必须 明了,像是哪些成员可以限定在子类中使用、哪些方法签署算重新定义、Java 中所有对象 都是一种 java.lang.Object 等细节,这将在本节中详细说明。 6.2.1 protected 成员 就上一节的 RPG 游戏来说,如果建立了一个角色,想显示角色的细节,则必须这样 撰写: SwordsMan swordsMan = new SwordsMan(); ... System.out.printf("剑士 (%s, %d, %d)%n", swordsMan.getName(), swordsMan.getLevel(), swordsMan.getBlood()); Magician magician = new Magician(); ... System.out.printf("魔法师 (%s, %d, %d)%n", magician.getName(), magician.getLevel(), magician.getBlood()); 这对使用 SwordsMan 或 Magician 的客户端有点不方便,如果可以在 SwordsMan 或 Magician 上定义 toString()方法,返回角色的字符串描述: public class SwordsMan extends Role { ... public String toString() { return String.format("剑士 (%s, %d, %d)", this.getName(), 继承与多态 6 175 this.getLevel(), this.getBlood()); } } public class Magician extends Role { ... public String toString() { return String.format("魔法师 (%s, %d, %d)", this.getName(), this.getLevel(), this.getBlood()); } } 客户端就可以这样撰写: SwordsMan swordsMan = new SwordsMan(); ... System.out.println(swordsMan.toString()); Magician magician = new Magician(); ... System.out.printf(magician.toString()); 看来客户端简洁许多。不过你定义的 toString()在取得名称、等级与血量时不是很方 便,因为 Role 中的 name、level 与 blood 被定义为 private,所以无法直接在子类中存取, 只能通过 getName()、getLevel()、getBlood()来取得。 将 Role 中的 name、level 与 blood 定义为 public,这又会完全开放 name、level 与 blood 访问权限,你并不想这么做。只想让子类可以直接存取 name、level 与 blood 的话,可以定 义它们为 protected: Game5 Role.java package cc.openhome; public abstract class Role { protected String name; protected int level; protected int blood; ... } 被声明为 protected 的成员,相同包中的类可以直接存取,不同包中的类可以在继承后 的子类直接存取。现在你的 SwordsMan 可以这样定义 toString(): Game5 SwordsMan.java package cc.openhome; public class SwordsMan extends Role { ... 176 public String toString() { return String.format("剑士 (%s, %d, %d)", this.name, this.level, this.blood); } } Magician 也可以这样撰写: Game5 Magician.java package cc.openhome; public class Magician extends Role { ... public String toString() { return String.format("魔法师 (%s, %d, %d)", this.name, this.level, this.blood); } } 如果方法中没有同名参数,this 可以省略,不过基于程序可读性,多打个 this 会比较清楚。 到这里为止,Java 中三个权限关键字你都看到了,也就是 public、protected 与 private。 虽然只有三个权限关键字,但实际上有四个权限范围,因为没有定义权限关键字,默认就 是包范围。权限关键字与权限范围的关系,如表 6.1 所示。 表 6.1 权限关键字与范围 关 键 字 类 内 部 相 同 包 类 不 同 包 类 public 可存取 可存取 可存取 protected 可存取 可存取 子类可存取 无 可存取 可存取 不可存取 private 可存取 不可存取 不可存取 简单来说,依权限小至大来区分,就是 private、无关键字、protected 与 public,设计 时要使用哪个权限,是依经验或团队讨论而定,如果一开始不知道使用哪个权限,就先使 用 private,以后视需求再放开权限。 6.2.2 重新定义的细节 在 6.1.3 节已看过何谓重新定义方法与实例,有时候重新定义方法时,并非完全不满 意父类中的方法,只是希望在执行父类中方法的前、后做点加工。例如,也许 Role 类中原 本就定义了 toString()方法: 继承与多态 6 177 Game6 Role.java package cc.openhome; public abstract class Role { ... public String toString() { return String.format("(%s, %d, %d)", this.name, this.level, this.blood); } } 如果在 SwordsMan 子类中重新定义 toString()的内容时,可以执行 Role 中的 toString() 方法取得字符串结果,再连接“剑士”字样,不就是你想要的描述了吗?在 Java 中,如 果想取得父类中的方法定义,可以在调用方法前,加上 super 关键字。例如: Game6 SwordsMan.java package cc.openhome; public class SwordsMan extends Role { ... @Override public String toString() { return "剑士 " + super.toString(); } } 类似地,Magician 在重新定义 toString()时,也可以如法炮制: Game6 Magician.java package cc.openhome; public class Magician extends Role { ... @Override public String toString() { return "魔法师 " + super.toString(); } } 可以使用 super 关键字调用的父类方法,不能定义为 private(因为这就限定只能在类内 使用)。 重新定义方法要注意,对于父类中的方法权限,只能扩大但不能缩小。若原来成员 public, 子类中重新定义时不可为 private 或 protected,如图 6.11 所示。 178 图 6.11 重新定义时不能缩小方法权限 在 JDK5 之前,重新定义方法时除了可以定义权限较大的关键字外,其他部分必须与 父类中方法签署完全一致。例如,原先设计了一个 Bird 类: public class Bird { protected String name; public Bird(String name) { this.name = name; } public Bird copy() { return new Bird(name); } } 原先 copy()返回了 Bird 类型,如果 Chicken 继承 Bird,打算让 copy()方法返回 Chicken, 那么在 JDK5 之前会发生编译错误,如图 6.12 所示。 图 6.12 JDK5 之前重新定义方法时,返回类型也必须一致 在 JDK5 之后,重新定义方法时,如果返回类型是父类中方法返回类型的子类,也是可以 通过编译的。图 6.12 所示的例子,在 JDK5 中并不会出现编译错误。 static 方法属于类拥有,如果子类中定义了相同签署的 static 成员,该成员属于子类所 有,而非重新定义,static 方法也没有多态,因为对象不会个别拥有 static 成员。 6.2.3 再看构造函数 如果类有继承关系,在创建子类实例后,会先进行父类定义的初始流程,再进行子类 中定义的初始流程,也就是创建子类实例后,会先执行父类构造函数定义的流程,再执行 子类构造函数定义的流程。 构造函数可以重载,父类中可重载多个构造函数,如果子类构造函数中没有指定执行 父类中哪个构造函数,默认会调用父类中无参数构造函数。如果这样撰写程序: class Some { Some() { System.out.println("调用 Some()"); } 继承与多态 6 179 } class Other extends Some { Other() { System.out.println("调用 Other()"); } } 如果尝试 new Other(),看来好像是先执行 Some()中的流程,再执行 Other()中的流程, 也就是先显示"调用 Some()",再显示"调用 Other()"。很奇怪是吧!先继续往下看,就知道 为什么了。如果想执行父类中某构造函数,可以使用 super()指定。例如: class Some { Some() { System.out.println("调用 Some()"); } Some(int i) { System.out.println("调用 Some(int i)"); } } class Other extends Some { Other() { super(10); System.out.println("调用 Other()"); } } 在这个例子中,new Other()时,先调用了 Other()版本的构造函数,super(10)表示调用 父类构造函数时传入 int 数值 10,因此就是调用了父类中 Some(int i)版本的构造函数,而 后再继续 Other()中 super(10)之后的流程。其实当你这么撰写时: class Some { Some() { System.out.println("调用 Some()"); } } class Other extends Some { Other() { System.out.println("调用 Other()"); } } 前面谈过,如果子类构造函数中没有指定执行父类中哪个构造函数,默认会调用父类 中无参数构造函数,也就是等于你这么撰写: class Some { Some() { System.out.println("调用 Some()"); } 180 } class Other extends Some { Other() { super(); System.out.println("调用 Other()"); } } 所以执行 new Other()时,是先执行 Other()中的流程,而 Other()中指定调用父类无参 数构造函数,而后再执行 super()之后的流程。 this()与 super()只能择一调用,而且一定要在构造函数第一行执行。 那么你知道图 6.13 为什么会编译错误吗? 图 6.13 找不到构造函数? 5.2.2 节谈过,编译程序会在你没有撰写任何构造函数时,自动加入没有参数的默认构 造函数(Default constructor),如果自行定义了构造函数,就不会自动加入任何构造函数了。 在图 6.13 中,Some 定义了有参数的构造函数,所以编译程序不会再加入默认构造函数,Other 的构造函数中没有指定调用父类中哪个构造函数,那就是默认调用父类中无参数构造函数, 但父类中现在哪来的无参数构造函数呢?因此编译失败了。 因此 5.2.3 节提示过一次,有些场合建议,如果定义了有参数的构造函数,也可以加入无 参数构造函数,即使内容为空也无所谓,这是为了日后使用上的弹性。例如,运用反射 (Reflection)机制生成对象的需求,或者继承时调用父类构造函数时的方便。 6.2.4 再看 final 关键字 在 3.1.2 节中谈过,如果在指定变量值之后,就不想再改变变量值,可以在声明变量 时加上 final 限定,如果后续撰写程序时,自己或别人不经意想修改 final 变量,就会出现 编译错误。 在 5.2.4 节中也谈过,如果对象数据成员被声明为 final,但没有明确使用=指定值, 那表示延迟对象成员值的指定,在构造函数执行流程中,一定要有对该数据成员指定值的 动作,否则编译错误。 class 前也可以加上 final 关键字,如果 class 前使用了 final 关键字定义,那么表示这 个类是最后一个了,不会再有子类,也就是不能被继承。有没有实际的例子呢?有的,String 继承与多态 6 181 在定义时就限定为 final 了,这可以在 API 文件上得以验证,如图 6.14 所示。 图 6.14 String 是 final 类 如果打算继承 final 类,则会发生编译错误,如图 6.15 所示。 图 6.15 不能继承 final 类 定义方法时,也可以限定该方法为 final,这表示最后一次定义方法了,也就是子类不可 以重新定义 final 方法。有没有实际的例子呢?有的,java.lang.Object 上有几个 final 方法, 如图 6.16 所示。 图 6.16 Object 类上的 final 方法之一 如果尝试在继承父类后,重新定义 final 方法,则会发生编译错误,如图 6.17 所示。 图 6.17 不能重新定义 final 方法 在 Java SE API 中会声明为 final 的类或方法,通常与 JVM 对象或操作系统资源管理有 密切相关,因此不希望 API 用户继承或重新定义。 6.2.5 java.lang.Object 在 Java 中,子类只能继承一个父类,如果定义类时没有使用 extends 关键字指定继承 任何类,那一定是继承 java.lang.Object。也就是说,如果这样定义类: public class Some { ... 182 } 那就相当于撰写: public class Some extends Object { ... } 因此在 Java 中,任何类追溯至最上层父类,一定就是 java.lang.Object,也就是 Java 中所有对象,一定“是一种”Object,所以这样撰写程序是合法的: Object o1 = "Justin"; Object o2 = new Date(); String 是一种 Object,Date 是一种 Object,任何类型的对象,都可以使用 Object 声明 的名称来参考。这有什么好处?如果有个需求是使用数组收集各种对象,那该声明为什么 类型呢?答案是 Object[]。例如: Object[] objs = {"Monica", new Date(), new SwordsMan()}; String name = (String) objs[0]; Date date = (Date) objs[1]; SwordsMan swordsMan = (SwordsMan) objs[2]; 因为数组长度有限,使用数组来收集对象不是那么的方便,以下定义的 ArrayList 类, 可以不限长度的收集对象: Inheritance ArrayList.java package cc.openhome; import java.util.Arrays; public class ArrayList { private Object[] list; private int next; public ArrayList(int capacity) { list = new Object[capacity]; } public ArrayList() { this(16); } public void add(Object o) { if(next == list.length) { list = Arrays.copyOf(list, list.length * 2); } list[next++] = o; }  使用 Object 数组收集  下一个可储存对象的索引  指定初始容量  初始容量默认为 16  自动增长 Object 数组长度  收集对象方法 继承与多态 6 183 public Object get(int index) { return list[index]; } public int size() { return next; } } 自定义的 ArrayList 类,内部使用 Object 数组来收集对象,每一次收集的对象会放在 next 指定的索引处,在创建 ArrayList 实例时,可以指定内部数组初始容量,如果使用 无参数构造函数,则默认容量为 16。 如果要收集对象,可通过 add()方法,注意参数的类型为 Object,可以接收任何对象。 如果内部数组原长度不够,就使用 Arrays.copyOf()方法自动建立原长度两倍的数组并复制 元素。如果想取得收集的对象,可以使用 get()指定索引取得。如果想知道已收集的对 象个数,则通过 size()方法得知。 以下使用自定义的 ArrayList 类,可收集访客名称,并将名单转为大写后显示: Inheritance Guest.java package cc.openhome; import java.util.Scanner; public class Guest { public static void main(String[] args) { ArrayList list = new ArrayList(); Scanner scanner = new Scanner(System.in); String name; while(true) { System.out.print("访客名称:"); name = scanner.nextLine(); if(name.equals("quit")) { break; } list.add(name); } System.out.println("访客名单:"); for(int i = 0; i < list.size(); i++) { String guest = (String) list.get(i); System.out.println(guest.toUpperCase()); } } }  已收集的对象个数 依索引取得收集的对象 184 一个执行结果如下所示: 访客名称:Justin 访客名称:Monica 访客名称:Irene 访客名称:quit 访客名单: JUSTIN MONICA IRENE java.lang.Object 是所有类的顶层父类,这代表了 Object 上定义的方法,所有对象都继承 下来了,只要不是被定义为 final 方法,都可以重新定义,如图 6.18 所示。 图 6.18 java.lang.Object 定义的方法 1. 重新定义 toString() 举例来说,在 6.2.1 节的范例中,SwordsMan 等类曾定义过 toString()方法,其实 toString() 是 Object 上定义的方法。Object 的 toString()默认定义为: public String toString() { return getClass().getName() + "@" + Integer.toHexString(hashCode()); } 目前你不用特别知道这段程序代码详细内容,总之返回的字符串包括了类名称以及 16 进制哈希码,通常这并没有什么阅读上的意义。实际上 6.2.1 节的范例中,SwordsMan 等类, 是重新定义了 toString() ,许多方法若传入对象,默认都会调用 toString() , 例如 System.out.print()等方法就会调用 toString()以取得字符串描述来显示,所以 6.2.1 节的这 个程序片段: SwordsMan swordsMan = new SwordsMan(); ... System.out.println(swordsMan.toString()); Magician magician = new Magician(); ... System.out.printf(magician.toString()); 继承与多态 6 185 实际上只要这么撰写就可以了: SwordsMan swordsMan = new SwordsMan(); ... System.out.println(swordsMan); Magician magician = new Magician(); ... System.out.printf(magician); 2. 重新定义 equals() 在 4.1.3 节谈过,在 Java 中要比较两个对象的实质相等性,并不是使用==,而是通过 equals()方法,在后续你看过 Integer 等打包器,以及字符串相等性比较时,都是使用 equals() 方法。 实际上 equals()方法是 Object 类有定义的方法,其程序代码是: public boolean equals(Object obj) { return (this == obj); } 如果没有重新定义 equals(),使用 equals()方法时,作用等同于==,所以要比较实质相 等性,必须自行重新定义。一个简单的例子,是比较两个 Cat 对象是否实际上代表同一只 Cat 的数据: public class Cat { ... public boolean equals(Object other) { // other 参考的就是这个对象,当然是同一对象 if (this == other) { return true; } /* other 参考的对象是不是 Cat 创建出来的 例如若是 Dog 创建出来的当然就不用比了 */ if (!(other instanceof Cat)) { return false; } Cat cat = (Cat) other; // 定义如果名称与生日,表示两个对象实质上相等 if (!getName().equals(cat.getName())) { return false; } if (!getBirthday().equals(cat.getBirthday())) { return false; } return true; } } 186 这个程序片段示范了 equals()操作的基本概念,相关说明都以批注方式呈现了。这里 也看到了 instanceof 运算符,它可以用来判断对象是否由某个类创建,左操作数是对象, 右操作数是类,在使用 instanceof 时,编译程序还会来帮点忙,会检查左操作数类型是否 在右操作数类型的继承架构中(或界面操作架构中,下一章会说明接口),如图 6.19 所示。 图 6.19 String 与 Date 在继承架构上一点关系也没有 执行时期,并非只有左操作数对象为右操作数类直接实例化才返回 true,只要左操作 数类型是右操作数类型的子类型,instanceof 也是返回 true。 这里仅示范了 equals()操作的基本概念,实际上操作 equals()并非这么简单。操作 equals()时通常也会操作 hashCode(),原因是等到第 9 章学习 Collection 时再说明。如果现 在就想知道 equals()与 hashCode()操作时要注意的一些事项,可以先参考以下文件: http://caterpillar.onlyfun.net/Gossip/JavaEssence/ObjectEquality.html 2007 年研究文献 Declarative Object Identity Using Relation Types 中指出,在考察大量 Java 程序代码之后,作者发现大部分 equals()方法都操作错误。 6.2.6 关于垃圾收集 创建对象会占据内存,如果程序执行流程中已无法再使用某个对象,该对象就只是徒 耗内存的垃圾。 对于不再有用的对象,JVM 有垃圾收集(Garbage Collection, GC)机制,收集到的垃圾 对象所占据的内存空间,会被垃圾收集器释放。那么,哪些会被 JVM 认定为垃圾对象?简 单地说,执行流程中,无法通过变量参考的对象,就是 GC 认定的垃圾对象。 执行流程?具体来说就是线程(Thread)(第 11 章才会说明线程),目前你唯一接触到的 线程就是 main()程序进入点开始之后的主线程(也就是主流程)。事实上,关于垃圾收集本 身就很复杂,不同的需求也会有不同垃圾收集算法,你只需要知道基本概念即可,细节就 交给 JVM 处理。 假设有一个类: public class Some { Some next; } 若是从程序进入点开始,有段程序代码如下撰写: Some some1 = new Some(); Some some2 = new Some(); Some some1 = some2; 继承与多态 6 187 执行到第二行时,主线程可以通过参考名称所参考到的对象,如图 6.20 所示。 图 6.20 两个对象都有牌子 执行到第三行时,是将 some2 参考的对象给 some1 参考,如图 6.21 所示。 图 6.21 没有牌子的就是垃圾 原先 some1 参考的对象不再被任何名称参考,通过主线程也不再能参考到该对象,这 个对象就是内存中的垃圾了,GC 会自动找出这些垃圾并予以回收。 GC 的基本概念就是这样,但可以加以变化。如果有段程序是这样: Some some = new Some(); some.next = new Some(); some = null; 在执行到第二行时,情况如图 6.22 所示,此时还没有对象是垃圾。 图 6.22 链状参考 由于从主流程开始,可以通过 some 参考至中间的对象,而 some.next 可以参考至最右 边的对象,目前没有必要回收任何对象。执行完成第三行后,情况变成如图 6.23 所示。 图 6.23 回收几个对象呢? 188 由于从主流程开始,无法通过 some 参考至中间对象,也就无法再通过中间对象的 next 参考至右边对象,所以两个对象都是垃圾。同样的道理,下面程序代码中,数组参考到的 对象全部都会被回收,如图 6.24 所示。 Some[] somes = {new Some(), new Some(), new Some}; somes = null; 图 6.24 数组参考到的对象被回收 被回收的对象包括了数组对象本身,以及三个索引所参考的三个对象。如果是形同孤 岛的对象,例如: Some some = new Some(); some.next = new Some(); some.next.next = new Some(); some.next.next.next = some; some = null; 执行到第四行时,情况如图 6.25 所示。 图 6.25 循环参考 执行完第五行后,情况变为如图 6.26 所示。 图 6.26 形成孤岛 继承与多态 6 189 这个时候形成孤岛的右边三个对象,将全部被 GC 给处理掉。 GC 在进行回收对象前,会调用对象的 finalize()方法,这是 Object 上就定义的方法。 如果在对象被回收前,有些事情想做,可以重新定义 finalize()方法,不过要注意的是, 何时启动 GC,要视所采用的 GC 算法而定,也就是 finalize()被调用的时机是无法确定 的。在 Effective Java 书中也建议,避免使用 finalize()方法。如果对 finalize()方法有 兴趣,可以参考: http://caterpillar.onlyfun.net/Gossip/JavaEssence/Finalize.html JWorld 上的讨论也可以参考一下: http://www.javaworld.com.tw/jute/post/view?bid=44&id=17264&sty=1&tpg=1&age=0 6.2.7 再看抽象类 撰写程序常有些看似不合理但又非得完成的需求。举个例子来说,现在老板叫你开发 一个猜数字游戏,会随机产生 0~9 的数字,用户输入的数字与随机产生的数字相比,如果 相同就显示“猜中了”,如果不同就继续让用户输入数字,直到猜中为止。 这程序有什么难的?相信现在的你也可以写出来: package cc.openhome; import java.util.Scanner; public class Guess { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); int number = (int) (Math.random() * 10); int guess; do { System.out.print("输入数字:"); guess = scanner.nextInt(); } while(guess != number); System.out.println("猜中了"); } } 圆满完成任务是吧。当你将程序交给老板后,老板皱着眉头说:“我有说要在文本模 式下执行这个游戏吗?”你就问了:“请问会在哪个环境下执行呢?”老板说:“还没决 定,也许会用窗口程序,不过改成网页也不错,唔.…..下个星期开会讨论一下。”你问: “那可以下星期讨论完我再来写吗?”老板说:“不行!”你(内心 OS):“当我是哆啦 A 梦喔!我又没有时光机..….” 这个例子可笑吗?在团队合作、多个部门开发程序时,有许多时候,你不能只是等另 一个部门将程序操作出来,也许另一部门要三个月后才能完成程序操作,难道你们这个部 门要空转三个月?有些需求无法决定,却要撰写出程序的例子太多了。 190 有些不合理的需求,本身确实不合理,但有些看似不合理的需求,其实可以通过设计 (Design)来解决。以上面的例子来说,取得用户输入、显示结果的环境未定,但你负责的 这部分还是可以先操作。例如: Inheritance GuessGame.java package cc.openhome; public abstract class GuessGame { public void go() { int number = (int) (Math.random() * 10); int guess; do { print("输入数字:"); guess = nextInt(); } while(guess != number); println("猜中了"); } public abstract void print(String text); public abstract void println(String text); public abstract int nextInt(); } 这个类的定义不完整,print()、println()与 nextInt()都是抽象方法,因为老板还没决 定在哪个环境执行猜数字游戏,所以如何显示输出、取得用户输入就不能操作。可以先操 作的是猜数字的流程,虽然是抽象方法,但在 go()方法中,还是可以调用。 等到下星期开会决定,终于还是在文本模式下执行猜数字游戏,你就再撰写 ConsoleGame 类,继承抽象类 GuessGame,操作当中的抽象方法即可: Inheritance ConsoleGame.java package cc.openhome; import java.util.Scanner; public class ConsoleGame extends GuessGame { private Scanner scanner = new Scanner(System.in); @Override public void print(String text) { System.out.print(text); } @Override public void println(String text) { System.out.println(text); } 继承与多态 6 191 @Override public int nextInt() { return scanner.nextInt(); } } 实际上只要创建出 ConsoleGame 实例,执行 go()方法过程中调用到 print()、nextInt() 或 println()等方法时,都是执行 ConsoleGame 中定义的流程,完整的猜数字游戏就操作出 来了。例如: Inheritance Guess.java package cc.openhome; public class Guess { public static void main(String[] args) { GuessGame game = new ConsoleGame(); game.go(); } } 一个执行的结果如下: 输入数字:5 输入数字:4 输入数字:3 猜中了 设计上的经验,称为设计模式(Design pattern),上面的例子是 Template method 模式的 实例。如果对其他设计模式有兴趣,可以先从这里开始: http://caterpillar.onlyfun.net/Gossip/DesignPattern/DesignPattern.htm 6.3 重点复习 面向对象中,子类继承父类,避免重复的行为定义,不过并非为了避免重复定义行为 就使用继承。如何正确判断使用继承的时机,以及继承之后如何活用多态,才是学习继承 时的重点。 程序代码重复在程序设计上,就是不好的信号,多个类间出现重复的程序代码时,设 计上可考虑的改进方式之一,就是把相同的程序代码提升为父类。 在 Java 中,继承时使用 extends 关键字,private 成员也会被继承,只不过子类无法直 接存取,必须通过父类提供的方法来存取(如果父类愿意提供访问方法的话)。 在 Java 中,子类只能继承一个父类,继承有个重要的关系,就是子类与父类间会有 is-a 的关系。要开始理解多态,必须先知道你操作的对象是“哪一种”东西。 192 检查多态语法逻辑是否正确,方式是从=号右边往左读:右边是不是一种左边呢(右边 类型是不是左边类型的子类)?如果不是就会编译失败,如果加上扮演(CAST)语法,编译 程序就让程序代码通过编译,不过后果得自行负责,也就是扮演失败,执行时会抛出 ClassCastException。 什么叫多态?以抽象讲法解释,就是使用单一接口操作多种类型的对象。若用 6.1.2 节的范例来理解,在 showBlood()方法中,既可以通过 Role 类型操作 SwordsMan 对象,也可 以通过 Role 类型操作 Magician 对象。 如果某方法区块中真的没有任何程序代码操作,可以使用 abstract 标示该方法为抽象 方法,该方法不用撰写{}区块,直接“;”结束即可。类中若有方法没有操作,并且标示为 abstract,表示这个类定义不完整,定义不完整的类就不能用来生成实例。Java 中规定内 含抽象方法的类,一定要在 class 前标示 abstract,表示这是一个定义不完整的抽象类。 被声明为 protected 的成员,相同包中的类可以直接存取,不同包中的类可以在继承后 的子类直接存取。 Java 中有 public、protected 与 private 三个权限关键字,但实际上有四个权限范围, 如表 6.1 所示。 如果想取得父类中的方法定义,可以在调用方法前,加上 super 关键字。重新定义方 法要注意,对于父类中的方法权限,只能扩大但不能缩小。在 JDK5 之后,重新定义方法 时,如果返回类型是父类中方法返回类型的子类,也是可以通过编译的。 如果子类构造函数中没有指定执行父类中哪个构造函数,默认会调用父类中无参数构 造函数。如果想执行父类中某构造函数,可以使用 super()指定。this()与 super()只能择一 调用,而且一定要在构造函数第一行执行。 如果 class 前使用了 final 关键字定义,那么表示这个类是最后一个了,不会再有子类, 也就是不能被继承。定义方法时,也可以限定该方法为 final,这表示最后一次定义方法了, 也就是子类不可以重新定义 final 方法。 如 果 定 义 类 时 没 有 使 用 extends 关键字指定继承任何类,那一定是继承 java.lang.Object。在 Java 中,任何类追溯至最上层父类,一定就是 java.lang.Object。 对于不再有用的对象,JVM 有垃圾收集机制,收集到的垃圾对象所占据的内存空间, 会被垃圾收集器释放。执行流程中,无法通过变量参考的对象,就是 GC 认定的垃圾对象。 6.4 课后练习 6.4.1 选择题 1. 如果有以下程序片段: class Some { void doService() { System.out.println("some service"); } 继承与多态 6 193 } class Other extends Some { @Override void doService() { System.out.println("other service"); } } public class Main { public static void main(String[] args) { Other other = new Other(); other.doService(); } } 以下描述正确的是( )。 A. 编译失败 B. 显示 some service C. 显示 other service D. 先显示 some service、后显示 other service 2. 承上题,如果 main()中改为: Some some = new Other(); some.doService(); 以下描述正确的是( )。 A. 编译失败 B. 显示 some service C. 显示 other service D. 先显示 some service、后显示 other service 3. 如果有以下程序片段: class Some { String ToString() { return "Some instance"; } } public class Main { public static void main(String[] args) { Some some = new Some(); System.out.println(some); } } 以下描述正确的是( )。 A. 显示 Some instance B. 显示 Some@XXXX,XXXX 为 16 进制数字 C. 发生 ClassCastException D. 编译失败 4. 如果有以下程序片段: class Some { int hashCode() { return 99; 194 } } public class Main { public static void main(String[] args) { Some some = new Some(); System.out.println(some.hashCode()); } } 以下描述正确的是( )。 A. 显示 99 B. 显示 0 C. 发生 ClassNotFoundException D. 编译失败 5. 如果有以下程序片段: class Some { @Override String ToString() { return "Some instance"; } } public class Main { public static void main(String[] args) { Some some = new Some(); System.out.println(some); } } 以下描述正确的是( )。 A. 显示 Some instance B. 显示 Some@XXXX,XXXX 为 16 进制数字 C. 发生 ClassCastException D. 编译失败 6. 如果有以下程序片段: class Some { abstract void doService(); } class Other extends Some { @Override void doService() { System.out.println("other service"); } } public class Main { public static void main(String[] args) { Some some = new Other(); some.doService(); } } 以下描述正确的是( )。 A. 编译失败 B. 显示 other service 继承与多态 6 195 C. 执行时发生 ClassCastException D. 移除@Override 可编译成功 7. 如果有以下程序片段: class Some { protected int x; Some(int x) { this.x = x; } } class Other extends Some { Other(int x) { this.x = x; } } 以下描述正确的是( )。 A. new Other(10)后,对象成员 x 值为 10 B. new Other(10)后,对象成员 x 值为 10 C. Other 中无法存取 x 的编译失败 D. Other 中无法调用父类构造函数的编译失败 8. 如果有以下程序片段: public class IterableString extends String { public IterableString(String original) { super(original); } public void iterate() { //... } } 以下描述正确的是( )。 A. String s = new IterableString("J")可通过编译 B. IterableString s = new IterableString("J")可通过编译 C. 因无法调用 super()的编译失败 D. 因无法继承 String 的编译失败 9. 如果有以下程序片段: class Some { Some() { this(10); System.out.println("Some()"); } Some(int x) { System.out.println("Some(int x)"); 196 } } class Other extends Some { Other() { super(10); System.out.println("Other()"); } Other(int y) { System.out.println("Other(int y)"); } } 以下描述正确的是( )。 A.new Other()显示"Some(int x)"、"Other()" B. new Other(10)显示"Other(int y)" C. new Some()显示"Some(int x)"、"Some()" D. 编译失败 10. 如果有以下程序片段: class Some { Some() { System.out.println("Some()"); this(10); } Some(int x) { System.out.println("Some(int x)"); } } class Other extends Some { Other() { super(10); System.out.println("Other()"); } Other(int y) { System.out.println("Other(int y)"); } } 以下描述正确的是( )。 A. new Other()显示"Some(int x)"、"Other()" B. new Other(10)显示"Some()"、"Some(int x)"、"Other(int y)" C. new Some()显示"Some(int x)"、"Some()" D. 编译失败 继承与多态 6 197 6.4.2 操作题 1. 如果使用 6.2.5 节设计的 ArrayList 类收集对象,想显示所收集对象的字符串描述时, 必须如下: ArrayList list = new ArrayList(); //...收集对象 for(int i = 0; i < list.size(); i++) { System.out.println(list.get(i)); } 请重新定义 ArrayList 的 toString()方法,让客户端想显示所收集对象的字符串描述时, 可以如下: ArrayList list = new ArrayList(); //...收集对象 System.out.println(list); 2. 承上题,若想比较两个 ArrayList 实例是否相等,希望可以如下比较: ArrayList list1 = new ArrayList(); //...用 list1 收集对象 ArrayList list2 = new ArrayList(); //...用 list2 收集对象 System.out.println(list1.equals(list2)); 请重新定义 ArrayList 的 equals()方法,先比较收集的对象个数,再比较各索引的对象 实质上是否相等(使用各对象的 equals()比较)。 学习目标  了解串流与输入/输出的关系  认识 InputStream、OutputStream 继承架构  认识 Reader、Writer 继承架构  使用输入/输出装饰器类 10 输入/输出 300 10.1 InputStream 与 OutputStream 想活用输入/输出 API,一定要先了解 Java 中如何以串流(Stream)抽象化输入/输出概 念,以及 InputStream、OutputStream 继承架构。如此一来,无论标准输入/输出、文档输入 /输出、网络输入/输出、数据库输入/输出等都可用一致的操作进行处理。 10.1.1 串流设计的概念 Java 将输入/输出抽象化为串流,数据有来源及目的地,衔接两者的是串流对象。比喻 来说,数据就好比水,串流好比水管,通过水管的衔接,水由一端流向另一端,如图 10.1 所示。 目的地 串流对象 数据 来源 图 10.1 串流衔接来源与目的地 从应用程序角度来看,如果要将数据从来源取出,可以使用输入串流,如果要将数据 写入目的地,可以使用输出串流。在 Java 中,输入串流代表对象为 java.io.InputStream 实 例,输出串流代表对象为 java.io.OutputStream 实例。无论数据源或目的地为何,只要设法 取得 InputStream 或 OutputStream 的实例,接下来操作输入/输出的方式都是一致,无须理会 来源或目的地的真正形式,如图 10.2 所示。 应用程序 来源 目的地 Inputstream Outputstream 图 10.2 从应用程序看 InputStream 与 OutputStream 来源与目的地都不知道的情况下,如何撰写程序?听来不可思议,但实际上就是会有 这类需求。举个例子来说,可以设计一个通用的 dump()方法: Stream IO.java package cc.openhome; import java.io.*; public class IO { public static void dump(InputStream src, OutputStream dest)  数据来源与目的地 输入/输出 10 301 throws IOException { try (InputStream input = src; OutputStream output = dest) { byte[] data = new byte[1024]; int length = -1; while ((length = input.read(data)) != -1) { output.write(data, 0, length); } } } } dump()方法接受 InputStream 与 OutputStream 实例,分别代表读取数据的来源,以及输 出数据的目的地。在进行 InputStream 与 OutputStream 的相关操作时若发生错误,会抛出 java.io.IOException 异常,在这里不特别处理,而是在 dump()方法上声明 throws,由调用 dump()方法的客户端处理。 在不使用 InputStream 与 OutputStream 时,必须使用 close()方法关闭串流。由于 InputStream 与 OutputStream 操作了 java.io.Closeable 接 口 , 其 父 接 口 为 java.lang.AutoCloseable 接口,因此可使用 JDK7 尝试自动关闭资源语法。 思考一下,如果不能使用 JDK7 尝试自动关闭资源语法,那使用 try、catch、finally 该 怎么写?可以参考一下 8.2.2 节的内容。 每次从 InputStream 读入的数据,都会先置入 byte 数组中,InputStream 的 read()方法, 每次会尝试读入 byte 数组长度的数据,并返回实际读入的字节,只要不是-1,就表示读取 到数据。可以使用 OutputStream 的 write()方法,指定要写出的 byte 数组、初始索引与数 据长度。 那么这个 dump()方法的来源是什么?不知道。目的地呢。也不知道。dump()方法并没有 限定来源或目的地真实形式,而是依赖于抽象的 InputStream、OutputStream。如果要将某个 文档读入并另存为另一个文档,则可以这么使用: Stream Copy.java package cc.openhome; import java.io.*; public class Copy { public static void main(String[] args) throws IOException { IO.dump( new FileInputStream(args[0]), new FileOutputStream(args[1]) ); } }  客户端要处理异常  读取数据  写出数据  尝试自动关闭资源  尝试每次从来源读取 1024 字节 302 这个程序可以由命令行自变量指定读取的文档来源与写出的目的地,例如: > java cc.openhome.Copy c:\workspace\Main.java C:\workspace\Main.txt 稍后就会介绍串流继承架构,FileInputStream 是 InputStream 的子类,用于衔接文档以 读入数据,FileOutputStream 是 OutputStream 的子类,用于衔接文档以写出数据。 如果要从 HTTP 服务器读取某个网页,并另存为文档,也可以使用这里设计的 dump() 方法。例如: Stream Download.java package cc.openhome; import java.io.*; import java.net.URL; public class Download { public static void main(String[] args) throws IOException { URL url = new URL(args[0]); InputStream src = url.openStream(); OutputStream dest = new FileOutputStream(args[1]); IO.dump(src, dest); } } 虽然没有正式介绍到网络程序设计,不过 java.net.URL 的使用很简单,只要指定网址, URL 实例会自动进行 HTTP 协议。可以使用 openStream()方法取得 InputStream 实例,代表 与网站连接的数据串流。可以这样指定网址下载文档: > java cc.openhome.Download http://openhome.cc c:\workspace\index.txt 无论来源或目的地实体形式为何,只要想办法取得 InputStream 或 OutputStream,接下 来都是调用 InputStream 或 OutputStream 的相关方法。例如,使用 java.net.ServerSocket 接 受客户端联机的例子: ServerSocket server = null; Socket client = null; try { server = new ServerSocket(port); while(true) { client = server.accept(); InputStream input = client.getInputStream(); OutputStream output = client.getOutputStream(); // 接下来就是操作 InputStream、OutputStream 实例了 ... } } catch(IOException ex) { 输入/输出 10 303 ... } 如果将来学到 Servlet,想将文档输出至浏览器,也会有类似的操作: response.setContentType("application/pdf"); InputStream in = this.getServletContext() .getResourceAsStream("/WEB-INF/jdbc.pdf"); OutputStream out = response.getOutputStream(); byte[] data = new byte[1024]; int length = -1; while((length = in.read(data)) != -1) { out.write(data, 0, length); } 10.1.2 串流继承架构 在了解串流抽象化数据源与目的地的概念后,接下来要搞清楚 Java 中 InputStream、 OutputStream 的继承架构。首先看到 InputStream 的常用类继承架构,如图 10.3 所示。 图 10.3 InputStream 常用类继承架构 再来看 OutputStream 的常用类继承架构,如图 10.4 所示。 图 10.4 OutputStream 常用类继承架构 了解 InputStream 与 OutputStream 类继承架构之后,再来逐步说明相关类的使用方式。 1. 标准输入/输出 还记得 System.in 与 System.out 吗?查看 API 文件的话,会发现它们分别是 InputStream 与 PrintStream 的实例,分别代表标准输入(Standard input)与标准输出(Standard output),以 个人计算机而言,通常对应至文本模式中的输入与输出。 以 System.in 而言,因为文本模式下通常是取得整行用户输入,因此较少直接操作 InputStream 相关方法,而是如前面章节使用 java.util.Scanner 打包 System.in,你操作 304 Scanner 相关方法,而 Scanner 会代你操控 System.in 取得数据,并转换为取得你想要的数据 类型。 可以使用 System 的 setIn()方法指定 InputStream 实例,重新指定标准输入来源。例如 下面范例故意将标准输入指定为 FileInputStream,可以读取指定文档并显示在文本模式: Stream StandardIn.java package cc.openhome; import java.io.*; import java.util.*; public class StandardIn { public static void main(String[] args) throws IOException { System.setIn(new FileInputStream(args[0])); try (Scanner scanner = new Scanner(System.in)) { while (scanner.hasNextLine()) { System.out.println(scanner.nextLine()); } } } } System.out 为 PrintStream 实例,从图 10.4 来看,它是一种 OutputStream,所以若要将 10.1.1 节的 Download 范例改为输出至标准输出,也可以这么写: ... URL url = new URL(args[0]); InputStream src = url.openStream(); IO.dump(src, System.out); ... 标准输出可以重新导向至文档,只要执行程序时使用>将输出结果导向至指定的文档。 例如,若 Hello 类执行了 System.out.println("HelloWorld"): > java Hello > Hello.txt 那么上面的指令执行方式,将会将 HelloWorld 导向至 Hello.txt 文档,而不会显示在文 本模式中,如果使用>>则是附加信息。可以使用 System 的 setOut()方法指定 PrintStream 实例,将结果输出至指定的目的地。例如,故意将标准输出指定至文档: Stream StandardOut.java package cc.openhome; import java.io.*; 输入/输出 10 305 public class StandardOut { public static void main(String[] args) throws IOException { try (PrintStream printStream = new PrintStream( new FileOutputStream(args[0]))) { System.setOut(printStream); System.out.println("HelloWorld"); } } } PrintStream 接受 InputStream 实例,在这个范例中用 PrintStream 打包 FileOutputStream, 你操作 PrintStream 相关方法,PrintStream 会代你操作 FileOutputStream。 除了 System.in 与 System.out 之外,还有个 System.err,为 PrintSteam 实例,称为标准 错误输出串流,它是用来立即显示错误信息。例如,在文本模式下,System.out 输出的信 息可以使用>或>>重新导向至文档,但 System.err 输出的信息一定会显示在文本模式中, 无法重新导向。也可以使用 System.setErr()指定 PrintStream,重新指定标准错误输出串流。 2. FileInputStream 与 FileOutputStream FileInputStream 是 InputStream 的子类,可以指定文件名创建实例,一旦创建文档就开 启,接着就可用来读取数据。FileOutputStream 是 OutputStream 的子类,可以指定文件名创 建实例,一旦创建文档就开启,接着就可以用来写出数据。无论 FileInputStream 还是 FileOutputStream,不使用时都要使用 close()关闭文档。 FileInputStream 主要操作了 InputStream 的 read()抽象方法,使之可从文档中读取数据, FileOutputStream 主要操作了 OutputStream 的 write()抽象方法,使之可写出数据至文档, 前面的 IO.dump()方法中已示范过 read()与 write()方法。 FileInputStream、FileOutputStream 在读取、写入文档时,是以字节为单位,通常会使 用一些高阶类加以打包,进行一些高阶操作,像是前面示范过的 Scanner 与 PrintStream 类 等。之后还会看到更多打包 InputStream 、 OutpuStream 的类,它们也可以用来打包 FileInputStream、FileOutputStream。 3. ByteArrayInputStream 与 ByteArrayOutputStream ByteArrayInputStream 是 InputStream 的子类,可以指定 byte 数组创建实例,一旦创建 就可将 byte 数组当作数据源进行读取。ByteArrayOutputStream 是 OutputStream 的子类,可 以指定 byte 数组创建实例,一旦创建将 byte 数组当作目的地写出数据。 ByteArrayInputStream 主要操作了 InputStream 的 read()抽象方法,使之可从 byte 数组 中读取数据。ByteArrayOutputStream 主要操作了 OutputStream 的 write()抽象方法,使之可 写出数据至 byte 数组。前面的 IO.dump()方法中示范过的 read()与 write()方法,就是 ByteArrayInputStream、ByteArrayOutputStream 的操作范例,毕竟它们都是 InputStream、 OutputStream 的子类。 306 10.1.3 串流处理装饰器 InputStream、OutputStream 提供串流基本操作,如果想要为输入/输出的数据做加工处理, 则可以使用打包器类。前面示范过的 Scanner 类就是作为打包器,其接受 InputStream 实例, 你操作 Scanner 打包器相关方法,Scanner 会实际操作打包的 InputStream 取得数据,并转换 为你想要的数据类型。 InputStream、OutputStream 的一些子类也具有打包器的作用,这些子类创建时,可以接 受 InputStream、OutputStream 实例。前面介绍的 PrintStream 就是实际例子,你操作PrintStream 的 print()、println()等方法,PrintStream 会自动转换为 byte 数组数据,利用打包的 OutputStream 进行输出。 常用的打包器有具备缓冲区作用的 BufferedInputStream、 BufferedOutputStream,具备数据转 换处理作用的 DataInputStream、 DataOutputStream,具备对象串行化能力的 ObjectInputStream、 ObjectOutputStream 等。 由于这些类本身并没有改变 InputStream、OutputStream 的行为,只不过在 InputStream 取得数据之后,再做一些加工处理,或者是要输出时做一些加工处理,再交由 OutputStream 真正进行输出,因此又称它们为装饰器(Decorator)。就像照片本身装上华丽外框,就可以 让照片感觉更为华丽,或有点像小水管衔接大水管,如小水管(InputStream)读入数据,再 由大水管(如 BufferedInputStream)增加缓冲功能,如图 10.5 所示。 应用程序 来源 目的地 图 10.5 装饰器提供高阶操作 下面介绍几个常用的串流装饰器类。 1. BufferedInputStream 与 BufferedOutputStream 在前面 IO.dump()方法中,每次调用 InputStream 的 read()方法,都会直接向来源要求数 据,每次调用 OutputStream 的 write()方法时,都会直接将数据写到目的地,这并不是个有 效率的方式。 以文档存取为例,如果传入 IO.dump()的是 FileInputStream、FileOutputStream 实例,每 次 read()时都会要求读取硬盘,每次 write()时都会要求写入硬盘,这会花费许多时间在硬 盘定位上。 如果 InputStream 第一次 read()时可以尽量读取足够的数据至内存的缓冲区,后续调用 read()时先看看缓冲区是不是还有数据,如果有就从缓冲区读取,没有再从来源读取数据 输入/输出 10 307 至缓冲区,这样减少从来源直接读取数据的次数,对读取效率将会有帮助(毕竟内存的访问 速度较快)。 如果 OutputStream 每次 write()时可将数据写入内存中的缓冲区,缓冲区满了再将缓冲 区的数据写入目的地,这样可减少对目的地的写入次数,对写入效率将会有帮助。 BufferedInputStream 与 BufferedOutputStream 提供的就是前面描述的缓冲区功能,创建 BufferedInputStream、BufferedOutputStream 必须提供 InputStream、OutputStream 进行打包, 可以使用默认或自定义缓冲区大小。 BufferedInputStream 与 BufferedOutputStream 主要在内部提供缓冲区功能,操作上与 InputStream、OutputStream 并没有太大差别。例如,改写前面的 IO.dump()为 BufferedIO.dump() 方法: Stream BufferedIO.java package cc.openhome; import java.io.*; public class BufferedIO { public static void dump(InputStream src, OutputStream dest) throws IOException { try(InputStream input = new BufferedInputStream(src); OutputStream output = new BufferedOutputStream(dest)) { byte[] data = new byte[1024]; int length = -1; while ((length = input.read(data)) != -1) { output.write(data, 0, length); } } } } 2. DataInputStream 与 DataOutputStream DataInputStream、DataOutputStream 用来装饰 InputStream、OutputStream,DataInputStream、 DataOutputStream 提供读取、写入 Java 基本数据类型的方法,像是读写 int、double、 boolean 等的方法。这些方法会自动在指定的类型与字节间转换,不用你亲自做字节与类型转换的 动作。 来看个实际使用 DataInputStream、DataOutputStream 的例子。下面的 Member 类可以调用 save()储存 Member 实例本身的数据,文件名为 Member 的会员号码,调用 Member.load()指定 会员号码,则可以读取文档中的会员数据,封装为 Member 实例并返回: 308 Stream Member.java package cc.openhome; import java.io.*; public class Member { private String number; private String name; private int age; public Member(String number, String name, int age) { this.number = number; this.name = name; this.age = age; } // 部分程序代码省略,因为只一些 Getter、Setter... @Override public String toString() { return String.format("(%s, %s, %d)", number, name, age); } public void save() { try(DataOutputStream output = new DataOutputStream(new FileOutputStream(number))) { output.writeUTF(number); output.writeUTF(name); output.writeInt(age); } catch(IOException ex) { throw new RuntimeException(ex); } } public static Member load(String number) { Member member = null; try(DataInputStream input = new DataInputStream(new FileInputStream(number))) { member = new Member( input.readUTF(), input.readUTF(), input.readInt()); } catch(IOException ex) { throw new RuntimeException(ex); } return member; }  根据不同的类型调用 writeXXX()方法  根据不同的类型调用 readXXX()方法  建立 DataOutputStream 打包 FileOutputStream  建立 DataInputStream 打包 FileInputStream 输入/输出 10 309 } 在 save()方法中,使用 DataOutputStream 打包 FileOutputStream,储存 Member 实例时, 会使用 writeUTF()、writeInt()方法分别储存字符串与 int 类型。在 load()方法中,则使 用 DataInputStream 打包 FileInputStream,并调用 readUTF()、readInt()分别读入字符串、 int 类型。下面是个使用 Member 类的例子: Stream MemberDemo.java package cc.openhome; public class MemberDemo { public static void main(String[] args) { Member[] members = {new Member("B1234", "Justin", 90), new Member("B5678", "Monica", 95), new Member("B9876", "Irene", 88)}; for(Member member : members) { member.save(); } System.out.println(Member.load("B1234")); System.out.println(Member.load("B5678")); System.out.println(Member.load("B9876")); } } 范例中准备了三个 Member 实例,分别储存为文档之后再读取回来。执行结果如下: (B1234, Justin, 90) (B5678, Monica, 95) (B9876, Irene, 88) 3. ObjectInputStream 与 ObjectOutputStream 前面的范例是取得 Member 的 number、name、age 数据进行储存,读回时也是先取得 number、 name、age 数据再用来创建 Member 实例。实际上,也可以将内存中的对象整个储存下来,之 后再读入还原为对象。可以使用 ObjectInputStream、ObjectOutputStream 装饰 InputStream、 OutputStream 来完成这项工作。 ObjectInputStream 提供 readObject()方法将数据读入为对象,而 ObjectOutputStream 提 供 writeObject()方法将对象写至目的地,可以被这两个方法处理的对象,必须操作 java.io.Serializable 接口,这个接口并没有定义任何方法,只是作为标示之用,表示这个 对象是可以串行化的(Serializable)。 下面这个范例改写前一个范例,使用 ObjectInputStream、ObjectOutputStream 来储存、 读入数据: 310 Stream Member2.java package cc.openhome; import java.io.*; public class Member2 implements Serializable { private String number; private String name; private int age; public Member2(String number, String name, int age) { this.number = number; this.name = name; this.age = age; } // 部分程序代码省略,因为只一些 Getter、Setter... @Override public String toString() { return String.format("(%s, %s, %d)", number, name, age); } public void save() { try(ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream(number))) { output.writeObject(this); } catch(IOException ex) { throw new RuntimeException(ex); } } public static Member2 load(String number) { Member2 member = null; try(ObjectInputStream input = new ObjectInputStream(new FileInputStream(number))) { member = (Member2) input.readObject(); } catch(IOException | ClassNotFoundException ex) { throw new RuntimeException(ex); } return member; } }  实作 Serializable  建立 DataOutputStream 打包 FileOutputStream  调用 writeObject()方法写出对象  建立 DataInputStream 打包 FileInputStream  调用 readObject()方法读入对象 输入/输出 10 311 为了能够直接将对象写出或读入,Member2 操作了 Serializable,在储存对象时,使 用 ObjectOutputStream 打包 FileOutputStream,ObjectOutputStream 的 writeObject()处理内 存中的对象数据,再交给 FileOutputStream 写至文档 。 在 读 入 对 象 时 , 使 用 ObjectInputStream 打包 FileInputStream,在 readObject()时,会用 FileInputStream 读入字 节数据,再交给 ObjectInputStream 处理,还原为 Member2 实例。 下面的程序用来测试 Member2 类是否可正确写出与读入对象,执行结果与 MemberDemo 是相同的: Stream Member2Demo.java package cc.openhome; public class Member2Demo { public static void main(String[] args) { Member2[] members = {new Member2("B1234", "Justin", 90), new Member2("B5678", "Monica", 95), new Member2("B9876", "Irene", 88)}; for(Member2 member : members) { member.save(); } System.out.println(Member2.load("B1234")); System.out.println(Member2.load("B5678")); System.out.println(Member2.load("B9876")); } } 如果在做对象串行化时,对象中某些数据成员不希望被写出,则可以标上 transient 关键字。 10.2 字符处理类 InputStream、OutputStream 是用来读入与写出字节数据,若实际上处理的是字符数据, 使用 InputStream、OutputStream 就得对照编码表,在字符与字节之间进行转换。所幸 Java SE API 已提供相关输入/输出字符处理类,让你不用亲自进行字节与字符编码转换的枯燥工作。 10.2.1 Reader 与 Writer 继承架构 针对字符数据的读取,Java SE 提供了 java.io.Reader 类,其抽象化了字符数据读入 的来源。针对字符数据的写入,则提供了 java.io.Writer 类,其抽象化了数据写出的目的地。 举个例子来说,如果想从来源读入字符数据,或将字符数据写至目的地,都可以使用 下面的 CharUtil.dump()方法: 312 Stream CharUtil.java package cc.openhome; import java.io.*; public class CharUtil { public static void dump(Reader src, Writer dest) throws IOException { try(Reader input = src; Writer output = dest) { char[] data = new char[1024]; int length = 0; while((length = input.read(data)) != -1) { output.write(data, 0, length); } } } } dump()方法接受 Reader 与 Writer 实例,分别代表读取数据的来源,以及输出数据的目 的地。在进行 Reader 与 Writer 的相关操作时若发生错误,会抛出 IOException 异常,在 这里不特别处理,而是在 dump()方法上声明 throws,由调用 dump()方法的客户端处理。 在不使用 Reader 与 Writer 时,必须使用 close()方法关闭串流。由于 Reader 与 Writer 操作了 Closeable 接口,其父接口为 AutoCloseable 接口,因此可使用 JDK7 尝试自动关闭 资源语法。 每次从 Reader 读入的数据,都会先置入 char 数组中。Reader 的 read()方法,每次会 尝试读入 char 数组长度的数据,并返回实际读入的字符数,只要不是-1,就表示读取到字 符。可以使用 Writer 的 write()方法,指定要写出的 byte 数组、初始索引与数据长度。 同样地,了解 Reader、Writer 继承架构会有利于 API 的灵活运用。首先看 Reader 继承 架构,如图 10.6 所示。 图 10.6 中列出了几个常用的 Reader 子类,再来看看 Writer 常用类继承架构,如图 10.7 所示。 图 10.6 Reader 继承架构 图 10.7 Writer 继承架构 从图 10.6 与图 10.7 得知,FileReader 是一种 Reader,主要用于读取文档并将读到的数  尝试自动关闭资源  尝试每次从来源读取 1024 字符  数据来源与目的地  客户端要处理异常  写出数据  读取数据 输入/输出 10 313 据转换为字符;StringWriter 是一种 Writer,可以将字符数据写至 StringWriter,最后使用 toString()方法取得字符串,代表所有写入的字符数据。所以,若要使用 CharUtil.dump() 读入文档、转为字符串并显示在文本模式中,可以如下: Stream CharUtilDemo.java package cc.openhome; import java.io.*; public class CharUtilDemo { public static void main(String[] args) throws IOException { FileReader reader = new FileReader(args[0]); StringWriter writer = new StringWriter(); CharUtil.dump(reader, writer); System.out.println(writer.toString()); } } 如果执行 CharUtilDemo 时,在命令行自变量指定了文档位置,若文档中实际都是字符 数据,就可以在文本模式中看到文档中的文字内容。 稍微解释一下几个常用的 Reader、Writer 子类。StringReader 可以将字符串打包,当作 读取来源,StringWriter 则可以作为写入目的地,最后用 toString()取得所有写入的字符组 成的字符串。CharArrayReader、CharArrayWriter 则类似,将 char 数组当作读取来源以及写 入目的地。 FileReader、FileWriter 可以对文档做读取与写入,读取或写入时默认会使用操作系统 默认编码来做字符转换。也就是说,如果你的操作系统默认编码是 GB2312,则 FileReader、 FileWriter 会以 GB2312 对你的“纯文本文档”做读取、写入的动作,如果操作系统默认 编码是 UTF-8,则 FileReader、FileWriter 就使用 UTF-8。 在启动 JVM 时,可以指定-Dfile.encoding 来指定 FileReader、FileWriter 所使用的编码。 例如,指定使用 UTF-8: > java –Dfile.encoding=UTF-8 cc.openhome.CharUtil sample.txt FileReader、FileWriter 没有可以指定编码的方法。如果在程序执行过程中想要指定编 码,则必须使用 InputStreamReader、OutputStreamWriter,这两个类实际上是作为装饰器。 在 10.2.2 节中一并说明。 纯文本文档?编码?如果你看到这些名词不太懂的话,建议参考一下“乱码 1/2”: http://caterpillar.onlyfun.net/Gossip/Encoding/ 10.2.2 字符处理装饰器 正如同 InputStream、OutputStream 有一些装饰器类,可以对 InputStream、OutputStream 打包增加额外功能,Reader、Writer 也有一些装饰器类可供使用。下面介绍常用的字符处 理装饰器类。 314 1. InputStreamReader 与 OutputStreamWriter 如果串流处理的字节数据,实际上代表某些字符的编码数据,而你想要将这些字节数 据转换为对应的编码字符,可以使用 InputStreamReader、OutputStreamWriter 对串流数据打包。 在建立 InputStreamReader 与 OutputStreamWriter 时,可以指定编码,如果没有指定编码, 则以 JVM 启动时所获取的默认编码来做字符转换。下面将 CharUtil 的 dump()改写,提供可 指定编码的 dump()方法: Stream CharUtil2.java package cc.openhome; import java.io.*; public class CharUtil2 { public static void dump(Reader src, Writer dest) throws IOException { try(Reader input = src; Writer output = dest) { char[] data = new char[1024]; int length = 0; while((length = input.read(data)) != -1) { output.write(data, 0, length); } } } public static void dump(InputStream src, OutputStream dest, String charset) throws IOException { dump( new InputStreamReader(src, charset), new OutputStreamWriter(dest, charset) ); } // 采用默认编码 public static void dump(InputStream src, OutputStream dest) throws IOException { dump(src, dest, System.getProperty("file.encoding")); } } 如果想以 UTF-8 处理字符数据,例如读取 UTF-8 的 Main.java 文本文件,并另存为 UTF-8 的 Main.txt 文本文件,则可以如下: CharUtil2.dump( new FileInputStream("Main.java"), 输入/输出 10 315 new FileOutputStream("Main.txt"), "UTF-8" ); 2. BufferedReader 与 BufferedWriter 正如 BufferedInputStream、BufferedOutputStream 为 InputStream、OutputStream 提供缓冲 区作用,以改进输入/输出的效率,BufferedReader、BufferedWriter 可对 Reader、Writer 提 供缓冲区作用,在处理字符输入/输出时,对效率也会有所帮助。 举个使用 BufferedReader 的例子。在 JDK 1.4 之前,标准 API 并没有 Scanner 类,若要 在文本模式下取得用户输入的字符串,会这样撰写: BufferedReader reader = new BufferedReader( new InputStreamReader(System.in)); String name = reader.readLine(); System.out.printf("Hello, %s!", name); 创建 BufferedReader 时要指定被打包的 Reader,可以指定或采用默认缓冲区大小。就 API 的使用而言,System.in 是 InputStream 实例,可以指定给 InputStreamReader 创建之用, InputStreamReader 是一种 Reader,所以可指定给 BufferedReader 创建之用。 就装饰器的作用而言,InputStreamReader 将 System.in 读入的字节数据做编码转换,而 BufferedReader 将编码转换后的数据做缓冲处理,以增加读取效率。BufferedReader 的 readLine()方法,可以读取一行数据(以换行字符为依据)并以字符串返回,返回的字符串不 包括换行字符。 3. PrintWriter PrintWriter 与 PrintStream 使用上极为类似,不过除了可以对 OutputStream 打包之外, PrintWriter 还可以对 Writer 进行打包,提供 print()、println()、format()等方法。 JDK1.4 开始提供了 NIO API,提供了非阻断式 IO,并提供 Buffer、Selector、Channel、Charset 等 进阶 API,有兴趣的话可以参考以下讨论串中的文件作为开始: http://www.javaworld.com.tw/jute/post/view?bid=20&id=31250&tpg=2&ppg=1&sty=0&age=0 10.3 重点复习 从应用程序角度来看,如果要将数据从来源取出,可以使用输入串流;如果要将数据 写入目的地,可以使用输出串流。在 Java 中,输入串流代表对象为 java.io.InputStream 实例,输出串流代表对象为 java.io.OutputStream 实例。无论数据源或目的地为何,只要设 法取得 InputStream 或 OutputStream 的实例,接下来操作输入/输出的方式都是一致,无须理 会来源或目的地的真正形式。 在不使用 InputStream 与 OutputStream 时,必须使用 close()方法关闭串流。由于 InputStream 与 OutputStream 操 作了 java.io.Closeable 接 口 , 其 父 接 口 为 java.lang.AutoCloseable 接口,因此可使用 JDK7 尝试自动关闭资源语法。 316 FileInputStream 是 InputStream 的子类,可以指定文件名创建实例,一旦创建文档就开 启,接着就可用来读取数据。FileOutputStream 是 OutputStream 的子类,可以指定文件名创 建实例,一旦创建文档就开启,接着就可以用来写出数据。无论 FileInputStream 还是 FileOutputStream,不使用时都要使用 close()关闭文档。 ByteArrayInputStream 是 InputStream 的子类,可以指定 byte 数组创建实例,一旦创建 就可将 byte 数组当作数据源进行读取。ByteArrayOutputStream 是 OutputStream 的子类,可 以指定 byte 数组创建实例,一旦创建将 byte 数组当作目的地写出数据。 InputStream、OutputStream 提供串流基本操作,如果想要为输入/输出的数据做加工处理, 则可以使用打包器类。常用的打包器有具备缓冲区作用的 BufferedInputStream 、 BufferedOutputStream,具备数据转换处理作用的 DataInputStream、 DataOutputStream,具备对象串行化 能力的 ObjectInputStream、 ObjectOutputStream 等。 针对字符数据的读取,Java SE 提供了 java.io.Reader 类,其抽象化了字符数据读入 的来源。针对字符数据的写入,则提供了 java.io.Writer 类,其抽象化了数据写出的目的地。 FileReader、FileWriter 则可以对文档做读取与写入,读取或写入时默认会使用操作系 统默认编码来做字符转换。在启动 JVM 时,可以指定-Dfile.encoding 来指定 FileReader、 FileWriter 所使用的编码。 Reader、Writer 也有一些装饰器类可供使用。如果串流处理的字节数据,实际上代表某 些字符的编码数据,而你想要将这些字节数据转换为对应的编码字符,可以使用 InputStreamReader、OutputStreamWriter 对串流数据打包。BufferedReader、BufferedWriter 可对 Reader、Writer 提供缓冲区作用,在处理字符输入/输出时,对效率也会有所帮助。 PrintWriter 与 PrintStream 使用上极为类似,不过除了可以对 OutputStream 打包之外, PrintWriter 还可以对 Writer 进行打包,提供 print()、println()、format()等方法。 10.4 课后练习 10.4.1 选择题 1. 输入/输出串流的父类是( )两个。 A. InputStream B. Reader C. OutputStream D. Writer 2. 处理字符输入/输出的父类是( )两个。 A. InputStream B. Reader C. OutputStream D. Writer 3. 以下( )两个类为 InputStream、OutputStream 提供缓冲区作用。 A. BufferedInputStream B. BufferedReader C. BufferedOutputStream D. BufferedWriter 4. 以下( )两个类为 Reader、Writer 提供缓冲区作用。 A. BufferedInputStream B. BufferedReader C. BufferedOutputStream D. BufferedWriter 输入/输出 10 317 5. 如果有以下程序片段: ObjectInputStream input = new ObjectInputStream(new ________________); 空白部分指定( )类型可以通过编译。 A. FileInputStream("Account.data") B. FileReader("Main.java") C. InputStreamReader(new FileReader("Main.java")) D. ObjectReader("Account.data") 6. 如果有以下程序片段: BufferedReader reader = new BufferedReader(new ________________); 空白部分指定( )类型可以通过编译。 A. FileInputStream("Account.data") B. FileReader("Main.java") C. InputStreamReader(new FileInputStream("Main.java")) D. ObjectReader("Account.data") 7. 以下( )两个类分别拥有 readObject()、writeObject()方法。 A. BufferedInputStream B. ObjectInputStream C. ObjectOutputStream D. BufferedOutputStream 8. 以下( )两个类为 InputStream、OutputStream 提供编码转换作用。 A. BufferedInputStream B. InputStreamReader C. BufferedOutputStream D. OutputStreamWriter 9. 以下( )两个类为 Reader、Writer 提供编码转换作用。 A. BufferedInputStream B. InputStreamReader C. BufferedOutputStream D. 以上皆非 10. 以下( )类位于 java.io 包中。 A. BufferedInputStream B. IOException C. Scanner D. BufferedReader 10.4.2 操作题 1. 在异常发生时,可以使用异常对象的 printStackTrace()显示堆栈追踪,如何改写以 下程序,使得异常发生时,可将堆栈追踪附加至 UTF-8 编码的 exception.log 文档: package cc.openhome; import java.io.*; public class Exercise1 { public static void dump(InputStream src, OutputStream dest) throws IOException { try (InputStream input = src; OutputStream output = dest) { byte[] data = new byte[1024]; 318 int length = -1; while ((length = input.read(data)) != -1) { output.write(data, 0, length); } } catch(IOException ex) { throw ex; } } } 2. 请撰写程序,可将任何编码的文本文件读入,指定文档名转存为 UTF-8 的文本 文件。 学习目标  了解 JDBC 架构  使用 JDBC API  了解交易与隔离层级  认识 RowSet 14 整合数据库 454 14.1 JDBC 入门 JDBC 是用于执行 SQL 的解决方案,开发人员使用 JDBC 的标准接口,数据库厂商则 对接口进行操作,开发人员无须接触底层数据库驱动程序的差异性。在本章中,会介绍一 些 JDBC 基本 API 的使用与概念,让你对 Java 如何存取数据库有所认识。 14.1.1 JDBC 简介 在正式介绍 JDBC 前,先来认识应用程序如何与数据库进行沟通。数据库本身是个独 立运行的应用程序,你撰写的应用程序是利用通信协议对数据库进行指令交换,以进行数 据的增删查找,如图 14.1 所示。 应用程序 通信协议 数据库 图 14.1 应用程序与数据库利用通信协议沟通 通常你的应用程序会利用一组专门与数据库进行通信协议的链接库,以简化与数据库 沟通时的程序撰写,如图 14.2 所示。 应用程序 链接库 通信协议 资料库 图 14.2 应用程序调用链接库以简化程序撰写 问题的重点在于,应用程序如何调用这组链接库?不同的数据库通常会有不同的通信 协议,用来联机不同数据库的链接库,在 API 上也会有所不同。如果你的应用程序直接使 用这些链接库,例如: XySqlConnection conn = new XySqlConnection("localhost", "root", "1234"); conn.selectDB("gossip"); XySqlQuery query = conn.query("SELECT * FROM T_USER"); 假设这段程序代码中的 API 是某 Xy 数据库厂商链接库所提供,你的应用程序中要使 用到数据库联机时,都会直接调用这些 API,若哪天应用程序打算改用 Ab 厂商数据库及 其提供的数据库联机 API,就得修改相关的程序代码。 另一个考虑是,若 Xy 数据库厂商的链接库底层实际使用了与操作系统相依的功能, 若你只打算换个操作系统,就还得先考虑一下,是否有提供该平台的数据库链接库。 更换数据库的需求并不是没有,应用程序跨平台也是经常的需求,JDBC 基本上就是 用来解决这些问题。JDBC 全名 Java DataBase Connectivity,是 Java 联机数据库的标准 规范。具体而言,它定义一组标准类与接口,应用程序需要联机数据库时调用这组标准 API, 整合数据库 14 455 而标准 API 中的接口会由数据库厂商操作,通常称为 JDBC 驱动程序(Driver),如图 14.3 所示。 应用程序 JDBC 标准 API JDBC 驱动程序 通信协议 数据库 图 14.3 应用程序调用 JDBC 标准 API JDBC 标准主要分为两个部分:JDBC 应用程序开发者接口(Application Developer Interface)以及 JDBC 驱动程序开发者接口(Driver Developer Interface)。如果你的应用程序需 要联机数据库,就是调用 JDBC 应用程序开发者接口,相关 API 主要在 java.sql 与 javax.sql 两个包中,也是本章节说明的重点;JDBC 驱动程序开发者接口是数据库厂商操作驱动程 序时的规范,一般开发者并不用了解,本书不予说明,如图 14.4 所示。 应用程序 驱动程序 图 14.4 JDBC 应用程序开发者接口 举个例子来说,你的应用程序会使用 JDBC 联机数据库: Connection conn = DriverManager.getConnection(…); Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT * FROM T_USER"); 其中粗体字部分就是标准类(像是 DriverManager)与接口(像是 Connection、Statement、 ResultSet)等标准 API。假设这段程序代码是联机 MySQL 数据库,则需要在 CLASSPATH 中设定 JDBC 驱动程序,具体来说,就是在 CLASSPATH 中设定一个 JAR 文档,此时应 用程序、JDBC 与数据库的关系如图 14.5 所示。 456 应用程序 MySQL JDBC API 驱动程序 MySQL 通信协议 MySQL 数据库 图 14.5 应用程序、JDBC 与数据库的关系 如果将来要换为 Oracle 数据库,只要置换 Oracle 驱动程序。具体来说,就是在 CLASSPATH 改设为 Oracle 驱动程序的 JAR 文档,然而应用程序本身不用修改,如图 14.6 所示。 保持不变 应用程序 Oracle JDBC 驱动程序 Oracle 通信协议 Oracle 数据库 图 14.6 置换驱动程序不用修改应用程序 如果开发应用程序操作数据库时,是通过 JDBC 提供的接口来设计程序,理论上在必 须更换数据库时,应用程序无须进行修改,只需要更换数据库驱动程序成果,即可对另一 个数据库进行操作。 JDBC 希望达到的目的,是让 Java 程序设计人员在撰写数据库操作程序时,可以有个 统一的接口,无须依赖特定数据库 API,希望达到“写一个 Java 程序,操作所有数据库” 的目的。 实际上在撰写 Java 程序时,会因为使用了数据库特定功能,而在转移数据库时仍得对程 序进行修改。例如使用了某数据库的特定 SQL 语法、数据类型或内部函数调用等。 厂商在操作 JDBC 驱动程序时,依操作方式可将驱动程序分为四种类型。  Type 1:JDBC-ODBC Bridge Driver。ODBC(Open DataBase Connectivity)是由 Microsoft 主导的数据库连接标准,(基本上 JDBC 是参考 ODBC 制订而来),所以 ODBC 在 Microsoft 系统上最为成熟,例如 Microsoft Access 数据库存取就是使用 ODBC。 Type 1 驱动程序会将 JDBC 调用转换为对 ODBC 驱动程序的调用,由 ODBC 驱 动程序操作数据库,如图 14.7 所示。 应用程序 Type 1 驱动程序 ODBC 驱动程序 通信协议 数据库 图 14.7 JDBC-ODBC Bridge Driver 整合数据库 14 457 由于利用现成的 ODBC 架构,只需要将 JDBC 调用转换为 ODBC 调用,所以要操作 这种驱动程序非常简单。在 Oracle/Sun JDK 中就附带有驱动程序,包名称为 sun.jdbc.odbc 开头。 不过由于 JDBC 与 ODBC 并非一对一的对应,所以部分调用无法直接转换,因此有些 功能受限,而多层调用转换的结果,访问速度也受到限制,ODBC 本身需在平台上先设定 好,弹性不足,ODBC 驱动程序本身也有跨平台限制。  Type 2:Native API Driver。这个类型的驱动程序会以原生(Native)方式,调用数 据库提供的原生链接库(通常由 C/C++操作),JDBC 的方法调用都会转换为原生链 接库中的相关 API 调用。由于使用了原生链接库,所以驱动程序本身与平台相依, 没有达到 JDBC 驱动程序的目标之一:跨平台。不过由于直接调用数据库原生 API, 因此在速度上,有机会成为四种类型中最快的驱动程序,如图 14.8 所示。 应用程序 Type 2 驱动程序 通信协议 数据库 链接库(C/C++) 图 14.8 Native API Driver Type 2 驱动程序有机会成为速度最快的驱动程序,速度的优势是在于获得数据库 响应数据后,创建相关 JDBC API 操作对象时,然而驱动程序本身无法跨平台, 使用前必须先在各平台进行驱动程序的安装设定(像是安装数据库专属的原生链 接库)。  Type 3:JDBC-Net Driver。这类型的 JDBC 驱动程序会将 JDBC 方法调用转换为 特定的网络协议(Protocol)调用,目的是与远程与数据库特定的中介服务器或组件 进行协议操作,而中介服务器或组件再真正与数据库进行操作,如图 14.9 所示。 应用程序 Type 3 驱动程序 通信协议 通信协议 数据库 图 14.9 JDBC-Net Driver 由于实际与中介服务器或组件进行沟通时,是利用网络协议的方式,所以客户端这里 安装的驱动程序,可以使用纯粹的 Java 技术来实现(基本上就是将 JDBC 调用对应至网络 协议而已),因此这种类型的驱动程序可以跨平台。使用这种类型驱动程序的弹性高,例如 可以设计一个中介组件,JDBC 驱动程序与中介组件间的协议是固定的,如果需要更换数 据库系统,则只需要更换中介组件,但客户端不受影响,驱动程序也无须更换,但由于通 过中介服务器转换,速度较慢,获得架构弹性是使用这种类型驱动程序的目的。 458  Type 4:Native Protocol Driver。这种类型驱动程序操作通常由数据库厂商直接提 供,驱动程序操作会将 JDBC 调用转换为与数据库特定的网络协议,以与数据库进 行沟通操作,如图 14.10 所示。 应用程序 Type 4 驱动程序 通信协议 数据库 图 14.10 Notive Protocla Driver 由于这种类型驱动程序主要的作用,是将 JDBC 调用转换为特定网络协议,所以驱动 程序可以使用纯粹 Java 技术实现,因此这种类型驱动程序可以跨平台,在效能上也能有 不错的表现。在不需要如 Type 3 获得架构上的弹性时,通常会使用这种类型驱动程序, 算是最常见的驱动程序类型。 在接下来的内容中,将使用 MySQL 数据库系统进行操作,并使用 Type 4 驱动程序。 可以在以下的网址取得 MySQL 的 JDBC 驱动程序: http://www.mysql.com/products/connector/j/index.html 数据库系统的使用与操作是个很大的主题,本书中并不针对这方面详加探讨,请寻找相关 的数据库系统相关书籍自行学习。为了能顺利练习这个章节的范例,附录中包括了 MySQL 数据库系统的简介,足够让你了解本章使用的一些数据库操作指令。 14.1.2 连接数据库 为了要连接数据库系统,必须要有厂商操作的 JDBC 驱动程序,必须在 CLASSPATH 中设定驱动程序 JAR 文档。如果使用 IDE,程序项目会有管理 CLASSPATH 的方式,通 常是“新增 JAR”之类的指令。例如 NetBeans 项目的话,可以这样新增链接库: (1) 在项目上的 Libraries 节点上右击,从弹出的快捷菜单中选择 Add JAR/Folder 命令。 (2) 在出现的 Add JAR/Folder 对话框中,选择驱动程序 JAR 文档后单击“打开”按钮。 (3) 确认项目的 Libraries 节点上出现 JAR 文档,这表示 JAR 文档已在项目的 CLASSPATH 管理中。 基本数据库操作相关的 JDBC 接口或类是位于 java.sql 包中。要取得数据库联机,必 须有几个动作:  注册 Driver 操作对象。  取得 Connection 操作对象。  关闭 Connection 操作对象。 整合数据库 14 459 IDE 也可以管理常用的 JAR 文档(有的 JAR 文档会内建),例如在 NetBeans 的“库”节 点上右击,在弹出的快捷菜单中选择“添加库”命令,打开“添加库”对话框,此时会出 现 NetBeans 已管理(或内建)的常用 JAR,如图 14.11 所示。 图 14.11 管理常用的 JAR 1. 注册 Driver 操作对象 操作 Driver 接口的对象是 JDBC 进行数据库存取的起点,以 MySQL 操作的驱动程序 为例,com.mysql.jdbc.Driver 类操作了 java.sql.Driver 接口,管理 Driver 操作对象的类是 java.sql.DriverManager。基本上,必须调用其静态方法 registerDriver()进行注册: DriverManager.registerDriver(new com.mysql.jdbc.Driver()); 不过实际上很少自行撰写程序代码进行这个动作,只要想办法加载 Driver 接口的操作 类.class 文档,就会完成注册。例如,可以通过 java.lang.Class 类的 forName()(下一章会详 细说明这个方法),动态加载驱动程序类: try { Class.forName("com.mysql.jdbc.Driver"); } catch(ClassNotFoundException e) { throw new RuntimeException("找不到指定的类"); } 如果查看 MySQL 的 Driver 类操作原始码: package com.mysql.jdbc; import java.sql.SQLException; public class Driver extends NonRegisteringDriver implements java.sql.Driver { 460 static { try { java.sql.DriverManager.registerDriver(new Driver()); } catch (SQLException E) { throw new RuntimeException("Can't register driver!"); } } public Driver() throws SQLException {} } 可以发现,在 static 区块中进行了注册 Driver 实例的动作,而 static 区块会在载 入.class 文档时执行(下一章会详细说明)。使用 JDBC 时,要求加载.class 文档的方式有四种: (1) 使用 Class.forName()。 (2) 自行建立 Driver 接口操作类的实例。 (3) 启动 JVM 时指定 jdbc.drivers 属性。 (4) 设定 JAR 中 /services/java.sql.Driver 文档。 第一种方式刚才已经说明。第二种方式就是直接撰写程序代码: java.sql.Driver driver = new com.mysql.jdbc.Driver(); 由于要建立对象,基本上就要加载.class 文档,自然也就会执行类的静态区块完成驱 动程序注册。第三种方式就是执行 java 指令时如下: > java –Djdbc.drivers=com.mysql.jdbc.Driver;ooo.XXXDriver YourProgram 应用程序可能同时联机多个厂商的数据库,所以 DriverManager 也可以注册多个驱动程 序实例,以上方式如果需要指定多个驱动程序类,就用分号分隔。第四种方式则是 JDK6 之后 JDBC 4.0 新特性,只要在驱动程序操作的 JAR 文档/services 文件夹中,放置一个 java.sql.Driver 文档,当中撰写 Driver 接口的操作类名称全名,DriverManager 会自动读取这 个文档并找到指定类进行注册。 2. 取得 Connection 操作对象 Connection 接口的操作对象是数据库联机代表对象,要取得 Connection 操作对象,可以 通过 DriverManager 的 getConnection(): Connection conn = DriverManager.getConnection( jdbcUrl, username, password); 除了基本的用户名称、密码之外,还必须提供 JDBC URL,其定义了连接数据库时的 协议、子协议、数据源识别: 协议:子协议:数据源识别 除了“协议”在 JDBC URL 中总是 jdbc 开始之外,JDBC URL 格式各家数据库都不 相同,必须查询数据库产品使用手册。以下以 MySQL 为例,“子协议”是桥接的驱动程 序、数据库产品名称或联机机制,例如使用 MySQL 的话,子协议名称是 mysql。“数据 整合数据库 14 461 源识别”标出数据库的地址、端口号、名称、用户、密码等信息。举个例子来说,MySQL 的 JDBC URL 撰写方式如下: jdbc:mysql://主机名:端口/数据库名称?参数=值&参数=值 主机名可以是本机(localhost)或其他联机主机名、地址,MySQL 端口默认为 3306。 例如要连接 demo 数据库,并指明用户名称与密码,可以这样指定: jdbc:mysql://localhost:3306/demo?user=root&password=123456 如果要使用中文存取,还必须给定参数 useUnicode 及 characterEncoding,表明是否 使用 Unicode,并指定字符编码方式。例如(假设数据库表格编码使用 UTF8): jdbc:mysql://localhost:3306/demo?user=root&password=123&useUnicode=true&characterEncodin g=UTF8 有的时候会将 JDBC URL 撰写在 XML 配置文件中,此时不能直接在 XML 中写&符号, 而必须改写为&替代字符。例如: jdbc:mysql://localhost:3306/demo?user=root&password=123&useUnicode=true&characterEnc oding=UTF8 如果要直接通过 DriverManager 的 getConnection()连接数据库,一个比较完整的代码段 如下: String url = "jdbc:mysql://localhost:3306/demo"; String user = "root"; String password = "123456"; Connection conn = null; SQLException ex = null; try { conn = DriverManager.getConnection(url, user, password); ... } catch(SQLException e) { ex = e; } finally { if(conn != null) { try { conn.close(); } catch(SQLException e) { if(ex == null) { ex = e; } else { ex.addSuppressed(e); } } } 462 if(ex != null) { throw new RuntimeException(ex); } } SQLException 是在处理 JDBC 时常遇到的异常对象,为数据库操作过程发生错误时的 代表对象。SQLException 是受检异常(Checked Exception),必须使用 try...catch...finally 明确处理,在异常发生时尝试关闭相关资源。 SQLException 有个子类 SQLWarning,如果数据库执行过程中发生了一些警示信息,会建立 SQLWarning 但不会抛出(throw),而是以链接方式收集起来。可以使用 Connection、Statement、 ResultSet 的 getWarnings()来取得第一个 SQLWarning,使用这个对象的 getNextWarning() 可以取得下一个 SQLWarning,由于它是 SQLException 的子类,所以必要时也可当作异常抛出 3. 关闭 Connection 操作对象 取得 Connection 对象之后,可以使用 isClosed()方法测试与数据库的连接是否关闭。 在操作完数据库之后,若确定不再需要连接,则必须使用 close()来关闭与数据库的连接, 以释放连接时相关的必要资源,像是联机相关对象、授权资源等。 除了像前一个范例代码段,自行撰写 try...catch...finally 尝试关闭 Connection 之外, 从 JDK7 之后,JDBC 的 Connection、Statement、ResultSet 等接口都是 java.lang.AutoClosable 子接口,因此可以使用尝试自动关闭资源语法来简化程序撰写。例如前一个程序片段,可 以简化为以下: String url = "jdbc:mysql://localhost:3306/demo"; String user = "root"; String password = "123456"; try(Connection conn = DriverManager.getConnection(url, user, password)) { ... } catch(SQLException e) { throw new RuntimeException(e); } 以上是撰写程序上的一些简介,然而在底层,DriverManager 如何进行联机呢? DriverManager 会在循环中逐一取出注册的每个 Driver 实例,使用指定的 JDBC URL 来调 用 Driver 的 connect()方法,尝试取得 Connection 实例。以下是 DriverManager 中相关原始 码的重点节录: SQLException reason = null; for (int i = 0; i < drivers.size(); i++) { // 逐一取得 Driver 实例 ... DriverInfo di = (DriverInfo)drivers.elementAt(i); ... try { Connection result = di.driver.connect(url, info); // 尝试联机 整合数据库 14 463 if (result != null) { return (result); // 取得 Connection 就返回 } } catch (SQLException ex) { if (reason == null) { // 记录第一个发生的异常 reason = ex; } } } if (reason != null) { println("getConnection failed: " + reason); throw reason; // 如果有异常对象就抛出 } throw new SQLException( // 没有适用的 Driver 实例,抛出异常 "No suitable driver found for "+ url, "08001"); Driver 的 connect()方法在无法取得 Connection 时会返回 null,所以简单来说, DriverManager 就是逐一使用 Driver 实例尝试联机。如果联机成功就返回 Connection 对象, 如果当中有异常发生,DriverManager 会记录第一个异常,并继续尝试其他的 Driver,在所 有 Driver 都试过了也无法取得联机,若原先尝试过程中有记录异常就抛出,没有的话,也 是抛出异常告知没有适合的驱动程序。 偶而为了除错或其他目的,也可自行建立 Driver 实例并调用其 connect()方法以取得 Connection 对象。例如: Properties props = new Properties(); props.put("user", "root"); props.put("password", "123456"); Driver driver = new com.mysql.jdbc.Driver(); conn = driver.connect(url, props); 以下先来示范联机数据库的完整范例。假设使用了以下的指令在 MySQL 后建立了 demo 数据库: CREATE schema demo; 以下撰写一个简单范例,测试一下可否联机数据库并取得 Connection 实例: JDBCDemo ConnectionDemo.java package cc.openhome; import java.sql.*; public class ConnectionDemo { public static void main(String[] args) throws ClassNotFoundException, SQLException { Class.forName("com.mysql.jdbc.Driver"); String jdbcUrl = "jdbc:mysql://localhost:3306/demo";  加载驱动程序 464 String user = "root"; String passwd = "123456"; try(Connection conn = DriverManager.getConnection(jdbcUrl, user, passwd)) { System.out.printf("数据库已%s%n", conn.isClosed() ? "关闭" : "打开"); } } } 这个范例对 Connection 使用尝试自动关闭资源语法,所以执行完 try 区块后,Connection 的 close()就会被调用。如果顺利取得联机,程序执行结果如下: 数据库已打开 实际上很少直接从 DriverManager 中取得 Connection 想想看,如果你在设计 API,用户 无法提供 JDBC URL、名称、密码时,你要怎么取得 Connectioin?答案是通过稍后要介 绍的 javax.sql.DataSource。 14.1.3 使用 Statement、ResultSet Connection 是数据库连接的代表对象,接下来要执行 SQL 的 话 , 必 须 取 得 java.sql.Statement 操作对象,它是 SQL 描述的代表对象。可以使用 Connection 的 createStatement()建立 Statement 对象: Statement stmt = conn.createStatement(); 取得 Statement 对象之后,可以使用 executeUpdate()、executeQuery()等方法来执行 SQL。 executeUpdate()主要用来执行 CREATE TABLE、INSERT、DROP TABLE、ALTER TABLE 等会改变数 据库内容的 SQL。例如,可以在 demo 数据库中建立一个 t_message 表格: Use demo; CREATE TABLE t_message ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, name CHAR(20) NOT NULL, email CHAR(40), msg TEXT NOT NULL ) CHARSET=UTF8; 如果要在这个表格中插入一笔数据,可以这样使用 Statement 的 executeUpdate()方法: stmt.executeUpdate("INSERT INTO t_message VALUES(1, 'justin', " + "'justin@mail.com', 'mesage...')"); Statement 的 executeQuery()方法用于 SELECT 等查询数据库的 SQL,executeUpdate()会 返回 int 结果,表示数据变动的笔数,executeQuery()会返回 java.sql.ResultSet 对象,代 表查询结果,查询结果会是一笔一笔的数据。可以使用 ResultSet 的 next()移动至下一笔数  取得 Connection 对象 整合数据库 14 465 据,它会返回 true 或 false 表示是否有下一笔数据,接着可以使用 getXXX()取得数据,如 getString()、getInt()、getFloat()、getDouble()等方法,分别取得相对应的字段类型数据。 getXXX()方法都提供有依域名取得数据,或是依字段顺序取得数据的方法。一个例子如下, 指定域名来取得数据: ResultSet result = stmt.executeQuery("SELECT * FROM t_message"); while(result.next()) { int id = result.getInt("id"); String name = result.getString("name"); String email = result.getString("email"); String msg = result.getString("msg"); // ... } 使用查询结果字段顺序来显示结果的方式如下(注意索引是从 1 开始): ResultSet result = stmt.executeQuery("SELECT * FROM t_message"); while(result.next()) { int id = result.getInt(1); String name = result.getString(2); String email = result.getString(3); String msg = result.getString(4); // ... } Statement 的 execute()可以用来执行 SQL,并可以测试 SQL 是执行查询或更新,返回 true 表示 SQL 执行将返回 ResultSet 作为查询结果,此时可以使用 getResultSet()取得 ResultSet 对象。如果 execute()返回 false,表示 SQL 执行会返回更新笔数或没有结果,此 时可以使用 getUpdateCount()取得更新笔数。如果事先无法得知 SQL 是进行查询或更新,就 可以使用 execute()。例如: if(stmt.execute(sql)) { ResultSet rs = stmt.getResultSet(); // 取得查询结果 ResultSet ... } else { // 这是个更新操作 int updated = stmt.getUpdateCount(); // 取得更新笔数 ... } 视需求而定,Statement 或 ResultSet 在不使用时,可以使用 close()将之关闭,以释放 相关资源。Statement 关闭时,所关联的 ResultSet 也会自动关闭。 接下来以一个简单的留言板作为示范,首先制作一个 MessageDAO 来存取数据库: JDBCDemo MessageDAO.java package cc.openhome; import java.sql.*; 466 import java.util.*; public class MessageDAO { private String url; private String user; private String passwd; public MessageDAO(String url, String user, String passwd) { this.url = url; this.user = user; this.passwd = passwd; } public void add(Message message) { try(Connection conn = DriverManager.getConnection(url, user, passwd); Statement statement = conn.createStatement()) { statement.executeUpdate( "INSERT INTO t_message(name, email, msg) VALUES ('" + message.getName() + "', '" + message.getEmail() +"', '" + message.getMsg() + "')"); } catch(SQLException ex) { throw new RuntimeException(ex); } } public List get() { List messages = null; try(Connection conn = DriverManager.getConnection(url, user, passwd); Statement statement = conn.createStatement()) { ResultSet result = statement.executeQuery( "SELECT * FROM t_message"); messages = new ArrayList<>(); while (result.next()) { Message message = new Message(); message.setId(result.getLong(1)); message.setName(result.getString(2)); message.setEmail(result.getString(3)); message.setMsg(result.getString(4)); messages.add(message); } } catch(SQLException ex) { throw new RuntimeException(ex); } return messages; } } 这个对象会从 DriverManager 取得 Connection 对象。add()接受一个 Message 对象, 操作中在数据库中利用 Statement 对象,执行 SQL 描述来新增一笔留言。get ()会从数 据库中取回所有留言,并放在一个 List对象中返回。  这个方法会在数据库中新增留言  取得 Connection 对象  建立 Statement 对象  执行 SQL 描述句  这个方法会从数据库中查询所有留言 整合数据库 14 467 JDBC 规范提到关闭 Connection 时,会关闭相关资源,但没有明确说明是哪些相关资源。 通常驱动程序操作时,会在关闭 Connection 时,一并关闭关联的 Statement,但最好留意 是否真的关闭了资源,自行关闭 Statement 是比较保险的做法。以上范例对 Connection 与 Statement 使用了尝试自动关闭资源语法。 范例中的 Message 只是用来封装留言信息的简单类: JDBCDemo Message.java package cc.openhome; import java.io.Serializable; public class Message implements Serializable { private Long id; private String name; private String email; private String msg; public Message() {} public Message(String name, String email, String msg) { this.name = name; this.email = email; this.msg = msg; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } 468 public String getName() { return name; } public void setName(String name) { this.name = name; } } 可以撰写一个简单的 MessageDAODemo 类来使用 MessageDAO。例如: JDBCDemo MessageDAODemo.java package cc.openhome; import java.util.Scanner; public class MessageDAODemo { public static void main(String[] args) throws Exception { Scanner scanner = new Scanner(System.in, "Big5"); MessageDAO dao = new MessageDAO( "jdbc:mysql://localhost:3306/demo?" + "useUnicode=true&characterEncoding=UTF8", "root", "123456"); while(true) { System.out.print("(1) 显示留言 (2) 新增留言:"); switch(Integer.parseInt(scanner.nextLine())) { case 1: for(Message message : dao.get()) { System.out.printf("%d\t%s\t%s\t%s%n", message.getId(), message.getName(), message.getEmail(), message.getMsg()); } break; case 2: System.out.print("姓名:"); String name = scanner.nextLine(); System.out.print("邮件:"); String email = scanner.nextLine(); System.out.print("留言:"); String msg = scanner.nextLine(); dao.add(new Message(name, email, msg)); } } 整合数据库 14 469 } } 以下是个执行的范例: (1) 显示留言 (2) 新增留言:2 姓名:良葛格 邮件:caterpillar@openhome.cc 留言:这是一篇测试留言! (1) 显示留言 (2) 新增留言:1 1 良葛格 caterpillar@openhome.cc 这是一篇测试留言! 范例中怎么没有用 Class.forName()载入 Driver 操作类呢?别忘了,JDK6之后支持JDBC 4.0,只要驱动程序 JAR 中有/services/java.sql.Driver 文档,就会自动读取当中设定的 Driver 操作类。 14.1.4 使用 PreparedStatement、CallableStatement Statement 在执行 executeQuery()、executeUpdate()等方法时,如果有些部分是动态的数 据,必须使用+运算符串接字符串以组成完整的 SQL 语句,十分不方便。例如前面范例中 在新增留言时,必须这样串接 SQL 语句: statement.executeUpdate( "INSERT INTO t_message(name, email, msg) VALUES ( '"+ message.getName() + "', '"+ message.getEmail() +"', '"+ message.getMsg() + "')"); 如果有些操作只是 SQL 语句当中某些参数会有所不同,其余的 SQL 子句皆相同,则 可以使用 java.sql.PreparedStatement。可以使用 Connection 的 preparedStatement()方法建立 好预先编译(precompile)的 SQL 语句,当中参数会变动的部分,先指定“?”这个占位字 符。例如: PreparedStatement stmt = conn.prepareStatement( "INSERT INTO t_message VALUES(?, ?, ?, ?)"); 等到需要真正指定参数执行时,再使用相对应的 setInt()、setString()等方法,指定 “?”处真正应该有的参数。例如: stmt.setInt(1, 2); stmt.setString(2, "momor"); stmt.setString(3, "momor@mail.com"); stmt.setString(4, "message2..."); stmt.executeUpdate(); stmt.clearParameters(); 470 要让 SQL 执行生效,要执行 executeUpdate()或 executeQuery()方法(如果是查询的话)。 在这次的 SQL 执行完毕后,可以调用 clearParameters()清除设置的参数,之后就能再次使 用这个 PreparedStatement 实例,所以使用 PreparedStatement,可以让你先准备好一段 SQL, 并重复使用这段 SQL 语句。 可以使用 PreparedStatement 改写前面 MessageDAO 中 add()执行 SQL 语句的部分。例如: JDBCDemo MessageDAO2.java package cc.openhome; import java.sql.*; import java.util.*; public class MessageDAO { ... public void add(Message message) { try(Connection conn = DriverManager.getConnection(url, user, passwd); PreparedStatement statement = conn.prepareStatement( "INSERT INTO t_message(name, email, msg) VALUES (?,?,?)")) { statement.setString(1, message.getName()); statement.setString(2, message.getEmail()); statement.setString(3, message.getMsg()); statement.executeUpdate(); } catch(SQLException ex) { throw new RuntimeException(ex); } } ... } 这样的写法显然比串接 SQL 的方式好得多。不过,使用 PreparedStatement 的好处不仅 如此,之前提过,在这次的 SQL 执行完毕后,可以调用 clearParameters()清除设置的参数, 之后就可以再次使用这个 PreparedStatement 实例,也就是说必要的话,可以考虑制作描述 句池(Statement Pool)将一些频繁使用的 PreparedStatement 重复使用,减少生成对象的负担。 在驱动程序支持的情况下,使用 PreparedStatement,可以将 SQL 描述预编译为数据库 的执行指令。由于已经是数据库的可执行指令,执行速度可以快许多[例如若使用 Java DB, 其驱动程序可以将 SQL 预编译为位码(byte code)格式,在 JVM 中执行就快许多了],而不 像 Statement 对象,是在执行时将 SQL 直接送到数据库,由数据库做剖析、直译再执行。 使用 PreparedStatement 在安全上也可以有点贡献。举个例子来说,如果原先使用串接 字符串的方式来执行 SQL: Statement statement = connection.createStatement(); String queryString = "SELECT * FROM user_table WHERE username='" + username + "' AND password='" + password + "'"; 整合数据库 14 471 ResultSet resultSet = statement.executeQuery(queryString); 其中 username 与 password 若是来自用户的输入字符串,原本是希望用户安分地输入名 称密码,组合之后的 SQL 应该像是这样: SELECT * FROM user_table WHERE username='caterpillar' AND password='123456' 但如果用户在密码的部分,输入了“' OR '1'='1”这样的字符串,而你又没有针对用户 的输入进行字符检查过滤动作,这个奇怪的字符串最后组合出来的 SQL 会如下: SELECT * FROM user_table WHERE username='caterpillar' AND password='' OR '1'='1' 方框是密码请求参数的部分,将方框拿掉会更清楚地看出这个 SQL 有什么问题! SELECT * FROM user_table WHERE username='caterpillar' AND password='' OR '1'='1' AND 子句之后的判断式永远成立,也就是说,用户不用输入正确的密码,也可以查询 出所有的数据,这就是 SQL Injection 的简单例子。 以串接的方式组合 SQL 描述基本上就会有 SQL Injection 的隐忧,如果这样改用 PreparedStatement 的话: PreparedStatement stmt = conn.prepareStatement( "SELECT * FROM user_table WHERE username=? AND password=?"); stmt.setString(1, username); stmt.setString(2, password); 在这里 username 与 password 将被视作是 SQL 中纯粹的字符串,而不会被当作 SQL 语 法来解释,所以就可避免这个例子的 SQL Injection 问题。 其实问题不仅是在串接字符串本身麻烦,以及 SQL Injection 发生的可能性。由于+串 接字符串会产生新的 String 对象,如果串接字符串动作经常进行(例如在循环中进行 SQL 串接的动作),那会是效能负担上的隐忧(如果真的非得串接 SQL,至少要考虑使用 StringBuffer 或 JDK 5.0 之后的 StringBuilder)。 如果撰写数据库的预存程序(Stored Procedure),并想使用 JDBC 来调用,则可使用 java.sql.CallableStatement。调用的基本语法如下: {?= call <程序名称>[<自变量 1>,<自变量 2>, ...]} {call <程序名称>[<自变量 1>,<自变量 2>, ...]} CallableStatement 的 API 使用,基本上与 PreparedStatement 差别不大,除了必须调用 prepareCall()建立 CallableStatement 异常外,一样是使用 setXXX()设定参数,如果是查询 操作,使用 executeQuery(),如果是更新操作,使用 executeUpdate()。另外,可以使用 registerOutParameter()注册输出参数等。 使用 JDBC 的 CallableStatement 调用预存程序,重点是在于了解各个数据库的预存程序 如何撰写及相关事宜,用 JDBC 调用预存程序,也表示应用程序将与数据库产生直接的相 依性。 472 在使用 PreparedStatement 或 CallableStatement 时,必须注意 SQL 类型与 Java 数据类 型的对应,因为两者本身并不是一对一对应,java.sql.Types 定义了一些常数代表 SQL 类 型。表 14.1 所示为 JDBC 规范建议的 SQL 类型与 Java 类型的对应。 表 14.1 Java 类型与 SQL 类型对应 Java 类型 SQL 类型 boolean BIT byte TINYINT short SMALLINT int INTEGER long BIGINT float FLOAT double DOUBLE byte[] BINARY、VARBINARY、LONGBINARY java.lang.String CHAR、VARCHAR、LONGVARCHAR java.math.BigDecimal NUMERIC、DECIMAL java.sql.Date DATE java.sql.Time TIME java.sql.Timestamp TIMESTAMP 其中要注意的是,日期时间在 JDBC 中,并不是使用 java.util.Date,这个对象可代表 的日期时间格式是“年、月、日、时、分、秒、毫秒”。在 JDBC 中要表示日期,是使用 java.sql.Date,其日期格式是“年、月、日”,要表示时间的话则是使用 java.sql.Time, 其时间格式为“时、分、秒”,如果要表示“时、分、秒、微秒”的格式,则是使用 java.sql.Timestamp。 14.2 JDBC 进阶 上一节介绍了 JDBC 入门概念与相关 API,在这一节,将说明更多进阶 API 的使用, 像是使用 DataSource 取得 Connection、使用 PreparedStatement、使用 ResultSet 进行更新操 作等。 14.2.1 使用 DataSource 取得联机 前面的 MessageDAO 范例必须告知 DriverManager 有关 JDBC URL、用户名称、密码等信 息,以取得 Connection 对象,然而实际应用程序开发时,JDBC URL、用户名称、密码等 信息是很敏感的信息,有些开发人员根本无从得知,如果 MessageDAO 的用户无法告知这些 信息,你如何改写 MessageDAO? 整合数据库 14 473 答案是可以让 MessageDAO 依赖于 javax.sql.DataSource 接口,可以通过其定义的 getConnection()方法取得 Connection。例如: JDBCDemo MessageDAO3.java package cc.openhome; import java.sql.*; import java.util.*; import javax.sql.DataSource; public class MessageDAO3 { private DataSource dataSource; public MessageDAO3(DataSource dataSource) { this.dataSource = dataSource; } public void add(Message message) { try(Connection conn = dataSource.getConnection(); PreparedStatement statement = conn.prepareStatement( "INSERT INTO t_message(name, email, msg) VALUES (?,?,?)")) { ... } catch(SQLException ex) { throw new RuntimeException(ex); } } public List get() { List messages = null; try(Connection conn = dataSource.getConnection(); Statement statement = conn.createStatement()) { ... } catch(SQLException ex) { throw new RuntimeException(ex); } return messages; } } 单看这个 MessageDAO3,并不会知道 DataSource 操作对象是从哪个 URL、使用哪个名称、 密码、内部如何建立 Connection,日后要修改数据库服务器主机位置,或者是为了打算重 复利用 Connection 对象而想要加入联机池(Connection Pool)机制等情况,这个 MessageDAO3 都不用修改。 要取得数据库联机,必须打开网络联机(中间经过实体网络),连接至数据库服务器后,进 行协议交换(当然也就是数次的网络数据往来)以进行验证名称、密码等确认动作。也就是 474 取得数据库联机事件耗时间及资源的动作。尽量利用已打开的联机,也就是重复利用取得 的 Connection 实例,是改善数据库联机效能的一个方式。采用联机池是基本做法。 例如以下范例操作具有简单连接池的 DataSource,示范如何重复使用已取得的 Connection: JDBCDemo SimpleConnectionPoolDataSource.java package cc.openhome; import java.util.*; import java.io.*; import java.sql.*; import java.util.concurrent.Executor; import java.util.logging.Logger; import javax.sql.DataSource; public class SimpleConnectionPoolDataSource implements DataSource { private Properties props; private String url; private String user; private String passwd; private int max; // 连接池中最大 Connection 数目 private List conns; public SimpleConnectionPoolDataSource() throws IOException, ClassNotFoundException { this("jdbc.properties"); } public SimpleConnectionPoolDataSource(String configFile) throws IOException, ClassNotFoundException { props = new Properties(); props.load(new FileInputStream(configFile)); url = props.getProperty("cc.openhome.url"); user = props.getProperty("cc.openhome.user"); passwd = props.getProperty("cc.openhome.password"); max = Integer.parseInt(props.getProperty("cc.openhome.poolmax")); conns = Collections.synchronizedList(new ArrayList()); } public synchronized Connection getConnection() throws SQLException { if(conns.isEmpty()) { return new ConnectionWrapper(  操作 DataSource  可指定.properties 文档  维护可重用的 Connection 对象  如果 List 为空就建立新的 ConnectionWrapper 整合数据库 14 475 DriverManager.getConnection(url, user, passwd), conns, max ); } else { return conns.remove(conns.size() - 1); } } private class ConnectionWrapper implements Connection { private Connection conn; private List conns; private int max; public ConnectionWrapper(Connection conn, List conns, int max) { this.conn = conn; this.conns = conns; this.max = max; } @Override public void close() throws SQLException { if(conns.size() == max) { conn.close(); } else { conns.add(this); } } @Override public Statement createStatement() throws SQLException { return conn.createStatement(); } ... } ... } SimpleConnectionPoolDataSource 操作了 DataSource 接口,其中使用 List 实例维护可重用的 Connection,联机相关信息可以使用.properties 设定。如果客户端调  ConnectionWrapper 操作 Connection 界面  否则返回 List 中一个 Connection  否则放入 List 中以备重用  如果超出最大可维护 Connection 数 量就关闭 Connection 476 用 getConnection()方法尝试取得联机,若 List为空,则建立新的 Connection 并 打包在 ConnectionWrapper 后返回,如果不为空就直接从 List移出返回。 ConnectionWrapper 操作了 Connection 接口,大部分方法操作时都是直接委托给被打包 的 Connection 实例。ConnectionWrapper 操作 close()方法时,会看看维护 Connection 的 List容量是否到了最大值,如果是就直接关闭被打包的 Connection,否则就 将自己置入 List以备重用。 如果准备一个 jdbc.properties 如下: JDBCDemo jdbc.properties cc.openhome.url=jdbc:mysql://localhost:3306/demo cc.openhome.user=root cc.openhome.password=123456 cc.openhome.poolmax=10 那么就可以这样使用 SimpleConnectionPoolDataSource 与 MessageDAO3: JDBCDemo MessageDAODemo3.java package cc.openhome; import java.util.Scanner; public class MessageDAODemo3 { public static void main(String[] args) throws Exception { Scanner scanner = new Scanner(System.in, "Big5"); MessageDAO3 dao = new MessageDAO3(new SimpleConnectionPoolDataSource()); while(true) { ... } } } 实际上应用程序经常通过 JNDI,从服务器上取得设定好的 DataSource,再从 DataSource 取得 Connection,将来你接触到 Servlet/JSP 或其他 Java EE 应用领域,就会看到相关设 定方式。 14.2.2 使用 ResultSet 卷动、更新数据 在 ResultSet 时,默认可以使用 next()移动数据光标至下一笔数据,而后使用 getXXX() 方法来取得数据。实际上,从 JDBC 2.0 开始,ResultSet 不仅可以使用 previous()、first()、 last()等方法前后移动数据光标,还可以调用 updateXXX()、updateRow()等方法进行数据修改。 在使用 Connection 的 createStatement()或 prepareStatement()方法建立 Statement 或 PreparedStatement 实例时,可以指定结果集类型与并行方式: 整合数据库 14 477 createStatement(int resultSetType, int resultSetConcurrency) prepareStatement(String sql, int resultSetType, int resultSetConcurrency) 结果集类型可以指定三种设定:  ResultSet.TYPE_FORWARD_ONLY(默认)  ResultSet.TYPE_SCROLL_INSENSITIVE  ResultSet.TYPE_SCROLL_SENSITIVE 指 定 为 TYPE_FORWARD_ONLY , ResultSet 就只能前进数据光标。指定 TYPE_SCROLL_INSENSITIVE 或 TYPE_SCROLL_SENSITIVE,则 ResultSet 可以前后移动数据光标, 两者差别在于 TYPE_SCROLL_INSENSITIVE 设定下,取得的 ResultSet 不会反映数据库中的数据 修改,而 TYPE_SCROLL_SENSITIVE 会反映数据库中的数据修改。 更新设定可以有两种指定:  ResultSet.CONCUR_READ_ONLY(默认)  ResultSet.CONCUR_UPDATABLE 指定为 CONCUR_READ_ONLY,则只能用 ResultSet 进行数据读取,无法进行更新。指定为 CONCUR_UPDATABLE,就可以使用 ResultSet 进行数据更新。 在使用 Connection 的 createStatement()或 prepareStatement()方法建立 Statement 或 PreparedStatement 实例时,若没有指定结果集类型与并行方式,默认就是 TYPE_FORWARD_ONLY 与 CONCUR_READ_ONLY。如果想前后移动数据光标并想使用 ResultSet 进行更新,则以下是个 Statement 指定的例子: Statement stmt = conn.createStatement( ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_UPDATEABLE); 以下是个 PreparedStatement 指定的例子: PreparedStatement stmt = conn.prepareStatement( "SELECT * FROM t_message", ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_UPDATEABLE); 在数据光标移动的 API 上,可以使用 absolute()、afterLast()、beforeFirst()、first()、last() 进行绝对位置移动,使用 relative()、previous()、next()进行相对位置移动。这些方法如果成功 移动就会返回 true,也可以使用 isAfterLast()、isBeforeFirst()、isFirst()、isLast()判断目前位 置。以下是个简单的程序范例片段: Statement stmt = conn.createStatement("SELECT * FROM t_message", ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); ResultSet rs = stmt.executeQuery(); rs.absolute(2); // 移至第 2 列 rs.next(); // 移至第 3 列 478 rs.first(); // 移至第 1 列 boolean b1 = rs.isFirst(); // b1 是 true 如果要使用 ResultSet 进行数据修改,则有些条件限制:  必须选取单一表格。  必须选取主键。  必须选取所有 NOT NULL 的值。 在取得 ResultSet 之后要进行数据更新,必须移动至要更新的列(Row),调用 updateXxx() 方法(Xxx 是类型),而后调用 updateRow()方法完成更新。如果调用 cancelRowUpdates()可取 消更新,但必须在调用 updateRow()前进行更新的取消。一个使用 ResultSet 更新数据的例 子如下: Statement stmt = conn.prepareStatement("SELECT * FROM t_message", ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); ResultSet rs = stmt.executeQuery(); rs.next(); rs.updateString(3, "caterpillar@openhome.cc"); rs.updateRow(); 如果取得 ResultSet 后想直接进行数据的新增,则要先调用 moveToInsertRow(),之后调 用 updateXXX()设定要新增的数据各个字段,然后调用 insertRow()新增数据。一个使用 ResultSet 新增数据的例子如下: Statement stmt = conn.prepareStatement("SELECT * FROM t_message", ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); ResultSet rs = stmt.executeQuery(); rs.moveToInsertRow(); rs.updateString(2, "momor"); rs.updateString(3, "momor@openhome.cc"); rs.updateString(4, "blah...blah"); rs.insertRow(); rs.moveToCurrentRow(); 如果取得 ResultSet 后想直接进行数据的删除,则要移动数据光标至想删除的列,调用 deleteRow()删除数据列。一个使用 ResultSet 删除数据的例子如下: Statement stmt = conn.prepareStatement("SELECT * FROM t_message", ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); ResultSet rs = stmt.executeQuery(); rs.absolute(3); rs.deleteRow(); 整合数据库 14 479 14.2.3 批次更新 如果必须对数据库进行大量数据更新,单纯使用类似以下的程序片段并不适当: Statement stmt = conn.createStatement(); while(someCondition) { stmt.executeUpdate( "INSERT INTO t_message(name,email,msg) VALUES('…','…','…')"); } 每一次执行 executeUpdate(),其实都会向数据库发送一次 SQL,如果大量更新的 SQL 有 一万笔,就等于通过网络进行了一万次的信息传送,网络传送信息实际上必须打开 I/O、进行 路由等动作。如此进行大量更新,效能上其实不好。 可以使用 addBatch()方法来收集 SQL,并使用 executeBatch()方法将所收集的 SQL 传 送出去。例如: Statement stmt = conn.createStatement(); while(someCondition) { stmt.addBatch( "INSERT INTO t_message(name,email,msg) VALUES('…','…','…')"); } stmt.executeBatch(); 以 MySQL 驱动程序的 Statement 操作为例,其 addBatch()使用了 ArrayList 来收集 SQL, 其原始码如下所示: public synchronized void addBatch(String sql) throws SQLException { if (this.batchedArgs == null) { this.batchedArgs = new ArrayList(); } if (sql != null) { this.batchedArgs.add(sql); } } 所有收集的 SQL,最后会串为一句 SQL,然后传送给数据库。也就是说,假设大量更 新的 SQL 有一万笔,这一万笔 SQL 会连接为一句 SQL,再通过一次网络传送给数据库, 节省了 I/O、网络路由等动作所耗费的时间。 既然是使用批次更新,顾名思义,就是仅用在更新操作,所以批次更新的限制是,SQL 不能是 SELECT,否则会抛出异常。 使用 executeBatch()时,SQL 的执行顺序就是 addBatch()时的顺序。executeBatch()会 返回 int[],代表每笔 SQL 造成的数据异动列数,执行 executeBatch()时,前面已打开的 ResultSet 会被关闭,执行过后收集 SQL 用的 List 会被清空,任何的 SQL 错误,会抛出 BatchUpdateException,可以使用这个对象的 getUpdateCounts()取得 int[],代表前面执行成 功的 SQL 所造成的异动笔数。 480 前面举的是 Statement 的例子,如果是 PreparedStatement 要使用批次更新,以下是个范例: PreparedStatement stmt = conn.prepareStatement( "INSERT INTO t_message(name,email,msg) VALUES(?, ?, ?)"); while(someCondition) { stmt.setString(1, "..."); stmt.setString(2, "..."); stmt.setString(3, "..."); stmt.addBatch(); // 收集参数 } stmt.executeBatch(); // 送出所有参数 PreparedStatement 的 addBatch() 会收集占位字符真正的数值。以 MySQL 的 PreparedStatement 操作类为例,其 addBatch()原始码如下: public void addBatch() throws SQLException { if (this.batchedArgs == null) { this.batchedArgs = new ArrayList(); } this.batchedArgs.add(new BatchParams(this.parameterValues, this.parameterStreams, this.isStream, this.streamLengths, this.isNull)); } 可以看到,内部是使用 ArrayList 来收集占位字符实际的数值。 除了在 API 上使用 addBatch()、executeBatch()等方法以进行批次更新之外,通常也会 搭配关闭自动提交(auto commit),在效能上也会有所影响,这在稍后说明交易时就会提到。 驱动程序本身是否支持批次更新也要注意一下。以 MySQL 为例,要支持批次更新,必须 在 JDBC URL 上附加 rewriteBatchedStatements=true 参数才有实际的作用。 14.2.4 Blob 与 Clob 如果要将文档写入数据库,可以在数据库表格字段上使用 BLOB 或 CLOB 数据类型。 BLOB 全名 Binary Large Object,用于存储大量的二进制数据,像是图档、影音档等,CLOB 全名 Character Large Object,用于存储大量的文字数据。 在 JDBC 中提供了 java.sql.Blob 与 java.sql.Clob 两个类分别代表 BLOB 与 CLOB 数 据。以 Blob 为例,写入数据时,可以通过 PreparedStatement 的 setBlob()来设定 Blob 对象, 读取数据时,可以通过 ResultSet 的 getBlob()取得 Blob 对象。 Blob 拥有 getBinaryStream()、getBytes()等方法,可以取得代表字段来源的 InputStream 或字段的 byte[]数据。Blob 拥有 getCharacterStream()、getAsciiStream()等方法,可以取得 Reader 或 InputStream 等数据。可以查看 API 文件来获得更详细的信息。 实际也可以把 BLOB 字段对应 byte[]或输入/输出串流。在写入数据时,可以使用 PreparedStatement 的 setBytes()来设定要存入的 byte[]数据,使用 setBinaryStream()来设定 整合数据库 14 481 代表输入来源的 InputStream。在读取数据时,可以使用 ResultSet 的 getBytes()以 byte[] 取得字段中存储的数据,或以 getBinaryStream()取得代表字段来源的 InputStream。 以下是取得代表文档来源的 InputStream 后,进行数据库存储的片段: InputStream in = readFileAsInputStream("..."); PreparedStatement stmt = conn.prepareStatement( "INSERT INTO IMAGES(src, img) VALUE(?, ?)"); stmt.setString(1, "…"); stmt.setBinaryStream(2, in); stmt.executeUpdate(); 以下是取得代表字段数据源的 InputStream 片段: PreparedStatement stmt = conn.prepareStatement( "SELECT img FROM IMAGES"); ResultSet rs = stmt.executeQuery(); while(rs.next()) { InputStream in = rs.getBinaryStream(1); //使用 InputStream 作数据读取 } 14.2.5 交易简介 交易的四个基本要求是原子性(Atomicity)、一致性(Consistency)、隔离行为(Isolation behavior)与持续性(Durability),依英文字母首字母简称为 ACID。  原子性:一个交易是一个单元工作(Unit of work),当中可能包括数个步骤,这些 步骤必须全部执行成功,若有一个失败,则整个交易声明失败。交易中其他步骤 必须撤销曾经执行过的动作,回到交易前的状态。 在数据库上执行单元工作为数据库交易(Database transaction),单元中每个步骤 就是每一句 SQL 的执行,你要定义开始一个交易边界(通常是以一个 BEGIN 的指 令开始),所有 SQL 语句下达之后,COMMIT 确认所有操作变更,此时交易成功, 或者因为某个 SQL 错误,ROLLBACK 进行撤销动作,此时交易失败。  一致性:交易作用的数据集合在交易前后必须一致。若交易成功,整个数据集合 都必须是交易操作后的状态;若交易失败,整个数据集合必须与开始交易前一样 没有变更,不能发生整个数据集合,部分有变更,部分没变更的状态。 例如转账行为,数据集合涉及 A、B 两个账户,A 原有 20000,B 原有 10000,A 转 10000 给 B,交易成功的话,最后 A 必须变成 10000,B 变成 20000,交易失 败的话,A 必须为 20000,B 为 10000,而不能发生 A 为 20000(未扣款),B 也为 20000(已入款)的情况。  隔离行为:在多人使用的环境下,每个用户可能进行自己的交易,交易与交易之 间,必须互不干扰,用户不会意识到别的用户正在进行交易,就好像只有自己在 进行操作一样。 482  持续性:交易一旦成功,所有变更必须保存下来,即使系统挂了,交易的结果也 不能遗失,这通常需要系统软、硬件架构的支持。 在原子性的要求上,在 JDBC 可以操作 Connection 的 setAutoCommit()方法,给它 false 自变量,提示数据库开始交易,在下达一连串的 SQL 语句后,自行调用 Connection 的 commit(),提示数据库确认(COMMIT)操作。如果中间发生错误,则调用 rollback(),提示 数据库撤销(ROLLBACK)所有的执行。一个示范的流程如下所示: Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 取消自动提交 Statement stmt = conn.createStatement(); stmt.executeUpdate("INSERT INTO …"); stmt.executeUpdate("INSERT INTO …"); conn.commit(); // 提交 } catch(SQLException e) { e.printStackTrace(); if(conn != null) { try { conn.rollback(); // 撤回 } catch(SQLException ex) { ex.printStackTrace(); } } } finally { ... if(conn != null) { try { conn.setAutoCommit(true); // 回复自动提交 conn.close(); } catch(SQLException ex) { ex.printStackTrace(); } } } 如果在交易管理时,仅想要撤回某个 SQL 执行点,则可以设定存储点(Save point)。例 如: Savepoint point = null; try { conn.setAutoCommit(false); Statement stmt = conn.createStatement(); stmt.executeUpdate("INSERT INTO …"); ... point = conn.setSavepoint(); // 设定存储点 stmt.executeUpdate("INSERT INTO …"); 整合数据库 14 483 ... conn.commit(); } catch(SQLException e) { e.printStackTrace(); if(conn != null) { try { if(point == null) { conn.rollback(); } else { conn.rollback(point); // 撤回存储点 conn.releaseSavepoint(point); // 释放存储点 } } catch(SQLException ex) { ex.printStackTrace(); } } } finally { ... if(conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch(SQLException ex) { ex.printStackTrace(); } } } 在批次更新时,不用每一笔都确认的话,也可以搭配交易管理。例如: try { conn.setAutoCommit(false); stmt = conn.createStatement(); while(someCondition) { stmt.addBatch("INSERT INTO …"); } stmt.executeBatch(); conn.commit(); } catch(SQLException ex) { ex.printStackTrace(); if(conn != null) { 484 try { conn.rollback(); } catch(SQLException e) { e.printStackTrace(); } } } finally { ... if(conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch(SQLException ex) { ex.printStackTrace(); } } } 数据表格必须支持交易,才可以执行以上所提到的功能。例如,在 MySQL 中可以建立 InnoDB 类型的表格: CREATE TABLE t_xxx ( ... ) Type = InnoDB; 至于在隔离行为的支持上,JDBC 可以通过 Connection 的 getTransactionIsolation()取 得数据库目前的隔离行为设定,通过 setTransactionIsolation()可提示数据库设定指定的隔 离行为。可设定常数是定义在 Connection 上,如下所示:  TRANSACTION_NONE  TRANSACTION_UNCOMMITTED  TRANSACTION_COMMITTED  TRANSACTION_REPEATABLE_READ  TRANSACTION_SERIALIZABLE 其中 TRANSACTION_NONE 表示对交易不设定隔离行为,仅适用于没有交易功能、以只读功 能为主、不会发生同时修改字段的数据库。有交易功能的数据库,可能不理会 TRANSACTION_NONE 的设定提示。 要了解其他隔离行为设定的影响,首先要了解多个交易并行时,可能引发的数据不一 致问题有哪些。以下逐一举例说明。 整合数据库 14 485 1. 更新遗失(Lost update) 基本上就是指某个交易对字段进行更新的信息,因另一个交易的介入而遗失更新效力。 举例来说,若某个字段数据原为 ZZZ,用户 A、B 分别在不同的时间点对同一字段进行更 新交易,如图 14.12 所示。 用户 A 用户 B 1:开始() 2:开始() 3:更新为 000() 4:更新为×××() 5:确认() 6:撤销() 交易 A 交易 B 图 14.12 更新遗失 单就用户 A 的交易而言,最后字段应该是 OOO,单就用户 B 的交易而言,最后字段 应该是 ZZZ。在完全没有隔离两者交易的情况下,由于用户 B 撤销操作时间在用户 A 确认 之后,最后字段结果会是 ZZZ,用户 A 看不到他更新确认的 OOO 结果,用户 A 发生更新 遗失问题。 可想象有两个用户,若 A 用户打开文件之后,后续又允许 B 用户打开文件,一开始 A、B 用户看到的文件都有 ZZZ 文字,A 修改 ZZZ 为 OOO 后存储,B 修改 ZZZ 为 XXX 后又还 原为 ZZZ 并存储,最后文件就为 ZZZ,A 用户的更新遗失。 如果要避免更新遗失问题,可以设定隔离层级为“可读取未确认”(Read uncommited), 也就是 A 交易已更新但未确认的数据,B 交易仅可做读取动作,但不可做更新的动作。JDBC 可通过 Connection 的 setTransactionIsolation()设定为 TRANSACTION_UNCOMMITTED 来提示数据 库确定此隔离行为。 数据库对此隔离行为的基本做法是,A 交易在更新但未确认,延后 B 交易的更新需求 至 A 交易确认之后。以上例而言,交易顺序结果会变成如图 14.13 所示。 可想象有两个用户,若 A 用户打开文件之后,后续只允许 B 用户以只读方式打开文件,B 用 户若要能够写入,至少得等 A 用户修改完成关闭文档后。 提示数据库“可读取未确认”的隔离层次之后,数据库至少得保证交易要避免更新遗失 问题,通常这也是具备交易功能的数据库引擎会采取的最低隔离层级。不过这个隔离层级 读取错误数据的机率太高,一般不会采用这种隔离层级。 486 用户 A 用户 A 交易 A 交易 B 1:开始() 2:开始() 3:更新为 000() 5:更新为×××() 4:确认() 6:撤销() 图 14.13 “可读取未确认”避免更新遗失 2. 脏读(Dirty read) 两个交易同时进行,其中一个交易更新数据但未确认,另一个交易就读取数据,就有 可能发生脏读问题,也就是读到所谓脏数据(Dirty data)、不干净、不正确的数据,如图 14.14 所示。 用户 A 用户 B 1:开始() 2:开始() 3:更新为 000() 4:查询字段为×××() 6:撤销() 5:确认() 交易 A 交易 B 图 14.14 脏读 用户 B 在 A 交易撤销前读取了字段数据为 OOO,如果 A 交易撤销了交易,那用户 B 读取的数据就是不正确的。 可想象有两个用户,若 A 用户打开文件并仍在修改期间,B 用户打开文件所读到的数据, 就有可能是不正确的。 如果要避免脏读问题,可以设定隔离层级为“可读取确认”(Read commited),也就是 交易读取的数据必须是其他交易已确认的数据。 JDBC 可通过 Connection 的 setTransactionIsolation()设定为 TRANSACTION_COMMITTED 来提示数据库确定此隔离行为。 整合数据库 14 487 数据库对此隔离行为的基本做法之一是,读取的交易不会阻止其他交易,未确认的更 新交易会阻止其他交易。若是这个做法,交易顺序结果会变成如图 14.15 所示(若原字段为 ZZZ)。 用户 A 用户 B 1:开始() 2:开始() 3:更新字段为 000() 5:查询字段为×××() 4:撤销() 6:确认() 交易 A 交易 B 图 14.15 “可读取确认”避免脏读 可想象有两个用户,若 A 用户打开文件并仍在修改期间,B 用户就不能打开文件。但在数 据库上这个做法影响效能较大,另一个基本做法是交易正在更新但尚未确定前先操作暂存 表格,其他交易就不至于读取到不正确的数据。JDBC 隔离层级的设定提示,实际在数据 库上如何操作,主要得以各家数据库在效能的考虑而定。 提示数据库“可读取确认”的隔离层次之后,数据库至少得保证交易要避免脏读与更 新遗失问题。 3. 无法重复的读取(Unrepeatable read) 某个交易两次读取同一字段的数据并不一致。例如,交易 A 在交易 B 更新前后进行数 据的读取,则 A 交易会得到不同的结果,如图 14.16 所示(若原字段为 ZZZ)。 用户 A 用户 B 1:开始() 2:开始() 3:查询字段为 ZZZ() 4:更新字段为 OOO() 6:查询字段为 OOO() 5:确认() 7:确认() 交易 A 交易 B 图 14.16 无法重复的读取 488 如果要避免无法重复的读取问题,可以设定隔离层级为“可重复读取”(Repeatable read),也就是同一交易内两次读取的数据必须相同。JDBC 可通过 Connection 的 setTransactionIsolation()设定为 TRANSACTION_REPEATABLE_READ 来提示数据库确定此隔离行为。 数据库对此隔离行为的基本做法之一是,读取交易在确认前不阻止其他读取交易,但 会阻止其他更新交易。若是这个做法,交易顺序结果会变成如图 14.17 所示(若原字段为 ZZZ)。 用户 A 用户 B 1:开始() 交易 A 交易 B 2:开始() 3:查询字段为 ZZZ() 4:查询字段为 ZZZ() 6:更新字段为 OOO() 5:确认() 7:确认() 图 14.17 可重复读取 在数据库上这个做法影响效能较大,另一个基本做法是交易正在读取但尚未确认前,另一 交易会在暂存表格上更新。 提示数据库“可重复读取”的隔离层次之后,数据库至少得保证交易要避免无法重复 读取、脏读与更新遗失问题。 4. 幻读(Phantom read) 同一交易期间,读取到的数据笔数不一致。例如,交易 A 第一次读取得到五笔数据, 此时交易 B 新增了一笔数据,导致交易 B 再次读取得到六笔数据。 如果隔离行为设定为可重复读取,但发生幻读现象,可以设定隔离层级为“可循序” (Serializable),也就是在有交易时若有数据不一致的疑虑,交易必须可以照顺序逐一进行。 JDBC 可通过 Connection 的 setTransactionIsolation()设定为 TRANSACTION_SERIALIZABLE 来提 示数据库确定此隔离行为。 交易若真的一个一个循序进行,对数据库的影响效能过于巨大,实际也许未必直接阻止其 他交易或真的循序进行,例如采取暂存表格方式。事实上,只要能符合四个交易隔离要求, 各家数据库会寻求最有效能的解决方式。 表 14.2 整理了各个隔离行为可预防的问题。 整合数据库 14 489 表 14.2 隔离行为与可预防的问题 隔离行为 更新遗失 脏读 无法重复的读取 幻读 可读取未确认 预防 可读取确认 预防 预防 可重复读取 预防 预防 预防 可循序 预防 预防 预防 预防 如果想通过 JDBC 得知数据库是否支持某个隔离行为设定,可以通过 Connection 的 getMetaData()取得 DatabaseMetadata 对象,通过 DatabaseMetadata 的 supportsTransaction- IsolationLevel()得知是否支持某个隔离行为。例如: DatabaseMetadata meta = conn.getMetaData(); boolean isSupported = meta.supportsTransactionIsolationLevel( Connection.TRANSACTION_READ_COMMITTED); 14.2.6 metadata 简介 Metadata 即“诠读数据的数据”(Data about data)。例如,数据库是用来存储数据的 地方,然而数据库本身产品名称为何?数据库中有几个数据表格?表格名称为何?表格中 有几个字段等?这些信息就是所谓的 metadata。 在 JDBC 中,可以通过 Connection 的 getMetaData()方法取得 DatabaseMetaData 对象, 通过这个对象提供的各个方法,可以取得数据库整体信息,而 ResultSet 表示查询到的数据, 而数据本身的字段、类型等信息,可以通过 ResultSet 的 getMetaData() 方法,取得 ResultSetMetaData 对象,通过这个对象提供的相关方法,就可以取得域名、字段类型等信息。 DatabaseMetaData 或 ResultSetMetaData 本身 API 使用上不难,问题点在于各家数据库 对某些名词的定义不同,必须查阅数据库厂商手册搭配对应的 API,才可以取得想要的 信息。 以下举个例子,利用 JDBC 的 metadata 相关 API,取得前面文档管理范例 t_message 表格相关信息: JDBCDemo TMessageInfo.java package cc.openhome; import java.sql.*; import java.util.*; import javax.sql.DataSource; public class TMessageInfo { private DataSource dataSource; 490 public TMessageInfo(DataSource dataSource) { this.dataSource = dataSource; } public List getAllColumnInfo() { List infos = null; try(Connection conn = dataSource.getConnection()) { DatabaseMetaData meta = conn.getMetaData(); ResultSet crs = meta.getColumns("demo", null, "t_message", null); infos = new ArrayList<>(); while(crs.next()) { ColumnInfo info = new ColumnInfo(); info.setName(crs.getString("COLUMN_NAME")); info.setType(crs.getString("TYPE_NAME")); info.setSize(crs.getInt("COLUMN_SIZE")); info.setNullable(crs.getBoolean("IS_NULLABLE")); info.setDef(crs.getString("COLUMN_DEF")); infos.add(info); } } catch(SQLException ex) { throw new RuntimeException(ex); } return infos; } } 在调用 getAllColumnInfo()时,会先从 Connection 上取得 DatabaseMetaData,以查询数据 库中指定表格的字段,这会取得一个 ResultSet。接着从 ResultSet 上,逐一取得各个想 要的信息,封装为 ColumnInfo 对象,并收集在 List 中返回。 ColumnInfo 只是自定义的简单类,用来封装字段各个信息: JDBCDemo ColumnInfo.java package cc.openhome; import java.io.Serializable; public class ColumnInfo implements Serializable { private String name; private String type; private int size; private boolean nullable; private String def; public String getName() {  查询 t_files 表格所有字段  用来收集字段信息  封装字段名称、类 型、大小、可否为 空、默认值等信息 整合数据库 14 491 return name; } public void setName(String name) { this.name = name; } public String getType() { return type; } public void setType(String type) { this.type = type; } public int getSize() { return size; } public void setSize(int size) { this.size = size; } public boolean isNullable() { return nullable; } public void setNullable(boolean nullable) { this.nullable = nullable; } public String getDef() { return def; } public void setDef(String def) { this.def = def; } } 可以使用以下范例来运用 TMessageInfo 取得字段信息: JDBCDemo TMessageInfoDemo.java package cc.openhome; import java.io.IOException; public class TMessageInfoDemo { public static void main(String[] args) throws IOException, ClassNotFoundException { TMessageInfo tMessageInfo = new TMessageInfo(new SimpleConnectionPoolDataSource()); System.out.println("名称\t 类型\t 为空\t 默认"); for(ColumnInfo columnInfo : tMessageInfo.getAllColumnInfo()) {  查询 t_files 表格所有字段  用来收集字段信息 492 System.out.printf("%s\t%s\t%s\t%s%n", columnInfo.getName(), columnInfo.getType(), columnInfo.isNullable(), columnInfo.getDef()); } } } 一个执行参考结果如下所示: 名称 类型 为空 默认 id INT false null name CHAR false null email CHAR true null msg TEXT false null 14.2.7 RowSet 简介 JDBC 定义了 javax.sql.RowSet 接口,用以代表数据的列集合。这里的数据并不一定是 数据库中的数据,可以是电子表格数据、XML 数据或任何具有列集合概念的数据源。 RowSet 是 ResultSet 的子接口,所以具有 ResultSet 的行为,可以使用 RowSet 对列集合 进行增删查改。RowSet 也新增了一些行为,像是通过 setCommand()设定查询指令、通过 execute()执行查询指令以填充数据等。 在 Sun 的 JDK 中附有 RowSet 的非标准操作,其包名称是 com.sun.rowset。 RowSet 定义了列集合基本行为,其下有 JdbcRowSet、CachedRowSet、FilteredRowSet、 JoinRowSet 与 WebRowSet 五个标准列集合子接口,定义在 javax.sql.rowset 包中。其继承关 系如图 14.18 所示。 图 14.18 RowSet 接口继承架构  用来收集字段信息 整合数据库 14 493 JdbcRowSet 是联机式(Connected)的 RowSet,也就是操作 JdbcRowSet 期间,会保持与数 据库的联机,可视为取得、操作 ResultSet 的行为封装,可简化 JDBC 程序的撰写,或作 为 JavaBean 使用。 CachedRowSet 则为脱机式(Disconnected)的 RowSet(其子接口当然也是),在查询并填充完 数据后,就会断开与数据源的联机,而不用占据相关联机资源,必要的也可以再与数据源 联机进行数据同步。 以下先以 JdbcRowSet 为例,介绍 RowSet 的基本操作。在这里使用的成果是 Oracle/Sun JDK 附带的 JdbcRowSetImpl。在 JDK6 之前,可以这样建立 JdbcRowSet 实例: JdbcRowSet rowset = new JdbcRowSetImpl(); 在JDK7之后,新增了 javax.sql.rowset.RowSetFactory 接口与 javax.sql.rowset.RowSetProvider 类 , 可 以 使 用 RowSetProvider.newFactory() 取得 RowSetFactory 操 作 对 象 , 再 利 用 RowSetFactory 的 createJdbcRowSet()、createCachedRowSet()等方法,建立 RowSet 实例。例如 建立 JdbcRowSet 实例: RowSetFactory rowSetFactory = RowSetProvider.newFactory(); JdbcRowSet rowset = rowSetFactory.createJdbcRowSet(); 如果使用 Oracle/Sun JDK,以上程序片段会取得 JdbcRowSetImpl 实例,若有其他厂商 成果,可以在启动 JVM 时,利用系统属性 javax.sql.rowset.RowSetFactory 指定,例如 -Djavax.sql.rowset.RowSetFactory=com.other.rowset.RowSetFactoryImpl。 要使用 RowSet 查询数据,基本上可以这样: rowset.setUrl("jdbc:mysql://localhost:3306/demo"); rowset.setUsername("root"); rowset.setPassword("123456"); rowset.setCommand("SELECT * FROM t_messages WHERE id = ?"); rowset.setInt(1, 1); rowset.execute(); 可以使用 setUrl()设定 JDBC URL,使用 setUsername()设定用户名称,使用 setPassword() 设定密码,使用 setCommand()设定查询 SQL。JdbcRowSet 也有 setAutocommit()与 commit() 方法,可以进行交易控制。 由于 RowSet 是 ResultSet 的子接口,要取得各字段数据,只要如 ResultSet 操作即可, 若要使用 RowSet 进行增删改的动作,也是与 ResultSet 相同。例如,下例使用 JdbcRowSet 改写 14.1.3 节中的 MessageDAO,可以比较使用 JdbcRowSet 之后的差别: JDBCDemo MessageDAO4.java package cc.openhome; import java.sql.*; import java.util.*; import javax.sql.rowset.*; public class MessageDAO4 { 494 private JdbcRowSet rowset; public MessageDAO4( String url, String user, String passwd) throws SQLException { JdbcRowSet rowset = RowSetProvider.newFactory().createJdbcRowSet(); rowset.setUrl(url); rowset.setUsername(user); rowset.setPassword(passwd); rowset.setCommand("SELECT * FROM t_message"); rowset.execute(); } public void add(Message message) throws SQLException { rowset.moveToInsertRow(); rowset.updateString(2, message.getName()); rowset.updateString(3, message.getEmail()); rowset.updateString(4, message.getMsg()); rowset.insertRow(); } public List get() throws SQLException { List messages = new ArrayList<>(); rowset.beforeFirst(); while (rowset.next()) { Message message = new Message(); message.setId(rowset.getLong(1)); message.setName(rowset.getString(2)); message.setEmail(rowset.getString(3)); message.setMsg(rowset.getString(4)); messages.add(message); } return messages; } public void close() throws SQLException { if (rowset != null) { rowset.close(); } } } 在这个 MessageDAO4 会重复使用已建立的 JdbcRowSet 操作对象,如果不需要使用 JdbcRowSet 了,可以调用 MessageDAO4 的 close()方法,当中会使用 JdbcRowSet 的 close()方 法关闭 JdbcRowSet。 如果在查询之后,想要脱机进行操作,则可以使用 CachedRowSet 或其子接口操作对象, 查询数据之后可以直接使用 close()关闭联机。若在相关更新操作之后,想再与数据源进行 同步,则可以调用 acceptChanges()方法。例如: 整合数据库 14 495 conn.setAutoCommit(false); // conn 是 Connection rowSet.acceptChanges(conn); // rowSet 是 CachedRowSet conn.setAutoCommit(true); WebRowSet 是 CachedRowSet 的子接口,其不仅具备脱机操作,还能进行 XML 读写。例如 以下的 TMessageUtil.writeXml() 方法,可以读取数据库的表格数据,然后对指定的 OutputStream 写出 XML: JDBCDemo TMessageUtil.java package cc.openhome; import java.io.*; import javax.sql.rowset.*; public class TMessageUtil { public static void writeXml(OutputStream outputStream) throws Exception { try(WebRowSet rowset = RowSetProvider.newFactory().createWebRowSet()) { rowset.setUrl("jdbc:mysql://localhost:3306/demo"); rowset.setUsername("root"); rowset.setPassword("123456"); rowset.setCommand("SELECT * FROM t_message"); rowset.execute(); rowset.writeXml(outputStream); } } public static void main(String[] args) throws Exception { TMessageUtil.writeXml(System.out); } } 从 JDK7 之后,RowSet 都是 java.lang.AutoCloseable 的子接口,可以使用尝试自动关闭 资源语法。范例中使用 WebRowSet 中的 writeXML(),可以将 WebRowSet 的 Metadata、属性与数 据以 XML 格式写出。一个执行结果如下所示: ... 1 良葛格 caterpillar@openhome.cc 这是一篇测试留言! 2 毛美眉 momor@openhome.cc 我来留言啰! 496 只要 TMessageUtil.writeXml()指定的目的地,有办法处理 XML,就可以自行组织出想 要的信息或画面。 FilteredRowSet 可以对列集合进行过滤,实现类似 SQL 中 WHERE 等条件式的功能。 可以通过 setFilter()方法,指定操作 javax.sql.rowset.Predicate 的对象。其定义如下: boolean evaluate(Object value, int column) boolean evaluate(Object value, String columnName) boolean evaluate(RowSet rs) Predicate 的 evaluate()方法返回 true,表示该列要包括在过滤后的列集合中。 JoinRowSet 则可以让你结合两个 RowSet 对象,实现类似 SQL 中 JOIN 的功能。可以通 过 setMatchColumn()指定要结合的行,然后使用 addRowSet()来加入 RowSet 进行结合。例如: rs1.setMatchColumn(1); rs2.setMatchColumn(2); JoinRowSet jrs = JoinRowSet jrs = new JoinRowSetImpl(); jrs.addRowSet(rs1); jrs.addRowSet(rs2); 在这个范例片段执行过后,JoinRowSet 中就会是原本两个 RowSet 结合的结果。也可以 通过 setJoinType()指定结合的方式,可指定的常数定义在 JoinRowSet 中,包括 CROSS_JOIN、 FULL_JOIN、INNER_JOIN、LEFT_OUTER_JOIN 与 RIGHT_OUTER_JOIN。 API 文件对 RowSet 的文件说明蛮清楚的,更多有关 RowSet 的说明,也可以参考: http://java.sun.com/developer/Books/JDBCTutorial/chapter5.html 14.3 重点复习 JDBC(Java DataBase Connectivity)是用于执行 SQL 的解决方案,开发人员使用 JDBC 的标准接口,数据库厂商则对接口进行操作,开发人员无须接触底层数据库驱动程 序的差异性。 厂商在操作 JDBC 驱动程序时,依方式可将驱动程序分为四种类型:  Type 1:JDBC-ODBC Bridge Driver  Type 2:Native API Driver  Type 3:JDBC-Net Driver  Type 4:Native Protocol Driver 数据库操作相关的 JDBC 接口或类都位于 java.sql 包中。要连接数据库,可以向 DriverManager 取得 Connection 对象。Connection 是数据库联机的代表对象,一个 Connection 对象就代表一个数据库联机。SQLException 是在处理 JDBC 时经常遇到的一个异常对象, 为数据库操作过程发生错误时的代表对象。 整合数据库 14 497 取得联机等与数据库来源相关的行为规范在 javax.sql.DataSource 接口,实际如何取得 Connection 则由操作接口的对象来负责。 Connection 是数据库连接的代表对象,接下来要执行 SQL 的 话 , 必 须 取 得 java.sql.Statement 对 象 , 它 是 SQL 描述的代表对象。可以使用 Connection 的 createStatement()来建立 Statement 对象。 Statement 的 executeQuery() 方 法 则 是 用 于 SELECT 等 查 询 数 据 库 的 SQL, executeUpdate() 会 返 回 int 结果,表示数据变动的笔数,executeQuery() 会返回 java.sql.ResultSet 对象,代表查询的结果,查询的结果会是一笔一笔的数据。可以使用 ResultSet 的 next()来移动至下一笔数据,它会返回 true 或 false 表示是否有下一笔数据, 接着可以使用 getXXX()来取得数据。 在 使 用 Connection 、 Statement 或 ResultSet 时,要将之关闭以释放相关 资源。 如果有些操作只是 SQL 语句当中某些参数会有所不同,其余的 SQL 子句皆相同,则 可以使用 java.sql.PreparedStatement。可以使用 Connection 的 preparedStatement()方法建立 好一个预先编译(precompile)的 SQL 语句,当中参数会变动的部分,先指定“?”这个占位 字符。等到需要真正指定参数执行时,再使用相对应的 setInt()、setString()等方法,指 定“?”处真正应该有的参数。 14.4 课后练习 14.4.1 选择题 1. JDBC 驱动程序有跨平台特性的是( )。 A. TYPE 1 B.TYPE 2 C. TYPE 3 D. TYPE 4 2. JDBC 驱动程序是基于数据库所提供的 API 来进行操作的是( )。 A. TYPE 1 B. TYPE 2 C. TYPE 3 D. TYPE 4 3. JDBC 相关接口或类是放在( )包之下加以管理。 A. java.lang B. javax.sql C. java.sql D. java.util 4. 使用 JDBC 时,通常会需要处理( )受检异常(Checked Exception)。 A. RuntimeException B. SQLException C. DBException D. DataException 5. 关于 Connection 的描述,正确的是( )。 A. 可以从 DriverManager 上取得 Connection B. 可以从 DataSource 上取得 Connection C. 在方法结束之后 Connection 会自动关闭 D. Connection 是线程安全(Thread-safe) 498 6. 使用 Statement 来执行 SELECT 等查询用的 SQL 指令时,应使用下列( )方法。 A. executeSQL() B. executeQuery() C. executeUpdate() D. executeFind() 7. ( )对象正确使用下,可以适当地避免 SQL Injection 的问题。 A. Statement B. ResultSet C. PreparedStatement D. Command 8. 取得 Connection 之后,取得 Statement 对象的方法是( )。 A. conn.createStatement() B. conn.buildStatement() C. conn.getStatement() D. conn.createSQLStatement() 9. 以下描述有误的是( )。 A. 使用 Statement 一定会发生 SQL Injection B. 使用 PreparedStatement 就不会发生 SQL Injection C. 不使用 Connection 时必须加以关闭 D. ResultSet 代表查询的结果集合 10. 使用 Statement 的 executeQuery()方法,会返回( )类型。 A. int B. Boolean C. ResultSet D. Table 14.4.2 操作题 请尝试撰写一个 JdbcTemplate 类封装 JDBC 更新操作,可以这样使用其 update()方法: JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); jdbcTemplate.update( "INSERT INTO t_message(name, email, msg) VALUES (?,?,?)", "测试员", "tester@openhome.cc", "这是一个测试留言"); 其中 dataSource 参考至 DataSource 操作对象,update()第一个参数接受更新 SQL,之后 的不定长度自变量可接受 SQL 中占位字符?实际数据,不定长度自变量部分不一定是字符 串,也可接受表 14.1 列出的数据类型。 思考一下,如果不能使用 JDK7 尝试自动关闭资源语法,那使用 try、catch、finally 该 怎么写?可以参考一下 8.2.2 节的内容。
还剩146页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 15 金币 [ 分享pdf获得金币 ] 2 人已下载

下载pdf

pdf贡献者

louise_1

贡献于2012-04-25

下载需要 15 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf