Guide to the JDBC ResultSet Interface – JDBC结果集接口指南

最后修改: 2019年 3月 9日

中文/混合/英文(键盘快捷键:t)

1. Overview

1.概述

The Java Database Connectivity (JDBC) API provides access to the database from a Java application. We can use JDBC to connect to any database as long as the supported JDBC driver is available.

Java数据库连接(JDBC)API提供了从Java应用程序访问数据库的功能。只要支持的JDBC驱动程序可用,我们就可以使用JDBC连接到任何数据库。

The ResultSet is a table of data generated by executing database queries. In this tutorial, we’re going to take a deeper look at the ResultSet API.

ResultSet是执行数据库查询所生成的数据表。在本教程中,我们将深入了解ResultSet API

2. Generating a ResultSet

2.生成一个ResultSet

First, we retrieve a ResultSet by calling executeQuery() on any object implementing the Statement interface. Both the PreparedStatement and the CallableStatement are subinterfaces of Statement:

首先,我们通过对任何实现Statement接口的对象调用executeQuery()来检索一个ResultSetPreparedStatementCallableStatement都是Statement的子接口。

PreparedStatement pstmt = dbConnection.prepareStatement("select * from employees");
ResultSet rs = pstmt.executeQuery();

The ResultSet object maintains a cursor that points to the current row of the result set. We’ll use next() on our ResultSet to iterate through the records.

ResultSet对象维护一个游标,该游标指向结果集的当前行。我们将在我们的ResultSet上使用next()来遍历这些记录。

Next, we’ll use the getX() methods while iterating through the results to fetch the values from the database columns, where X is the datatype of the column. In fact, we’ll provide database column names to the getX() methods:

接下来,我们将使用getX()方法,同时迭代结果,从数据库列中获取数值,其中X是列的数据类型。事实上,我们将向getX()方法提供数据库列名。

while(rs.next()) {
    String name = rs.getString("name");
    Integer empId = rs.getInt("emp_id");
    Double salary = rs.getDouble("salary");
    String position = rs.getString("position");
}

Likewise, the index number of the column can be used with the getX() methods instead of the column name. The index number is the sequence of the columns in the SQL select statement.

同样,列的索引号可以用getX()方法来代替列名。索引号是SQL选择语句中的列的顺序。

If the select statement doesn’t list column names, the index number is the sequence of columns in the table. The column index numbering starts from one:

如果select语句没有列出列名,那么索引号就是表中的列的顺序。列索引编号从1开始。

Integer empId = rs.getInt(1);
String name = rs.getString(2);
String position = rs.getString(3);
Double salary = rs.getDouble(4);

3. Retrieving MetaData from the ResultSet

3.从ResultSet中检索元数据

In this section, we’ll see how to retrieve information about the column properties and types in a ResultSet.

在本节中,我们将看到如何检索ResultSet中的列属性和类型信息。

First, let’s use the getMetaData() method on our ResultSet to obtain the ResultSetMetaData:

首先,让我们在我们的ResultSet上使用getMetaData()方法来获得ResultSetMetaData

ResultSetMetaData metaData = rs.getMetaData();

Next, let’s get the number of columns that are in our ResultSet:

接下来,让我们得到我们的ResultSet中的列的数量。

Integer columnCount = metaData.getColumnCount();

Furthermore, we can use any of the below methods on our metadata object to retrieve properties of each column:

此外,我们可以在我们的元数据对象上使用以下任何方法来检索每一列的属性。

  • getColumnName(int columnNumber)  to get the name of  the column
  • getColumnLabel(int columnNumber)  to access the label of the column, which is specified after AS in the SQL query
  • getTableName(int columnNumber)  to get the table name this column belongs to
  • getColumnClassName(int columnNumber)  to acquire the Java data type of the column
  • getColumnTypeName(int columnNumber)  to get the data type of the column in the database
  • getColumnType(int columnNumber)  to get the SQL data type of the column
  • isAutoIncrement(int columnNumber)  indicates whether the column is auto increment
  • isCaseSensitive(int columnNumber)  specifies whether the column case matters
  • isSearchable(int columnNumber)  suggests if we can use the column in the where clause of the SQL query
  • isCurrency(int columnNumber)   signals if the column contains a cash value
  • isNullable(int columnNumber)  returns zero if the column cannot be null, one if the column can contain a null value, and two if nullability of the column is unknown
  • isSigned(int columnNumber)  returns true if values in the column are signed, otherwise returns false

Let’s iterate through the columns to get their properties:

让我们遍历这些列以获得它们的属性。

for (int columnNumber = 1; columnNumber <= columnCount; columnNumber++) {
    String catalogName = metaData.getCatalogName(columnNumber);
    String className = metaData.getColumnClassName(columnNumber);
    String label = metaData.getColumnLabel(columnNumber);
    String name = metaData.getColumnName(columnNumber);
    String typeName = metaData.getColumnTypeName(columnNumber);
    int type = metaData.getColumnType(columnNumber);
    String tableName = metaData.getTableName(columnNumber);
    String schemaName = metaData.getSchemaName(columnNumber);
    boolean isAutoIncrement = metaData.isAutoIncrement(columnNumber);
    boolean isCaseSensitive = metaData.isCaseSensitive(columnNumber);
    boolean isCurrency = metaData.isCurrency(columnNumber);
    boolean isDefiniteWritable = metaData.isDefinitelyWritable(columnNumber);
    boolean isReadOnly = metaData.isReadOnly(columnNumber);
    boolean isSearchable = metaData.isSearchable(columnNumber);
    boolean isReadable = metaData.isReadOnly(columnNumber);
    boolean isSigned = metaData.isSigned(columnNumber);
    boolean isWritable = metaData.isWritable(columnNumber);
    int nullable = metaData.isNullable(columnNumber);
}

4. Navigating the ResultSet

4.浏览ResultSet

When we obtain a ResultSet, the position of the cursor is before the first row. Moreover, by default, the ResultSet moves only in the forward direction. But, we can use a scrollable ResultSet for other navigation options.

当我们获得一个ResultSet时,光标的位置是在第一行之前。此外,默认情况下,ResultSet只在前进方向上移动。但是,我们可以使用一个可滚动的ResultSet来实现其他的导航选项。

In this section, we’ll discuss the various navigation options.

在本节中,我们将讨论各种导航选项。

4.1. ResultSet Types

4.1.ResultSet类型

ResultSet type indicates how we’ll steer through the dataset:

ResultSet类型表明我们将如何引导数据集的发展。

  • TYPE_FORWARD_ONLY – the default option, in which the cursor moves from start to end
  • TYPE_SCROLL_INSENSITIVE – our cursor can move through the dataset in both forward and backward directions; if there are changes to the underlying data while moving through the dataset, they are ignored; the dataset contains the data from the time the database query returns the result
  • TYPE_SCROLL_SENSITIVE – similar to the scroll insensitive type, however for this type, the dataset immediately reflects any changes to the underlying data

Not all databases support all the ResultSet types. So, let’s check if the type is supported by using the supportsResultSetType on our DatabaseMetaData object:

不是所有的数据库都支持所有的ResultSet类型。因此,让我们通过使用supportsResultSetType在我们的DatabaseMetaData对象上检查该类型是否被支持。

DatabaseMetaData dbmd = dbConnection.getMetaData();
boolean isSupported = dbmd.supportsResultSetType(ResultSet.TYPE_SCROLL_INSENSITIVE);

4.2. Scrollable ResultSet

4.2.可滚动的结果集

To get a scrollable ResultSet, we need to pass some additional parameters while preparing the Statement.

为了得到一个可滚动的ResultSet,我们需要在准备Statement时传递一些附加参数。

For example, we would obtain a scrollable ResultSet by using either TYPE_SCROLL_INSENSITIVE or TYPE_SCROLL_SENSITIVE as a ResultSet type:

例如,我们可以通过使用TYPE_SCROLL_INSENSITIVETYPE_SCROLL_SENSITIVE作为ResultSet类型来获得一个可滚动的ResultSet

PreparedStatement pstmt = dbConnection.prepareStatement(
  "select * from employees",
  ResultSet.TYPE_SCROLL_INSENSITIVE,
  ResultSet.CONCUR_UPDATABLE); 
ResultSet rs = pstmt.executeQuery();

4.3. Navigation Options

4.3.导航选项

We can use any of the below options on a scrollable ResultSet:

我们可以在一个可滚动的ResultSet上使用以下任何选项。

  • next() – proceeds to the next row from the current position
  • previous() – traverses to the previous row
  • first() – navigates to the first row of the ResultSet
  • last() – jumps to the last row
  • beforeFirst() – moves to the start; calling next() on our ResultSet after calling this method returns the first row from our ResultSet
  • afterLast() – leaps to the end; calling previous() on our ResultSet after executing this method returns the last row from our ResultSet
  • relative(int numOfRows) – go forward or backward from the current position by the numOfRows
  • absolute(int rowNumber) – jumps to the rowNumber specified

Let’s see some examples:

让我们看看一些例子。

PreparedStatement pstmt = dbConnection.prepareStatement(
  "select * from employees",
  ResultSet.TYPE_SCROLL_SENSITIVE,
  ResultSet.CONCUR_UPDATABLE);
ResultSet rs = pstmt.executeQuery();

while (rs.next()) {
    // iterate through the results from first to last
}
rs.beforeFirst(); // jumps back to the starting point, before the first row
rs.afterLast(); // jumps to the end of resultset

rs.first(); // navigates to the first row
rs.last(); // goes to the last row

rs.absolute(2); //jumps to 2nd row

rs.relative(-1); // jumps to the previous row
rs.relative(2); // jumps forward two rows

while (rs.previous()) {
    // iterates from current row to the first row in backward direction
}

4.4. ResultSet Row Count

4.4.ResultSet行数

Let’s use getRow() to get the current row number of our ResultSet.

让我们使用getRow()来获取ResultSet的当前行数。

First, we’ll navigate to the last row of the ResultSet and then use getRow() to get the number of records:

首先,我们将导航到ResultSet的最后一行,然后使用getRow()来获得记录的数量。

rs.last();
int rowCount = rs.getRow();

5. Updating Data in a ResultSet

5.更新ResultSet中的数据

By default, the ResultSet is read-only. However, we can use an updatable ResultSet to insert, update, and delete the rows.

默认情况下,ResultSet是只读的。然而,我们可以使用一个可更新的ResultSet来插入、更新和删除行。

5.1. ResultSet Concurrency

5.1.ResultSet并发性

The concurrency mode indicates if our ResultSet can update the data.

并发模式表明我们的ResultSet是否可以更新数据。

The CONCUR_READ_ONLY option is the default and should be used if we don’t need to update the data using our ResultSet.

CONCUR_READ_ONLY选项是默认的,如果我们不需要使用我们的ResultSet更新数据,就应该使用这个选项。

However, if we need to update the data in our ResultSet, then the CONCUR_UPDATABLE option should be used.

然而,如果我们需要更新ResultSet中的数据,那么应该使用CONCUR_UPDATABLE选项。

Not all databases support all the concurrency modes for all ResultSet types. Therefore, we need to check if our desired type and concurrency mode are supported using the supportsResultSetConcurrency() method:

并不是所有的数据库都支持所有ResultSet类型的所有并发模式。因此,我们需要使用supportsResultSetConcurrency()方法检查我们所需的类型和并发模式是否被支持。

DatabaseMetaData dbmd = dbConnection.getMetaData(); 
boolean isSupported = dbmd.supportsResultSetConcurrency(
  ResultSet.TYPE_SCROLL_SENSITIVE, ResultSet.CONCUR_UPDATABLE);

5.2. Obtaining an Updatable ResultSet

5.2.获得一个可更新的ResultSet

To obtain an updatable ResultSet, we need to pass an additional parameter when we prepare the Statement. For that, let’s use CONCUR_UPDATABLE as the third parameter while creating a statement:

为了获得一个可更新的ResultSet,我们需要在准备Statement时传递一个额外的参数。为此,让我们在创建一个语句时使用CONCUR_UPDATABLE作为第三个参数。

PreparedStatement pstmt = dbConnection.prepareStatement(
  "select * from employees",
  ResultSet.TYPE_SCROLL_SENSITIVE,
  ResultSet.CONCUR_UPDATABLE);
ResultSet rs = pstmt.executeQuery();

5.3. Updating a Row

5.3.更新一个行

In this section, we’ll update a row using the updatable ResultSet created in the previous section.

在这一节中,我们将使用上一节中创建的可更新的ResultSet来更新一条记录。

We can update data in a row by calling updateX() methods, passing the column names and values to update. We can use any supported data type in place of X in the updateX() method.

我们可以通过调用updateX()方法,传递要更新的列名和值,来更新行中的数据。我们可以在updateX()方法中使用任何支持的数据类型来代替X

Let’s update the “salary” column, which is of type double:

让我们更新“salary”列,它的类型是double

rs.updateDouble("salary", 1100.0);

Note that this just updates the data in the ResultSet, but the modifications are not yet saved back to the database.

注意,这只是更新了ResultSet中的数据,但修改的内容还没有保存回数据库。

Finally, let’s call updateRow() to save the updates to the database:

最后,让我们调用updateRow()将更新保存到数据库

rs.updateRow();

Instead of the column names, we can pass the column index to the updateX() methods. This is similar to using the column index for getting the values using getX() methods. Passing either the column name or index to the updateX() methods yields the same result:

我们可以向updateX()方法传递列的索引,而不是列名。这与使用getX()方法获取数值时使用列索引类似。将列名或索引传递给updateX()方法会产生相同的结果。

rs.updateDouble(4, 1100.0);
rs.updateRow();

5.4. Inserting a Row

5.4.插入一个行

Now, let’s insert a new row using our updatable ResultSet.

现在,让我们使用我们的可更新的ResultSet插入一个新行。

First, we’ll use moveToInsertRow() to move the cursor to insert a new row:

首先,我们将使用moveToInsertRow()来移动光标以插入一个新行。

rs.moveToInsertRow();

Next, we must call updateX() methods to add the information to the row. We need to provide data to all the columns in the database table. If we don’t provide data to every column, then the default column value is used:

接下来,我们必须调用updateX()方法,将信息添加到该行。我们需要为数据库表中的所有列提供数据。如果我们没有为每一列提供数据,那么就会使用默认的列值。

rs.updateString("name", "Venkat"); 
rs.updateString("position", "DBA"); 
rs.updateDouble("salary", 925.0);

Then, let’s call insertRow() to insert a new row into the database:

然后,让我们调用insertRow()在数据库中插入一个新行。

rs.insertRow();

Finally, let’s use moveToCurrentRow(). This will take the cursor position back to the row we were at before we started inserting a new row using the moveToInsertRow() method:

最后,让我们使用moveToCurrentRow().这将使光标位置回到我们使用moveToInsertRow()方法开始插入新行之前所在的行。

rs.moveToCurrentRow();

5.5. Deleting a Row

5.5.删除一个行

In this section, we’ll delete a row using our updatable ResultSet.

在本节中,我们将使用可更新的ResultSet删除一行。

First, we’ll navigate to the row we want to delete. Then, we’ll call the deleteRow() method to delete the current row:

首先,我们将导航到我们要删除的行。然后,我们将调用deleteRow() 方法来删除当前行。

rs.absolute(2);
rs.deleteRow();

6. Holdability

6.持久性

The holdability determines if our ResultSet will be open or closed at the end of a database transaction.

保持性决定了我们的ResultSet在数据库事务结束时是打开还是关闭。

6.1. Holdability Types

6.1.保持性类型

Use CLOSE_CURSORS_AT_COMMIT if the ResultSet is not required after the transaction is committed.

如果在事务提交后不需要ResultSet,请使用CLOSE_CURSORS_AT_COMMIT

Use HOLD_CURSORS_OVER_COMMIT to create a holdable ResultSet. A holdable ResultSet is not closed even after the database transaction is committed.

使用HOLD_CURSORS_OVER_COMMIT来创建一个可保持的ResultSet。一个可保持的ResultSet即使在数据库事务提交后也不会关闭。

Not all databases support all the holdability types.

不是所有的数据库都支持所有的可持有性类型。

So, let’s check if the holdability type is supported using supportsResultSetHoldability() on our DatabaseMetaData object. Then, we’ll get the default holdability of the database using getResultSetHoldability():

因此,让我们使用supportsResultSetHoldability()在我们的DatabaseMetaData对象上检查是否支持holdability类型。然后,我们将使用getResultSetHoldability()获得数据库的默认保持性。

boolean isCloseCursorSupported
  = dbmd.supportsResultSetHoldability(ResultSet.CLOSE_CURSORS_AT_COMMIT);
boolean isOpenCursorSupported
  = dbmd.supportsResultSetHoldability(ResultSet.HOLD_CURSORS_OVER_COMMIT);
boolean defaultHoldability
  = dbmd.getResultSetHoldability();

6.2. Holdable ResultSet

6.2.可持有的结果集

To create a holdable ResultSet, we need to specify the holdability type as the last parameter while creating a Statement. This parameter is specified after the concurrency mode.

要创建一个可持有的ResultSet,我们需要在创建Statement时指定holdability类型作为最后一个参数。这个参数是在并发模式之后指定的。

Note that if we’re using Microsoft SQL Server (MSSQL), we have to set holdability on the database connection, rather than on the ResultSet:

请注意,如果我们使用的是Microsoft SQL Server(MSSQL),我们必须在数据库连接上设置保持性,而不是在ResultSet上。

dbConnection.setHoldability(ResultSet.HOLD_CURSORS_OVER_COMMIT);

Let’s see this in action. First, let’s create a Statement, setting the holdability to HOLD_CURSORS_OVER_COMMIT:

让我们看看这个动作。首先,让我们创建一个Statement,将holdability设置为HOLD_CURSORS_OVER_COMMIT

Statement pstmt = dbConnection.createStatement(
  ResultSet.TYPE_SCROLL_SENSITIVE, 
  ResultSet.CONCUR_UPDATABLE, 
  ResultSet.HOLD_CURSORS_OVER_COMMIT)

Now, let’s update a row while retrieving the data. This is similar to the update example we discussed earlier, except that we’ll continue to iterate through the ResultSet after committing the update transaction to the database. This works fine on both MySQL and MSSQL databases:

现在,让我们在检索数据的同时更新一条记录。这与我们之前讨论的更新例子类似,只是在向数据库提交更新事务后,我们将继续在ResultSet中进行迭代。这在MySQL和MSSQL数据库上都能正常工作。

dbConnection.setAutoCommit(false);
ResultSet rs = pstmt.executeQuery("select * from employees");
while (rs.next()) {
    if(rs.getString("name").equalsIgnoreCase("john")) {
        rs.updateString("name", "John Doe");
        rs.updateRow();
        dbConnection.commit();
    }                
}
rs.last();

It’s worth noting that MySQL supports only HOLD_CURSORS_OVER_COMMIT. So, even if we use CLOSE_CURSORS_AT_COMMIT, it will be ignored.

值得注意的是,MySQL只支持HOLD_CURSORS_OVER_COMMIT。因此,即使我们使用CLOSE_CURSORS_AT_COMMIT,它也会被忽略。

The MSSQL database supports CLOSE_CURSORS_AT_COMMIT. This means that the ResultSet will be closed when we commit the transaction. As a result, an attempt to access the ResultSet after committing the transaction results in a ‘Cursor is not open error’. Therefore, we can’t retrieve further records from the ResultSet.

MSSQL数据库支持CLOSE_CURSORS_AT_COMMIT。这意味着,当我们提交事务时,ResultSet将被关闭。因此,在提交事务后,试图访问ResultSet的结果是 “游标未打开错误”。因此,我们无法从ResultSet中获取更多的记录。

7. Fetch Size

7.获取大小

Typically, when loading data into a ResultSet, the database drivers decide on the number of rows to fetch from the database. On a MySQL database, for example, the ResultSet normally loads all the records into memory at once.

通常,在向ResultSet加载数据时,数据库驱动程序会决定从数据库中获取的行数。例如,在MySQL数据库中,ResultSet通常会一次性将所有记录加载到内存中。

Sometimes, however, we may need to deal with a large number of records that won’t fit into our JVM memory. In this case, we can use the fetch size property either on our Statement or ResultSet objects to limit the number of records initially returned.

然而,有时我们可能需要处理大量的记录,而这些记录又无法装入我们的JVM内存。在这种情况下,我们可以在StatementResultSet对象上使用fetch size属性来限制最初返回的记录数量。

Whenever additional results are required, ResultSet fetches another batch of records from the database. Using the fetch size property, we can provide a suggestion to the database driver on the number of rows to fetch per database trip. The fetch size we specify will be applied to the subsequent database trips.

每当需要额外的结果时,ResultSet会从数据库中获取另一批记录。使用fetch size属性,我们可以向数据库驱动程序提供一个建议,即每次数据库旅行要获取的行数。我们指定的获取大小将被应用于后续的数据库之旅。

If we don’t specify the fetch size for our ResultSet, then the fetch size of the Statement is used. If we don’t specify fetch size for either the Statement or the ResultSet, then the database default is used.

如果我们没有为我们的ResultSet指定fetch size,那么将使用Statement的fetch size。如果我们没有为StatementResultSet指定获取大小,那么将使用数据库默认值。

7.1. Using Fetch Size on Statement

7.1.在Statement上使用Fetch Size

Now, let’s see the fetch size on Statement in action. We’ll set the fetch size of the Statement to 10 records. If our query returns 100 records, then there will be 10 database round trips, loading 10 records each time:

现在,让我们看看Statement上的fetch size的操作。我们将设置Statement的获取大小为10条记录。如果我们的查询返回100条记录,那么就会有10次数据库往返,每次加载10条记录。

PreparedStatement pstmt = dbConnection.prepareStatement(
  "select * from employees", 
  ResultSet.TYPE_SCROLL_SENSITIVE, 
  ResultSet.CONCUR_READ_ONLY);
pstmt.setFetchSize(10);

ResultSet rs = pstmt.executeQuery();

while (rs.next()) {
    // iterate through the resultset
}

7.2. Using Fetch Size on ResultSet

7.2.在ResultSet上使用Fetch Size

Now, let’s change the fetch size in our previous example using the ResultSet.

现在,让我们使用ResultSet改变我们之前的例子中的取值大小。

First, we’ll use the fetch size on our Statement. This allows our ResultSet to initially load 10 records after executing the query.

首先,我们将在我们的Statement上使用fetch size。这允许我们的ResultSet在执行查询后最初加载10条记录。

Then, we’ll modify the fetch size on the ResultSet. This will override the fetch size we earlier specified on our Statement. So, all the subsequent trips will load 20 records until all the records are loaded.

然后,我们将修改ResultSet上的fetch size。这将覆盖我们先前在Statement上指定的获取大小。因此,所有后续的行程将加载20条记录,直到所有的记录被加载。

As a result, there will be only 6 database trips to load all the records:

因此,将只有6次数据库旅行来加载所有记录。

PreparedStatement pstmt = dbConnection.prepareStatement(
  "select * from employees", 
  ResultSet.TYPE_SCROLL_SENSITIVE, 
  ResultSet.CONCUR_READ_ONLY);
pstmt.setFetchSize(10);

ResultSet rs = pstmt.executeQuery();
 
rs.setFetchSize(20); 

while (rs.next()) { 
    // iterate through the resultset 
}

Finally, we’ll see how to modify the fetch size of the ResultSet while iterating the results.

最后,我们将看到如何在迭代结果时修改ResultSet的取值大小。

Similar to the previous example, we’ll first set the fetch size to 10 on our Statement. So, our first 3 database trips will load 10 records per each trip.

与之前的例子类似,我们首先在Statement上设置获取大小为10。因此,我们的前3次数据库旅行将在每次旅行中加载10条记录。

And then, we’ll modify the fetch size on our ResultSet to 20 while reading the 30th record. So, the next 4 trips will load 20 records per each trip.

然后,在读取第30条记录时,我们将修改ResultSet的获取大小为20。所以,接下来的4次旅行,每次都会加载20条记录。

Therefore, we’ll need 7 database trips to load all 100 records:

因此,我们将需要7次数据库旅行来加载所有的100条记录。

PreparedStatement pstmt = dbConnection.prepareStatement(
  "select * from employees", 
  ResultSet.TYPE_SCROLL_SENSITIVE, 
  ResultSet.CONCUR_READ_ONLY);
pstmt.setFetchSize(10);

ResultSet rs = pstmt.executeQuery();

int rowCount = 0;

while (rs.next()) { 
    // iterate through the resultset 
    if (rowCount == 30) {
        rs.setFetchSize(20); 
    }
    rowCount++;
}

8. Conclusion

8.结语

In this article, we saw how to use the ResultSet API for retrieving and updating data from a database. Several of the advanced features we discussed are dependent on the database we’re using. Thus, we need to check the support for those features before using them.

在这篇文章中,我们看到了如何使用ResultSet API来检索和更新数据库中的数据。我们讨论的几个高级功能取决于我们所使用的数据库。因此,在使用这些功能之前,我们需要检查对这些功能的支持情况。

As always, the code is available over on GitHub.

像往常一样,代码可在GitHub上获得