1.简介
1.1 IO分类
Java IO一般包含两个部分:http://java.io的阻塞型IO和java.nio的非阻塞型IO,即NIO。
系统运行的瓶颈一般在于IO操作,一般打开某个IO通道需要大量的时间,同时端口中不一定就有足够的数据,这样read方法就一直等待读取此端口的内容,从而浪费大量的系统资源。
使用java的多线程技术在当前进程中创建线程也是要花费一定的时间和系统资源的,因此不一定可取。
Java New IO的非堵塞技术主要采用了Observer模式,就是有一个具体的观察者监测IO端口,如果有数据进入就会立即通知相应的应用程序。这样我们就避免建立多个线程,同时也避免了read等待的时间
Java的IO主要包括三个部分:- 流式部分:IO主体部分;
- 非流式部分:主要包括一些辅助流式部分的类,File类/RandomAccessFile类和FileDescription类;
- 文件读取部分的与安全相关的类,如SerializablePerrmission类;以及与本次操作系统相关的文件系统的类,如FileSystem类和Win32FileSystem类和WinNTFileSystem类。
流式部分可以概括为:两个对应一个桥梁。两个对应指:字节流(Byte Stream)和字符流(Char Stream)的对应;输入和输出的对应。一个桥梁指:从字节流到字符流的桥梁。对应于输入和输出为InputStreamReader和OutputStreamWriter。
在流的具体类中又可以具体分为:介质流(Media Stream或者称为原始流Raw Stream)——主要指一些基本的流,他们主要是从具体的介质上,如:文件、内存缓冲区(Byte数组、Char数组、StringBuffer对象)等,读取数据;
过滤流(Filter Stream)——主要指所有FilterInputStream/FilterOutputStream和FilterReader/FilterWriter的子类,主要是对其包装的类进行某些特定的处理,如:缓存等。
1.2 IO中的流
流具有最基本的特点:“One dimension , one direction.”即流是一维的,同时流是单向的。单向就是只可以一个方向(按顺序从头至尾依次)读取,不可以读到某个位置,再返回前面某个位置。
有些场合我们需要在文件中随机插入数据、在流中来来回回地执行某些操作,这时候我们绝对不可以使用流相关的对象。需要使用单独的类RandomAccessFile,它可以完成打开、关闭文件、以基本数据类型的方式读取数据、读取下一个行、以UTF等格式读取数据、写入各种类型的数据、比较特殊的是他可以通过文件指针的seek方法让文件指针移到某个位置,可以通过getFilePointer方法得到当前指针的位置、可以通过length()方法得到当前文件的容量、通过getFD得到FileDescriptor对象,通过getChannel方法得到FileChannel对象,从而和New IO整合。
流的本质是对文件的处理。Java处理文件的类是File。
public class FileExample{
public static void main(String[] args){
createFile();
}
/*** 文件处理示例*/
public static void createFile(){
File f = new File("E:/电脑桌面/jar/files/create.txt");
try{
f.createNewFile();//当且仅当不存在具有此抽象路径名指定名称的文件时,不可分地创建一个新的空文件。 System.out.println("该分区大小" + f.getTotalSpace()/(1024 * 1024 * 1024) + "G");//返回由此抽象路径名表示的文件或目录的大小。 f.mkdirs();//创建此抽象路径名指定的目录,包括所有必需但不存在的父目录。 f.delete(); // 删除此抽象路径名表示的文件或目录 System.out.println("文件名" + f.getName());// 返回由此抽象路径名表示的文件或目录的名称。 System.out.println("文件父目录字符串" + f.getParent());// 返回此抽象路径名父目录的路径名字符串;如果此路径名没有指定父目录,则返回 null。 } catch(Exception e){
e.printStackTrace();
}
}
}
2.字节流的输入和输出
2.1 输入字节流
InputStream是所有输入字节流的父类,是一个抽象类。
ByteArrayInputStream、StringBufferInputStream、FileInputStream是三种基本的介质流,它们分别将Byte数组、StringBuffer、和本地文件中读取数据。PipedInputStream是从与其它线程共用的管道中读取数据。
ObjectInputStream和所有FilterInputStream的子类都是装饰流(装饰器模式的主角)。
2.1.1 ByteArrayInputStream
功能:将内存中的Byte数组适配为一个InputStream。
构造方式:从内存中的Byte数组创建该对象(2种方法);
使用方法:一般作为数据源,会使用其它装饰流提供额外的功能,一般都建议加个缓冲功能。
2.1.2 StringBufferInputStream(已弃用)
功能:将内存中的字符串适配为一个InputStream。
构造方式:从一个String对象创建该对象。底层的实现使用StringBuffer。该类被Deprecated。主要原因是StringBuffer不应该属于字节流,所以推荐使用StringReader。
使用方法:一般作为数据源,同样会使用其它装饰器提供额外的功能。
2.1.3 FileInputStream
最基本的文件输入流。主要用于从文件中读取信息。
构造方式:通过一个代表文件路径的String、File对象或者FileDescriptor对象创建。
使用方法:一般作为数据源,同样会使用其它装饰器提供额外的功能。
2.1.4 PipedInputStream
读取从对应PipedOutputStream写入的数据。在流中实现了管道的概念。
构造方式:利用对应的PipedOutputStream创建。
使用方法:在多线程程序中作为数据源,同样会使用其它装饰器提供额外的功能。
2.1.5 SequenceInputStream
将2个或者多个InputStream 对象转变为一个InputStream.
构造方式:使用两个InputStream或者内部对象为InputStream的Enumeration对象创建该对象。
使用方法:一般作为数据源,同样会使用其它装饰器提供额外的功能。
2.1.6 FilterInputStream
给其它被装饰对象提供额外功能的抽象类。
2.1.7 DataInputStream
一般和DataOutputStream配对使用,完成基本数据类型的读写。
构造方式:利用一个InputStream构造。
使用方法:提供了大量的读取基本数据类新的读取方法。
2.1.8 BufferedInputStream
使用该对象阻止每次读取一个字节都会频繁操作IO。将字节读取一个缓存区,从缓存区读取。
构造方式:利用一个InputStream、或者带上一个自定义的缓存区的大小构造。
使用方法:使用InputStream的方法读取,只是背后多一个缓存的功能。设计模式中透明装饰器的应用。
2.1.9 LineNumberInputStream
跟踪输入流中的行号。可以调用getLineNumber()和setLineNumber(int)方法得到和设置行号。
构造方式:利用一个InputStream构造。
使用方法:仅仅是增加一个行号。可以象使用其它InputStream一样使用。
2.1.10 PushbackInputStream
可以在读取最后一个byte 后将其放回到缓存中。
构造方式:利用一个InputStream构造。
使用方法:一般仅仅会在设计compiler的scanner时会用到这个类。在我们的java语言的编译器中使用它。
2.2 输出字节流
1.OutputStream是所有的输出字节流的父类,它是一个抽象类。
2.ByteArrayOutputStream、FileOutputStream是两种基本的介质流,它们分别向Byte数组、和本地文件中写入数据。PipedOutputStream是向与其它线程共用的管道中写入数据
3.ObjectOutputStream和所有FilterOutputStream的子类都是装饰流。
2.2.1 ByteArrayOutputStream
在内存中创建一个buffer。所有写入此流中的数据都被放入到此buffer中。
构造方式:无参或者使用一个可选的初始化buffer的大小的参数构造。
使用方法:一般将其和FilterOutputStream套接得到额外的功能。建议首先和BufferedOutputStream套接实现缓冲功能。通过toByteArray方法可以得到流中的数据。
2.2.2 FileOutputStream
将信息写入文件中。
构造方式:使用代表文件路径的String、File对象或者 FileDescriptor对象创建。还可以加一个代表写入的方式是否为append的标记。
使用方法:一般将其和FilterOutputStream套接得到额外的功能。
2.2.3 PipedOutputStream
任何写入此对象的信息都被放入对应PipedInputStream对象的缓存中,从而完成线程的通信,实现了“管道”的概念。
构造方式:利用PipedInputStream构造
使用方法:在多线程程序中数据的目的地的。一般将其和FilterOutputStream套接得到额外的功能。
2.2.4 FilterOutputStream
实现装饰器功能的抽象类。为其它OutputStream对象增加额外的功能。
2.2.5 DataOutputStream
通常和DataInputStream配合使用,使用它可以写入基本数据类新。
构造方式:使用OutputStream构造
使用方法:包含大量的写入基本数据类型的方法。
2.2.6 PrintStream
产生具有格式的输出信息。(一般地在java程序中DataOutputStream用于数据的存储,即J2EE中持久层完成的功能,PrintStream完成显示的功能,类似于J2EE中表现层的功能)
构造方式:使用OutputStream和一个可选的表示缓存是否在每次换行时是否flush的标记构造。还提供很多和文件相关的构造方法。
使用方法:一般是一个终极(“final”)的包装器
2.2.7 BufferedOutputStream
使用它可以避免频繁地向IO写入数据,数据一般都写入一个缓存区,在调用flush方法后会清空缓存、一次完成数据的写入。
构造方式:从一个OutputStream或者和一个代表缓存区大小的可选参数构造
使用方法:提供和其它OutputStream一致的接口,只是内部提供一个缓存的功能。
2.3 字节流的输入和输出的对应
1.LineNumberInputStream主要完成从流中读取数据时,会得到相应的行号,至于什么时候分行、在哪里分行是由类主动确定的,并不是在原始中有这样一个行号。在输出部分没有对应的部分,我们完全可以自己建立一个LineNumberOutputStream,在最初写入时会有一个基准的行号,以后每次遇到换行时会在下一行添加一个行号。
2.PushbackInputStream的功能是查看最后一个字节,不满意就放入缓冲区。主要用在编译器的语法、词法分析部分。输出部分的BufferedOutputStream几乎实现相近的功能。
3.SequenceInputStream可以认为是一个工具类,将两个或者多个输入流当成一个输入流依次读取。
4.PrintStream也可以认为是一个辅助工具。主要可以向其他输出流,或者FileInputStream写入数据,本身内部实现还是带缓冲的。System.out就是PrintStream的实例!
ObjectInputStream/ObjectOutputStream和DataInputStream/DataOutputStream主要是要求写对象/数据和读对象/数据的次序要保持一致,否则轻则不能得到正确的数据,重则抛出异常(一般会如此);
PipedInputStream/PipedOutputStream在创建时一般就一起创建,调用它们的读写方法时会检查对方是否存在,或者关闭!
3 字符流
3.1 输入字符流
1.Reader是所有的输入字符流的父类,它是一个抽象类。
2.CharReader、StringReader是两种基本的介质流,它们分别将Char数组、String中读取数据。PipedReader是从与其它线程共用的管道中读取数据。
3.BufferedReader是一个装饰器,它和其子类负责装饰其它Reader对象。
4.FilterReader是所有自定义具体装饰流的父类,其子类PushbackReader对Reader对象进行装饰,会增加一个行号。
5.InputStreamReader是一个连接字节流和字符流的桥梁,它将字节流转变为字符流。FileReader可以说是一个达到此功能、常用的工具类,在其源代码中明显使用了将FileInputStream转变为Reader的方法。我们可以从这个类中得到一定的技巧。
Reader中各个类的用途和使用方法基本和InputStream中的类使用一致。
3.2 输出字符流
1.Writer是所有的输出字符流的父类,它是一个抽象类。
2.CharArrayWriter、StringWriter是两种基本的介质流,它们分别向Char数组、String中写入数据。PipedWriter是向与其它线程共用的管道中写入数据,
3.BufferedWriter是一个装饰器为Writer提供缓冲功能。
4.PrintWriter和PrintStream极其类似,功能和使用也非常相似。
5.OutputStreamWriter是OutputStream到Writer转换的桥梁,它的子类FileWriter其实就是一个实现此功能的具体类。
4.实例分析
4.1 字节流实例
4.1.1 FileInputStream
//检测文件长度public class FileCount {
public static void main(String[] args) throws IOException {
int count = 0;
InputStream streamReader = null;
try {
//FileInputStream是有缓冲区的,所以用完之后必须关闭, //否则可能导致内存占满,数据丢失 streamReader = new FileInputStream("F:/fileTest.doc");
while(streamReader.read() != -1) {
count++;
}
System.out.println("---长度是: "+count+" 字节");
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
try {
streamReader.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
InputStream还有其他方法mark,reset,markSupported方法,例如:markSupported 判断该输入流能支持mark 和 reset 方法。- mark用于标记当前位置;在读取一定数量的数据(小于readlimit的数据)后使用reset可以回到mark标记的位置。
- FileInputStream不支持mark/reset操作;BufferedInputStream支持此操作;
- mark(readlimit)的含义是在当前位置作一个标记,制定可以重新读取的最大字节数,也就是说你如果标记后读取的字节数大于readlimit,你就再也回不到回来的位置了。
- 通常InputStream的read()返回-1后,说明到达文件尾,不能再读取。除非使用了mark/reset。
4.1.2 FileOutputStream
Java I/O默认是不缓冲流的,所谓“缓冲”就是先把从流中得到的一块字节序列暂存在一个被称为buffer的内部字节数组里,然后你可以一下子取到这一整块的 字节数据。
/*** 文件复制* @author Administrator**/
public class FileCopy {
public static void main(String[] args) throws IOException {
byte[] buffer = new byte[512];//一次取出的字节数大小,缓冲区大小 int numberRead = 0;
FileInputStream input = null;
FileOutputStream output = null;
try {
input = new FileInputStream("F:/fileTest.doc");
output = new FileOutputStream("F:/fileTest2.doc");
numberRead的目的在于防止最后一次读取的字节小于buffer长度 while((numberRead = input.read(buffer)) != -1) {
output.write(buffer, 0, numberRead);//否则会自动被填充0 }
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
input.close();
output.close();
}
}
}
4.1.3 ObjectStream
该流允许读取或写入用户自定义的类,但是要实现这种功能,被读取和写入的类必须实现Serializable接口。
public class Student implements Serializable {
private String name;
private int age;
public Student(String name, int age) {
super();
this.name = name;
this.age = age;
}
public Student() {
super();
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "Student [name=" + name + ", age=" + age +"]";
}
}
下面的程序实现如下功能:如果文件夹中没有该文件,则生成该文件;如果存在该文件,则将文件中的内容全部删除,更改为objectwriter.writeObject()中写入的内容。
public class ObjectStream {
public static void main(String[] args) {
ObjectOutputStream objectwriter = null;
ObjectInputStream objectreader = null;
try {
objectwriter = new ObjectOutputStream(new FileOutputStream("F:/fileTest.doc"));
objectwriter.writeObject(new Student("gg", 22));
objectwriter.writeObject(new Student("tt", 28));
objectwriter.writeObject(new Student("rr", 29));
objectreader = new ObjectInputStream(new FileInputStream("F:/fileTest.doc"));
for(int i = 0; i < 3; i++) {
System.out.println(objectreader.readObject());
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
objectreader.close();
objectwriter.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
运行程序后,文件的内容更改为
Student [name=gg, age=22]
Student [name=tt, age=28]
Student [name=rr, age=29]
4.1.4 DataStream
有时没有必要存储整个对象的信息,而只是要存储一个对象的成员数据,成员数据的类型假设都是Java的基本数据类型,这样的需求不必使用到与Object输入、输出相关的流对象,可以使用DataInputStream、DataOutputStream来写入或读出数据.
DataInputStream的好处在于在从文件读出数据时,不用费心地自行判断读入字符串时或读入int类型时何时将停止,使用对应的readUTF()和readInt()方法就可以正确地读入完整的类型数据。
下面程序实现如下功能:将Student类实例的成员数据写入文件中,在读入文件数据后,将这些数据还原为Student对象
public class DataStreamDemo {
public static void main(String[] args) throws Exception {
Student[] students = {new Student("Justin",90),new Student("momor",95),new Student("Bush",88)};
try {
DataOutputStream dataOutputStream = new DataOutputStream(new FileOutputStream(args[0]));
for(Student student : students) {
//写入UTF字符串 dataOutputStream.writeUTF(student.getName());
//写入int数据 dataOutputStream.writeInt(student.getAge());
}
//所有数据至目的地 dataOutputStream.flush();
//关闭流 dataOutputStream.close();
DataInputStream dataInputStream = new DataInputStream(new FileInputStream(args[0]));
//读出数据并还原为对象 for(int i = 0; i < students.length; i++) {
//读出UTF字符串 String name = dataInputStream.readUTF();
//读出int数据 int age = dataInputStream.readInt();
students[i] = new Student(name, age);
}
//关闭流 dataInputStream.close();
//显示还原后的数据 for(Student student : students) {
System.out.printf("%s\t%d%n",student.getName(),student.getAge());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
程序会报错,后续要进行更改
4.1.5 PushbackInputStream
PushbackInputStream类继承了FilterInputStream类是iputStream类的修饰者。提供可以将数据插入到输入流前端的能力(当然也可以做其他操作)。
简而言之PushbackInputStream类的作用就是能够在读取缓冲区的时候提前知道下 一个字节是什么,其实质是读取到下一个字符后回退的做法类似于数组的遍历,遍历到某个字符的时候可以进行的操作,当然,如果要插入,能够插入的最大字节数是与推回缓冲区的大小相关的,插入字符肯定不能大于缓冲区。
public class PushBackInputStreamDemo {
public static void main(String[] args) throws IOException {
String str = "hello,pushbackinputstream";
PushbackInputStream push = null;// 声明回退流对象 ByteArrayInputStream bat = null;// 声明字节数组流对象 bat = new ByteArrayInputStream(str.getBytes());
// 创建回退流对象,将拆解的字节数组流传入 push = new PushbackInputStream(bat);
int temp = 0;
// push.read()逐字节读取存放在temp中,如果读取完成返回-1 while((temp = push.read()) != -1) {
if(temp == ',') {// 判断读取的是否是逗号 push.unread(temp);//回到temp的位置 temp = push.read();//接着读取字节 // 输出回退的字符 System.out.print("(回退" + (char) temp + ") ");
} else {
System.out.print((char)temp); 否则输出字符 }
}
}
}
输出结果为“hello(回退,) pushbackinputstream”。
4.1.6 SequenceInputStream
有些情况下,当我们需要从多个输入流中向程序读入数据。此时,可以使用合并流,将多个输入 流合并成一个SequenceInputStream流对象。SequenceInputStream会将与之相连接的流集组合成一个输入流并从第一个输入流开始读取,直到到达文件末尾,接着从第二个输入流读取,依次类推,直到到达包含的最后一个输入流的文件末尾为止。合并流的作用是将多个源合并合一个源。其可接收枚举类所封闭的多个字节流对象。
//将三个文件的内容全部整合到一个文件中public class SequenceInputStreamDemo {
public static void main(String[] args) {
doSequence();
}
private static void doSequence() {
// 创建一个合并流的对象 SequenceInputStream sis = null;
// 创建输出流 BufferedOutputStream bos = null;
try {
Vector vector = new Vector<>();
vector.addElement(new FileInputStream("F:/ptest.txt"));
vector.addElement(new FileInputStream("F:/ptest1.txt"));
vector.addElement(new FileInputStream("F:/ptest2.txt"));
Enumeration e = vector.elements();
sis = new SequenceInputStream(e);
bos = new BufferedOutputStream(new FileOutputStream("F:/ptest3.txt"));
//读写数据 byte[] buff = new byte[1024];
int len = 0;
while((len = sis.read(buff)) != -1) {
bos.write(buff, 0, len);
bos.flush();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if(sis != null) {
sis.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if(bos != null) {
bos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
4.2 字符流
Java使用Unicode存储字符串,在写入字符流时我们都可以指定写入的字符串的编码。处理字节流的ByteArrayOutputStream不用抛出异常,在处理字符流时,使用的是CharArrayReader和CharArrayWriter类,使用字符流缓冲区。
字符串放入到字符流中使用如下代码
CharArrayReader reader = new CharArrayReader(str.toCharArray());
4.2.1 FileReader和FileWriter
public class Print {
public static void main(String[] args) throws Exception {
char[] buffer = new char[512];//一次取出的字节数大小,缓冲区大小 int numberRead = 0;
FileReader reader = null;//读取字符文件的流 PrintWriter writer = null;//写字符到控制台的流 try {
reader = new FileReader("F:/ptest3.txt");
//PrintWriter可以输出字符到文件,也可以输出到控制台 writer = new PrintWriter(System.out);
while((numberRead = reader.read(buffer)) != -1) {
writer.write(buffer, 0, numberRead);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
reader.close();
writer.close();
}
}
}
4.2.2 BufferedWriter和BufferedReader
BufferedWriter和BufferedReader用文件级联的方式进行写入,即将多个文件写入到同一文件中,其readLine()方法可实现读取一行数据,并返回String。
public class FileCascade {
public static void main(String[] args) {
try {
cascade(args);
} catch (IOException e) {
e.printStackTrace();
}
}
private static void cascade(String... fileName) throws IOException {
String str;
BufferedWriter write = new BufferedWriter(new FileWriter("F:/ptest2.txt"));
for(String name : fileName) {
BufferedReader reader = new BufferedReader(new FileReader(name));
while((str = reader.readLine()) != null) {
write.write(str);
write.newLine();
}
}
}
}
4.2.3 StreamTokenizer类
StreamTokenizer只处理InputStream 对象。
案例的文本内容为:
'mbjuu'
mingchaonaxieshi
"the best friends of myself"
{3211}
15235 06561354 2564561
i love my mom
. , !
程序代码为
public class StreamTokenDemo {
public static void main(String[] args) throws IOException {
String fileName = "F:/ptest3.txt";
StreamTokenDemo.statis(fileName);
}
private static long statis(String fileName) throws IOException {
FileReader fileReader = null;
try {
fileReader = new FileReader(fileName);
//创建分析给定字符流的标记生成器 StreamTokenizer streamTokenizer = new StreamTokenizer(new BufferedReader(fileReader));
//ordinaryChar方法指定字符参数在此标记生成器中是“普通”字符 streamTokenizer.ordinaryChar('\'');
streamTokenizer.ordinaryChar('\"');
streamTokenizer.ordinaryChar('/');
String s;
int numberSum = 0;
int wordSum = 0;
int symbolSum = 0;
int total = 0;
//nextToken方法读取下一个Token //TT_EOF指示已读到流末尾的常量 while(streamTokenizer.nextToken() != StreamTokenizer.TT_EOF) {
//在调用 nextToken 方法之后,ttype字段将包含刚读取的标记的类型 switch(streamTokenizer.ttype) {
//TT_EOL指示已读到行末尾的常量 case StreamTokenizer.TT_EOL:
break;
//TT_NUMBER指示已读到一个数字标记的常量 case StreamTokenizer.TT_NUMBER:
s = String.valueOf(streamTokenizer.nval);
System.out.println("数字有:"+s);
numberSum++;
break;
//TT_WORD指示已读到一个文字标记的常量 case StreamTokenizer.TT_WORD:
//如果当前标记是一个文字标记,sval字段包含一个给出该文字标记的字符的字符串 s = streamTokenizer.sval;
System.out.println("单词有: "+s);
wordSum ++;
break;
default:
//如果以上3中类型都不是,则为英文的标点符号 s = String.valueOf((char) streamTokenizer.ttype);
System.out.println("标点有: "+s);
symbolSum ++;
}
}
System.out.println("数字有 " + numberSum+"个");
System.out.println("单词有 " + wordSum+"个");
System.out.println("标点符号有: " + symbolSum+"个");
total = symbolSum + numberSum +wordSum;
System.out.println("Total = " + total);
return total;
} catch (FileNotFoundException e) {
e.printStackTrace();
return -1;
} finally {
if(fileReader != null) {
fileReader.close();
}
}
}
}
运行结果为
标点有: '
单词有: mbjuu
标点有: '
单词有: mingchaonaxieshi
标点有: "单词有: the单词有: best单词有: friends单词有: of单词有: myself标点有: "
标点有: {
数字有:3211.0
标点有: }
数字有:15235.0
数字有:6561354.0
数字有:2564561.0
单词有: i
单词有: love
单词有: my
单词有: mom
数字有:0.0
标点有: ,
标点有: !
数字有 5个
单词有 11个
标点符号有: 8个
Total = 24
参考文献: