数据库和 MIDP,第三部分:使用数据映射
本系列中的 第二部分 介绍了数据映射的基本知识。您学会了如何在字节数组(byte array)中存储原始数据类型的数值,如何使用流向记录库(record store)中存储对象和从记录库中检索对象以及如何从字节数组中抽取已存储的数值。在本文中,您将学会如何使您的应用程序远离这些低级操作,方法是对核心类进行扩展使其包含读写操作,创建并使用字段列表来存储和检索对象。
扩展核心类
J2ME 开发人员的目标之一是最小化应用程序对内存的使用。理想情况下,在任何给定的时间内,内存中应该只含有某个数据块的一个副本。然而,当您向 ByteArrayOutputStream
写数据时,您从来无法访问其基本的字节数组--调用 toByteArray()
返回该数组的一个拷贝。如果您紧接着就将该字节数组保存到记录库中,则会造成没必要的内存浪费。要直接访问该数组,只需要对类 ByteArrayOutputStream
进行简单的扩展即可,扩展后的类称为 DirectByteArrayOutputStream
:
package j2me.io; import java.io.*; // A version of ByteArrayOutputStream that gives you // direct access to the underlying byte array if // you need it. public class DirectByteArrayOutputStream extends ByteArrayOutputStream { // Constructs a byte array output stream of default size public DirectByteArrayOutputStream(){ super(); } // Constructs a byte array output stream of given size public DirectByteArrayOutputStream( int size ){ super( size ); } // Returns a reference to the underlying byte array. // The actual amount of data in the byte array is // obtained via the size method. public synchronized byte[] getByteArray(){ return buf; } // Swaps in a new byte array for the old one, resetting // the count as well. public synchronized byte[] swap( byte[] newBuf ){ byte[] oldBuf = buf; buf = newBuf; reset(); return oldBuf; } }
记住调用 size()
方法查看存储在字节数组中的实际数据的多少:
... DirectByteArrayOutputStream bout = ... RecordStore rs = ... int numBytes = bout.size(); byte[] data = bout.getByteArray(); rs.addRecord( data, 0, numBytes ); ...
未来保持一致性,您可以用类似的方法对 ByteArrayInputStream
类进行扩展:
package j2me.io; import java.io.*; // A version of ByteArrayInputStream that lets you // replace the underlying byte array. public class DirectByteArrayInputStream extends ByteArrayInputStream { // Constructs an output stream from the given array public DirectByteArrayInputStream( byte buf[] ){ super( buf ); } // Constructs an output stream from the given subarray public DirectByteArrayInputStream( byte buf[], int offset, int length ){ super( buf, offset, length ); } // Resets the array the stream reads from public synchronized void setByteArray( byte[] buf ){ this.buf = buf; this.pos = 0; this.count = buf.length; this.mark = 0; } // Resets the array the stream reads from public synchronized void setByteArray( byte[] buf, int offset, int length ){ this.buf = buf; this.pos = offset; this.count = Math.min( offset + length, buf.length ); this.mark = offset; } }
注意类ByteArrayInputStream
和类 ByteArrayOutputStream
以及以上所写的两个扩展类都使用了用于线程安全的同步方法。尽管在大多数情况下只有单一线程使用这些流,同步也就没有必要了。如果您的应用程序需要读写大量的数据,可考虑创建基于这些类的非同步的版本以获得较快的速度。
您也可以很容易的对 DataInputStream
和 DataOutputStream
进行扩展。例如,如果您需要在多个地方写整型数组,请使用 DataOutputStream
的以下扩展:
package j2me.io; import java.io.*; public class ExtendedDataOutputStream extends DataOutputStream { public ExtendedDataOutputStream( OutputStream out ){ super( out ); } public final void writeIntArray( int[] arr ) throws IOException { int size = arr.length; writeInt( size ); for( int i = 0; i < size; ++i ){ writeInt( arr[i] ); } } }
相反,您可以将这种代码放入一个帮助器类,因为您不需要访问任何的 protected members。
创建记录字段
现在您已经拥有了创建基于字段的记录库的所有工具,记录库中的每条记录都是一组指定类型的命名字段。您利用两个类来管理该记录库。其中的第一个类 FieldList
管理字段自身的信息--元数据:
package j2me.rms; import java.io.*; import javax.microedition.rms.*; // Maintains information about the fields in a // field-based record store. Currently just a list of // field types and (optional) field names, but could // easily be expanded to store other information. public class FieldList { private static final int VERSION = 1; // The basic field types. public static final byte TYPE_BOOLEAN = 1; public static final byte TYPE_BYTE = 2; public static final byte TYPE_CHAR = 3; public static final byte TYPE_SHORT = 4; public static final byte TYPE_INT = 5; public static final byte TYPE_LONG = 6; public static final byte TYPE_STRING = 7; // Constructs an empty list. public FieldList(){ } // Constructs a list of the given size. public FieldList( int numFields ){ if( numFields < 0 || numFields > 255 ){ throw new IllegalArgumentException( "Bad number of fields" ); } _types = new byte[ numFields ]; _names = new String[ numFields ]; } // Returns the number of fields. public int getFieldCount(){ return _types != null ? _types.length : 0; } // Returns the name of a field. public String getFieldName( int index ){ String name = _names[ index ]; return name != null ? name : ""; } // Returns the type of a field. public byte getFieldType( int index ){ return _types[ index ]; } // Reads the field list from a byte array. public void fromByteArray( byte[] data ) throws IOException { ByteArrayInputStream bin = new ByteArrayInputStream( data ); fromDataStream( new DataInputStream( bin ) ); bin.close(); } // Reads the fields list from a data stream. public void fromDataStream( DataInputStream din ) throws IOException { int version = din.readUnsignedByte(); if( version != VERSION ){ throw new IOException( "Incorrect version " + version + " for FieldList, expected " + VERSION ); } int numFields = din.readUnsignedByte(); _types = new byte[ numFields ]; _names = new String[ numFields ]; if( numFields > 0 ){ din.readFully( _types ); for( int i = 0; i < numFields; ++i ){ _names[i] = din.readUTF(); } } } // Reads a field list from a record store. public void fromRecordStore( RecordStore rs, int index ) throws IOException, RecordStoreException { fromByteArray( rs.getRecord( index ) ); } // Sets the name of a field. public void setFieldName( int index, String name ){ _names[ index ] = name; } // Sets the type of a field. public void setFieldType( int index, byte type ){ _types[ index ] = type; } // Stores the fields list to a byte array public byte[] toByteArray() throws IOException { ByteArrayOutputStream bout = new ByteArrayOutputStream(); toDataStream( new DataOutputStream( bout ) ); byte[] data = bout.toByteArray(); bout.close(); return data; } // Stores the fields list to a data stream public void toDataStream( DataOutputStream out ) throws IOException { out.writeByte( VERSION ); int count = getFieldCount(); out.writeByte( count ); if( count > 0 ){ out.write( _types, 0, count ); for( int i = 0; i < count; ++i ){ out.writeUTF( getFieldName( i ) ); } } } // Writes a field list to a record store. public int toRecordStore( RecordStore rs, int index ) throws IOException, RecordStoreException { byte[] data = toByteArray(); boolean add = true; if( index > 0 ){ try { rs.setRecord( index, data, 0, data.length ); add = false; } catch( InvalidRecordIDException e ){ } } // If the record doesn't actually exist yet, // go ahead and create it by inserting dummy // records ahead of it if( add ){ synchronized( rs ){ int nextID = rs.getNextRecordID(); if( index <= 0 ) index = nextID; while( nextID < index ){ rs.addRecord( null, 0, 0 ); } if( nextID == index ){ rs.addRecord( data, 0, data.length ); } } } return index; } private String[] _names; private byte[] _types; }
实际上,一个 FieldList
实例只是两个数组的一个包装器。
在其核心,一个 FieldList
实例只是两个数组的一个包装器。第一个数组存储每个字段的类型,第二个数组存储每个字段的名称。名称是可选的,重要的是类型,因为它决定如何将数据写入记录和从记录中读出。所有标准的 Java 原始数据类型都支持,还有 String
类型。下面是对存储某个组织的部门列表的字段的定义:
... FieldList depts = new FieldList( 3 ); depts.setFieldType( 0, FieldList.TYPE_SHORT ); depts.setFieldName( 0, "ID" ); depts.setFieldType( 1, FieldList.TYPE_STRING ); depts.setFieldName( 1, "Name" ); depts.setFieldType( 2, FieldList.TYPE_INT ); depts.setFieldName( 2, "ManagerID" ); ...
一个 FieldList
实例可存储在一个数据流、一个字节数组或者一个记录库中。如果您将字段列表存储为记录库中的第一条记录,则任何可打开该记录库的代码就可以读取该记录以决定其余字段的字段布局。要使这个策略起到作用,您必须在创建记录库后立即存储记录列表:
... FieldList list = ... // a field list RecordStore rs = RecordStore.openRecordStore( "foo", true ); if( rs.getNumRecords() == 0 ){ // empty, store it list.toRecordStore( rs, -1 ); } ...
toRecordStore()
方法的第二个参数确定了保存字段数据所使用的记录。负值表示将要添加一条新的记录。
管理基于字段的记录库所需的第二个类是 FieldBasedStore
,它将管理实际的读写操作:
package j2me.rms; import java.io.*; import javax.microedition.rms.*; import j2me.io.*; // A wrapper class for a record store that allows the // records to be accessed as a set of fields. The field // definitions are maintained separately using a FieldList // object, which can be stored as part of the record store // or separately. public class FieldBasedStore { // Some useful constants public static Boolean TRUE = new Boolean( true ); public static Boolean FALSE = new Boolean( false ); // Markers for the types of string we support private static final byte NULL_STRING_MARKER = 0; private static final byte UTF_STRING_MARKER = 1; // Constructs a field store where the field list is // assumed to be stored in the first record. public FieldBasedStore( RecordStore rs ) throws IOException, RecordStoreException { this( rs, 1 ); } // Constructs a field store where the field list is // stored in the given record. public FieldBasedStore( RecordStore rs, int fieldListID ) throws IOException, RecordStoreException { this( rs, loadFieldList( rs, fieldListID ) ); } // Constructs a field store with the given field list. public FieldBasedStore( RecordStore rs, FieldList list ){ _rs = rs; _fieldList = list; } // Adds a new record to the store. Returns the new // record ID. public synchronized int addRecord( Object[] fields ) throws IOException, RecordStoreException { writeStream( fields ); byte[] data = _bout.getByteArray(); return _rs.addRecord( data, 0, data.length ); } // Returns the current field list. public FieldList getFieldList(){ return _fieldList; } // Returns the record store. public RecordStore getRecordStore(){ return _rs; } // Loads the field list from the record store. private static FieldList loadFieldList( RecordStore rs, int fieldListID ) throws IOException, RecordStoreException { FieldList list = new FieldList(); list.fromRecordStore( rs, fieldListID ); return list; } // Prepares the store for input by making sure that // the data buffer is big enough. The streams are // reused. private void prepareForInput( int size ){ if( _buffer == null || _buffer.length < size ){ _buffer = new byte[ size ]; } if( _bin == null ){ _bin = new DirectByteArrayInputStream( _buffer ); _din = new DataInputStream( _bin ); } else { _bin.setByteArray( _buffer ); } } // Prepares the store for output. The streams are reused. private void prepareForOutput(){ if( _bout == null ){ _bout = new DirectByteArrayOutputStream(); _dout = new DataOutputStream( _bout ); } else { _bout.reset(); } } // Reads a field from the buffer. private Object readField( int type ) throws IOException { switch( type ){ case FieldList.TYPE_BOOLEAN: return _din.readBoolean() ? TRUE : FALSE; case FieldList.TYPE_BYTE: return new Byte( _din.readByte() ); case FieldList.TYPE_CHAR: return new Character( _din.readChar() ); case FieldList.TYPE_SHORT: return new Short( _din.readShort() ); case FieldList.TYPE_INT: return new Integer( _din.readInt() ); case FieldList.TYPE_LONG: return new Long( _din.readLong() ); case FieldList.TYPE_STRING: { byte marker = _din.readByte(); if( marker == UTF_STRING_MARKER ){ return _din.readUTF(); } } } return null; } // Reads the record at the given ID and returns it as // a set of objects that match the types in the // field list. public synchronized Object[] readRecord( int recordID ) throws IOException, RecordStoreException { prepareForInput( _rs.getRecordSize( recordID ) ); _rs.getRecord( recordID, _buffer, 0 ); int count = _fieldList.getFieldCount(); Object[] fields = new Object[ count ]; for( int i = 0; i < count; ++i ){ fields[i] = readField(_fieldList.getFieldType(i)); } return fields; } // Converts an object to a boolean value. public static boolean toBoolean( Object value ){ if( value instanceof Boolean ){ return ((Boolean) value).booleanValue(); } else if( value != null ){ String str = value.toString().trim(); if( str.equals( "true" ) ) return true; if( str.equals( "false" ) ) return false; return( toInt( value ) != 0 ); } return false; } // Converts an object to a char. public static char toChar( Object value ){ if( value instanceof Character ){ return ((Character) value).charValue(); } else if( value != null ){ String s = value.toString(); if( s.length() > 0 ){ return s.charAt( 0 ); } } return 0; } // Converts an object to an int. This code // would be much simpler if the CLDC supported // the java.lang.Number class. public static int toInt( Object value ){ if( value instanceof Integer ){ return ((Integer) value).intValue(); } else if( value instanceof Boolean ){ return ((Boolean) value).booleanValue() ? 1 : 0; } else if( value instanceof Byte ){ return ((Byte) value).byteValue(); } else if( value instanceof Character ){ return ((Character) value).charValue(); } else if( value instanceof Short ){ return ((Short) value).shortValue(); } else if( value instanceof Long ){ return (int) ((Long) value).longValue(); } else if( value != null ){ try { return Integer.parseInt( value.toString() ); } catch( NumberFormatException e ){ } } return 0; } // Converts an object to a long. This code // would be much simpler if the CLDC supported // the java.lang.Number class. public static long toLong( Object value ){ if( value instanceof Integer ){ return ((Integer) value).longValue(); } else if( value instanceof Boolean ){ return ((Boolean) value).booleanValue() ? 1 : 0; } else if( value instanceof Byte ){ return ((Byte) value).byteValue(); } else if( value instanceof Character ){ return ((Character) value).charValue(); } else if( value instanceof Short ){ return ((Short) value).shortValue(); } else if( value instanceof Long ){ return ((Long) value).longValue(); } else if( value != null ){ try { return Long.parseLong( value.toString() ); } catch( NumberFormatException e ){ } } return 0; } // Writes a field to the output buffer. private void writeField( int type, Object value ) throws IOException { switch( type ){ case FieldList.TYPE_BOOLEAN: _dout.writeBoolean( toBoolean( value ) ); break; case FieldList.TYPE_BYTE: _dout.write( (byte) toInt( value ) ); break; case FieldList.TYPE_CHAR: _dout.writeChar( toChar( value ) ); break; case FieldList.TYPE_SHORT: _dout.writeShort( (short) toInt( value ) ); break; case FieldList.TYPE_INT: _dout.writeInt( toInt( value ) ); break; case FieldList.TYPE_LONG: _dout.writeLong( toLong( value ) ); break; case FieldList.TYPE_STRING: if( value != null ){ String str = value.toString(); _dout.writeByte( UTF_STRING_MARKER ); _dout.writeUTF( str ); } else { _dout.writeByte( NULL_STRING_MARKER ); } break; } } // Writes a set of fields to the given record. The // fields must be compatible with the types in // the field list. public synchronized void writeRecord( int recordID, Object[] fields ) throws IOException, RecordStoreException { writeStream( fields ); byte[] data = _bout.getByteArray(); _rs.setRecord( recordID, data, 0, data.length ); } // Writes a set of fields to the output stream. private void writeStream( Object[] fields ) throws IOException { int count = _fieldList.getFieldCount(); int len = ( fields != null ? fields.length : 0 ); prepareForOutput(); for( int i = 0; i < count; ++i ){ writeField( _fieldList.getFieldType( i ), ( i < len ? fields[i] : null ) ); } } private DirectByteArrayInputStream _bin; private DirectByteArrayOutputStream _bout; private byte[] _buffer; private DataInputStream _din; private DataOutputStream _dout; private FieldList _fieldList; private RecordStore _rs; }
要创建一个 FieldBasedStore
,你需要一个 RecordStore
实例和一个 FieldList
实例。您可以在构造 FieldBasedStore
的过程中隐式地从记录库自身中读取后者:
... RecordStore rs = ... // an open record store FieldBasedStore fstore = new FieldBasedStore( rs ); ...
或者您可以对其进行显式指定:
... RecordStore rs = ... // an open record store FieldList list = ... // a field list FieldBasedStore fstore = new FieldBasedStore( rs, list ); ...
FieldBasedStore
将每条记录作为一个对象数组来处理。数组中的数据类型和字段列表中所描述的字段类型相匹配。在上面的部门列表中,每一个都含有一个部门标识符、一个部门名称以及一个经理标识符。您可以用如下方法添加一条记录:
... Object[] fields = new Object[]{ new Short( 1 ), "Accounting", new Integer( 100 ) }; int recordID = fstore.addRecord( fields ); ...
注意 FieldBasedStore
中的写记录代码是具有智能性的,它可以执行“明显的”数据转换,所以您也可以用以下方法进行:
... Object[] fields = new Object[]{ "1", "Accounting", "100" }; int recordID = fstore.addRecord( fields ); ...
读取记录也同样简单:
... Object[] fields = fstore.readRecord( recordID ); for( int i = 0; i < fields.length; ++i ){ System.out.println( "Field: " + fstore.getFieldList().getFieldName( i ) + " Value: " + fields[i] ); } ...
您可以在任何时候重写该数组来修改记录:
... Object[] fields = fstore.readRecord( recordID ); fields[2] = "134"; // change the manager fstore.writeRecord( recordID, fields ); ...
这里有一个 MIDlet 的示例,它使用了一对基于字段的记录库来存储和检索雇员和部门数据。它也可使用 第一部分 所述的 RMSAnalyzer
类将记录库中的内容倒出,只是向您表明记录是如何被存储的。
import java.io.*; import java.util.*; import javax.microedition.lcdui.*; import javax.microedition.midlet.*; import javax.microedition.rms.*; import j2me.rms.*; // A simple MIDlet for testing RMS mappings // done using the FieldBasedStore class. public class RMSMappings extends MIDlet implements CommandListener { private Display display; public static final Command exitCommand = new Command( "Exit", Command.EXIT, 1 ); public static final Command testCommand = new Command( "Test", Command.SCREEN, 1 ); private static Object[][] empList = { new Object[]{ "1", "Mary", "CEO", "100", "F" }, new Object[]{ "2", "John", "CFO", "200", "M" }, new Object[]{ "3", "Pat", "Closem", "300", "F" }, new Object[]{ "4", "PJ", "Admin", "100", "M" }, }; private static Object[][] deptList = { new Object[]{ "100", "Executive", "1" }, new Object[]{ "200", "Operations", "2" }, new Object[]{ "300", "Sales", "1" }, }; public RMSMappings(){ } public void commandAction( Command c, Displayable d ){ if( c == exitCommand ){ exitMIDlet(); } else if( c == testCommand ){ runTest(); } } protected void destroyApp( boolean unconditional ) throws MIDletStateChangeException { exitMIDlet(); } public void exitMIDlet(){ notifyDestroyed(); } public Display getDisplay(){ return display; } protected void initMIDlet(){ display.setCurrent( new MainForm() ); } protected void pauseApp(){ } private void printRecord( FieldBasedStore store, int recordID ){ try { FieldList list = store.getFieldList(); Object[] fields = store.readRecord( recordID ); if( fields.length != list.getFieldCount() ){ System.out.println( "Error: bad count" ); return; } System.out.println( "Record " + recordID + ":" ); for( int i = 0; i < fields.length; ++i ){ System.out.println( " " + list.getFieldName( i ) + ": " + fields[i] ); } } catch( RecordStoreException e ){ } catch( IOException e ){ } } private void runTest(){ // First delete the record stores... System.out.println( "Deleting record stores..." ); String[] names = RecordStore.listRecordStores(); for( int i = 0; i < names.length; ++i ){ try { RecordStore.deleteRecordStore( names[i] ); } catch( RecordStoreException e ){ System.out.println( "Could not delete " + names[i] ); } } // Create two record stores, one with a field list // stored in the first record and the second with // a field list stored separately (in the app) RecordStore empRS = null; RecordStore deptRS = null; FieldList empFields = new FieldList( 5 ); FieldList deptFields = new FieldList( 3 ); FieldBasedStore employees; FieldBasedStore departments; empFields.setFieldType( 0, FieldList.TYPE_INT ); empFields.setFieldName( 0, "ID" ); empFields.setFieldType( 1, FieldList.TYPE_STRING ); empFields.setFieldName( 1, "Given Name" ); empFields.setFieldType( 2, FieldList.TYPE_STRING ); empFields.setFieldName( 2, "Last Name" ); empFields.setFieldType( 3, FieldList.TYPE_BOOLEAN ); empFields.setFieldName( 3, "Active" ); empFields.setFieldType( 4, FieldList.TYPE_CHAR ); empFields.setFieldName( 4, "Sex" ); System.out.println( "Initializing employees" ); try { empRS = RecordStore.openRecordStore( "empRS", true ); // now store the field list in the RS empFields.toRecordStore( empRS, -1 ); employees = new FieldBasedStore( empRS ); } catch( RecordStoreException e ){ System.out.println( "Could not create empRS" ); return; } catch( IOException e ){ System.out.println( "Error storing field list" ); return; } System.out.println( "Initializing departments" ); deptFields.setFieldType( 0, FieldList.TYPE_INT ); deptFields.setFieldName( 0, "ID" ); deptFields.setFieldType( 1, FieldList.TYPE_STRING ); deptFields.setFieldName( 1, "Name" ); deptFields.setFieldType( 2, FieldList.TYPE_INT ); deptFields.setFieldName( 2, "Manager" ); try { deptRS = RecordStore.openRecordStore( "deptRS", true ); departments = new FieldBasedStore( deptRS, deptFields ); } catch( RecordStoreException e ){ System.out.println( "Could not create deptRS" ); return; } int[] empRecordID; int[] deptRecordID; int i; // Add the data... try { empRecordID = new int[ empList.length ]; for( i = 0; i < empList.length; ++i ){ empRecordID[i] = employees.addRecord( empList[i] ); } deptRecordID = new int[ deptList.length ]; for( i = 0; i < deptList.length; ++i ){ deptRecordID[i] = departments.addRecord( deptList[i] ); } } catch( RecordStoreException e ){ System.out.println( "Error adding record" ); return; } catch( IOException e ){ System.out.println( "Error writing field" ); return; } // Now fetch the data back and print it... System.out.println( "---- Employee data ----" ); for( i = 0; i < empRecordID.length; ++i ){ printRecord( employees, empRecordID[i] ); } System.out.println( "---- Department data ----" ); for( i = 0; i < deptRecordID.length; ++i ){ printRecord( departments, deptRecordID[i] ); } System.out.println( "Closing empRS" ); try { empRS.closeRecordStore(); } catch( RecordStoreException e ){ System.out.println( "Error closing empRS" ); } System.out.println( "Closing deptRS" ); try { deptRS.closeRecordStore(); } catch( RecordStoreException e ){ System.out.println( "Error closing deptRS" ); } System.out.println( "Dumping record stores..." ); // Analyze them... RMSAnalyzer analyzer = new RMSAnalyzer( new RMSAnalyzer.SystemLogger( 10 ) ); analyzer.analyzeAll(); } protected void startApp() throws MIDletStateChangeException { if( display == null ){ display = Display.getDisplay( this ); initMIDlet(); } } public class MainForm extends Form { public MainForm(){ super( "RMSMappings" ); addCommand( exitCommand ); addCommand( testCommand ); setCommandListener( RMSMappings.this ); } } }
正如它们现在所表明的,FieldBasedStore
和 FieldList
类可以使用一些改善。例如,基于字段的库也可以基于游标,像一个 JDBC 结果集,并使您在记录之间移动并抽取单个字段值。记录库可以缓冲记录。字段列表可以返回给定名称的字段的索引。当然,这些和其他的改进都是有同样代价的,于是您不得不在需要什么和什么会在您的平台上起作用之间进行平衡。当您预先不知道数据结构时,一个通用的基于字段的方法是最合适的。当然,预先不知道数据结构的情况并不常出现。通常,最好的方案是在一个持久存储对象中封装已知的对象类型。我在这里提出通用的方法,主要是告诉您最多能做些什么。
下一部分提要
第四节将讨论使用 RMS 的更高级的方面:遍历记录库和筛选记录。
关于作者
Eric Giguere 是来自 Sybase 子公司 iAnywhere Solutions 的一个软件开发人员,主要从事用于手持设备和无线计算的 Java 技术。他拥有 Waterloo 大学的 BMath 和 MMath 学位,并广泛撰写有关计算主题的文章。