阅读开源框架,遍览Java嵌套类的用法

Java的类对外而言只有一种面貌,但封装在类内部的形态却可以丰富多彩。嵌套类在这其中,扮演了极为重要的角色。它既丰富了类的层次,又可以灵活控制内部结构的访问限制与粒度,使得我们在开放性与封闭性之间、公开接口与内部实现之间取得适度的平衡。

嵌套类之所以能扮演这样的设计平衡角色,是因为嵌套类与其主类的关系不同,主类的所有成员对嵌套类而言都是完全开放的,主类的私有成员可以被嵌套类访问,而嵌套类则可以被看做是类边界中自成一体的高内聚类而被主类调用。因此,嵌套类的定义其实就是对类内部成员的进一步封装。虽然在一个类中定义嵌套类并不能减小类定义的规模,但由于嵌套类体现了不同层次的封装,使得一个相对较大的主类可以显得更有层次感,不至于因为成员过多而显得过于混乱。

当一个类的业务逻辑非常复杂,且它承担的职责却又不足以单独分离为另外的类型时,内部嵌套类尤其是静态嵌套类就会变得非常有用。它是帮助我们组织类内部代码的利器。通过阅读顶级的Java开源项目,我们发现内部嵌套类通常用于几种情况。

封装Builder

Builder模式常常用于组装一个类,它通过更加流畅的接口形式简化构建组成元素的逻辑。在Java中,除非必要,我们一般会将一个类的Builder定义为内部嵌套类。这几乎已经成为一种惯有模式了。

例如框架airlift定义了Request类,它是客户端请求对象的一个封装。组成一个Request对象需要诸如Uri、header、http verb、body等元素,且这些元素的组成会因为客户请求的不同而调用不同的组装方法。这是典型的Builder模式应用场景。让我们看看在Presto框架下,它如何调用airlift提供的Request及对应的Builder

@ThreadSafe
public class StatementClient implements Closeable {
private Request buildQueryRequest(ClientSession session, String query) {
Request.Builder builder = prepareRequest(preparePost(), uriBuilderFrom(session.getServer()).replacePath("/v1/statement").build())
.setBodyGenerator(createStaticBodyGenerator(query, UTF_8));

if (session.getSource() != null) {
builder.setHeader(PrestoHeaders.PRESTO_SOURCE, session.getSource());
}
if (session.getClientInfo() != null) {
builder.setHeader(PrestoHeaders.PRESTO_CLIENT_INFO, session.getClientInfo());
}
if (session.getCatalog() != null) {
builder.setHeader(PrestoHeaders.PRESTO_CATALOG, session.getCatalog());
}
if (session.getSchema() != null) {
builder.setHeader(PrestoHeaders.PRESTO_SCHEMA, session.getSchema());
}
builder.setHeader(PrestoHeaders.PRESTO_TIME_ZONE, session.getTimeZone().getId());
if (session.getLocale() != null) {
builder.setHeader(PrestoHeaders.PRESTO_LANGUAGE, session.getLocale().toLanguageTag());
}

Map<String, String> property = session.getProperties();
for (Entry<String, String> entry : property.entrySet()) {
builder.addHeader(PrestoHeaders.PRESTO_SESSION, entry.getKey() + "=" + entry.getValue());
}

Map<String, String> statements = session.getPreparedStatements();
for (Entry<String, String> entry : statements.entrySet()) {
builder.addHeader(PrestoHeaders.PRESTO_PREPARED_STATEMENT, urlEncode(entry.getKey()) + "=" + urlEncode(entry.getValue()));
}

builder.setHeader(PrestoHeaders.PRESTO_TRANSACTION_ID, session.getTransactionId() == null ? "NONE" : session.getTransactionId());

return builder.build();
}

private Request.Builder prepareRequest(Request.Builder builder, URI nextUri) {
builder.setHeader(PrestoHeaders.PRESTO_USER, user);
builder.setHeader(USER_AGENT, USER_AGENT_VALUE)
.setUri(nextUri);

return builder;
}
}

显然,StatementClient会根据ClientSession对象的不同情况,构造不同的Request。由于引入了Builder模式,则这种构造Request对象的职责到Request.Builder对象。观察该对象的使用:

Request.Builder builder = prepareRequest(...);

Builder类被定义为Request的静态嵌套类,以下为airlift框架的源代码:

public class Request {
public static class Builder {
private URI uri;
private String method;
private final ListMultimap<String, String> headers = ArrayListMultimap.create();
private BodyGenerator bodyGenerator;

public Builder() {
}

public static Request.Builder prepareHead() {
return (new Request.Builder()).setMethod("HEAD");
}

public static Request.Builder prepareGet() {
return (new Request.Builder()).setMethod("GET");
}

public static Request.Builder preparePost() {
return (new Request.Builder()).setMethod("POST");
}

public static Request.Builder preparePut() {
return (new Request.Builder()).setMethod("PUT");
}

public static Request.Builder prepareDelete() {
return (new Request.Builder()).setMethod("DELETE");
}

public static Request.Builder fromRequest(Request request) {
Request.Builder requestBuilder = new Request.Builder();
requestBuilder.setMethod(request.getMethod());
requestBuilder.setBodyGenerator(request.getBodyGenerator());
requestBuilder.setUri(request.getUri());
Iterator var2 = request.getHeaders().entries().iterator();

while (var2.hasNext()) {
Entry entry = (Entry)var2.next();
requestBuilder.addHeader((String)entry.getKey(), (String)entry.getValue());
}

return requestBuilder;
}

public Request.Builder setUri(URI uri) {
this.uri = Request.validateUri(uri);
return this;
}

public Request.Builder setMethod(String method) {
this.method = method;
return this;
}

public Request.Builder setHeader(String name, String value) {
this.headers.removeAll(name);
this.headers.put(name, value);
return this;
}

public Request.Builder addHeader(String name, String value) {
this.headers.put(name, value);
return this;
}

public Request.Builder setBodyGenerator(BodyGenerator bodyGenerator) {
this.bodyGenerator = bodyGenerator;
return this;
}

public Request build() {
return new Request(this.uri, this.method, this.headers, this.bodyGenerator);
}
}
}

当一个类的构建逻辑非常复杂,并有多种不同的组合可能性时,我们会倾向于使用Builder模式来封装这种变化的组装逻辑。惯用的模式是将Builder类定义为公有静态嵌套类,很多Java实践证实了这一做法。

封装Iterator

当我们需要在类中提供自定义的迭代器,且又不需要将该迭代器的实现对外公开时,都可以通过嵌套类实现一个内部迭代器。这是迭代器模式的一种惯用法。例如在Presto框架中,定义了属于自己的PrestoResultSet类,继承自JDBC的ResultSet,并重写了父类的迭代方法next()next方法的迭代职责又委派给了一个内部迭代器results

public class PrestoResultSet implements ResultSet {
private final Iterator<List<Object>> results;

@Override
public boolean next() throws SQLException {
checkOpen();
try {
if (!results.hasNext()) {
row.set(null);
return false;
}
row.set(results.next());
return true;
}
catch (RuntimeException e) {
if (e.getCause() instanceof SQLException) {
throw (SQLException) e.getCause();
}
throw new SQLException("Error fetching results", e);
}
}
}

而这个results实则是一个内部迭代器:

public class PrestoResultSet implements ResultSet {
private static class ResultsPageIterator
extends AbstractIterator<Iterable<List<Object>>> {
//......
}
}

ResultsPageIterator迭代器继承自Guava框架定义的AbstractIterator<T>,间接实现了接口Iterator<T>

封装内部概念

编写整洁代码的实践告诉我们:类的定义要满足“单一职责原则”,这样的类才是专注的。编写可扩展代码的实践也告诉我们:类的定义要满足“单一职责原则”,如此才能保证只有一个引起变化的原因。然而,即使类只履行一个专注的职责,也可能因为过于复杂的业务逻辑而导致设计出相对比较庞大的类(真实项目总是要比玩具项目复杂100倍)。这时,我们会面临左右为难的选择。倘若不将职责分离到另外的单独类中,则该类就过于庞大,导致内部逻辑过于复杂;倘若将部分逻辑分离出去,又会导致类的数量过多,导致系统复杂度增加。这时,我们可以用嵌套类来封装内部概念,定义属于自己的数据与行为,在主类内部形成更加清晰的职责边界,却没有引起系统类数量的增加,算是一种折中的设计手法。

当然,我们必须谨记的一点是:究竟职责应该定义在独立类中,还是定义在附属的嵌套类,判断标准不是看代码量以及类的规模,而是看这些需要封装的逻辑究竟属于内部概念,还是外部概念。

例如Presto框架HiveWriterFactory类。它是一个工厂类,创建的产品为HiveWriter。在创建过程中,需要根据列名、列类型以及列的Hive类型进行组合,并将组合后的结果赋值给Properties类型的schema

这些值并不需要公开,如果不对其进行封装,则存在问题:

  • 没有体现领域概念,只有散乱的三种类型的变量
  • 无法将其作为整体放入到集合中,因而也无法调用集合的API对其进行转换

针对Hive所要操作的数据,列名、列类型以及列的Hive类型这三个散乱概念实际上表达的是数据列的概念,于是就可以在这个工厂类中定义嵌套类来封装这些概念与逻辑:

public class HiveWriterFactory {
private static class DataColumn {
private final String name;
private final Type type;
private final HiveType hiveType;

public DataColumn(String name, Type type, HiveType hiveType) {
this.name = requireNonNull(name, "name is null");
this.type = requireNonNull(type, "type is null");
this.hiveType = requireNonNull(hiveType, "hiveType is null");
}

public String getName() {
return name;
}

public Type getType() {
return type;
}

public HiveType getHiveType() {
return hiveType;
}
}
}

在封装为DataColumn嵌套类后,就可以将其放入到List<DataColumn>集合中,然后像如下代码那样对其进行操作:

schema.setProperty(META_TABLE_COLUMNS, dataColumns.stream()
.map(DataColumn::getName)
.collect(joining(",")));
schema.setProperty(META_TABLE_COLUMN_TYPES, dataColumns.stream()
.map(DataColumn::getHiveType)
.map(HiveType::getHiveTypeName)
.collect(joining(":")));

在开发过程中,我们还会经常遇见一种典型的数据结构,它需要将两个值对应起来形成一种映射。这种数据结构通常称之为tuple。虽然在Java 7已经提供了这种简便的结构(Scala在一开始就提供了tuple结构,而且还提供了._1._2的快捷访问方式),但这种结构是通用的,不利于体现出业务概念。当这种结构仅仅在一个类的内部使用时,就是嵌套类登上舞台的时候了。

例如我们要建立TypeObject之间的映射关系,并被用在一个SQL语句的解析器中充当解析过程中的元数据,就可以定义为类TypeAndValue,命名直白,清晰可见:

public class SQLStatementParser {
private List<TypeAndValue> accumulator = new ArrayList<>();

public PreparedStatement buildSql(JdbcClient client, Connection connection, String catalog, String schema, String table, List<JdbcColumnHandle> columns) {
StringBuilder sql = new StringBuilder();

String columnNames = columns.stream()
.map(JdbcColumnHandle::getColumnName)
.map(this::quote)
.collect(joining(", "));

sql.append("SELECT ");
sql.append(columnNames);
if (columns.isEmpty()) {
sql.append("null");
}

sql.append(" FROM ");
if (!isNullOrEmpty(catalog)) {
sql.append(quote(catalog)).append('.');
}
if (!isNullOrEmpty(schema)) {
sql.append(quote(schema)).append('.');
}
sql.append(quote(table));

List<String> clauses = toConjuncts(columns, accumulator);
if (!clauses.isEmpty()) {
sql.append(" WHERE ")
.append(Joiner.on(" AND ").join(clauses));
}

PreparedStatement statement = client.getPreparedStatement(connection, sql.toString());

for (int i = 0; i < accumulator.size(); i++) {
TypeAndValue typeAndValue = accumulator.get(i);
if (typeAndValue.getType().equals(BigintType.BIGINT)) {
statement.setLong(i + 1, (long) typeAndValue.getValue());
}
else if (typeAndValue.getType().equals(IntegerType.INTEGER)) {
statement.setInt(i + 1, ((Number) typeAndValue.getValue()).intValue());
}
else if (typeAndValue.getType().equals(SmallintType.SMALLINT)) {
statement.setShort(i + 1, ((Number) typeAndValue.getValue()).shortValue());
}
else if (typeAndValue.getType().equals(TinyintType.TINYINT)) {
statement.setByte(i + 1, ((Number) typeAndValue.getValue()).byteValue());
}
else if (typeAndValue.getType().equals(DoubleType.DOUBLE)) {
statement.setDouble(i + 1, (double) typeAndValue.getValue());
}
else if (typeAndValue.getType().equals(RealType.REAL)) {
statement.setFloat(i + 1, intBitsToFloat(((Number) typeAndValue.getValue()).intValue()));
}
else if (typeAndValue.getType().equals(BooleanType.BOOLEAN)) {
statement.setBoolean(i + 1, (boolean) typeAndValue.getValue());
}
else if (typeAndValue.getType().equals(DateType.DATE)) {
long millis = DAYS.toMillis((long) typeAndValue.getValue());
statement.setDate(i + 1, new Date(UTC.getMillisKeepLocal(DateTimeZone.getDefault(), millis)));
}
else if (typeAndValue.getType().equals(TimeType.TIME)) {
statement.setTime(i + 1, new Time((long) typeAndValue.getValue()));
}
else if (typeAndValue.getType().equals(TimeWithTimeZoneType.TIME_WITH_TIME_ZONE)) {
statement.setTime(i + 1, new Time(unpackMillisUtc((long) typeAndValue.getValue())));
}
else if (typeAndValue.getType().equals(TimestampType.TIMESTAMP)) {
statement.setTimestamp(i + 1, new Timestamp((long) typeAndValue.getValue()));
}
else if (typeAndValue.getType().equals(TimestampWithTimeZoneType.TIMESTAMP_WITH_TIME_ZONE)) {
statement.setTimestamp(i + 1, new Timestamp(unpackMillisUtc((long) typeAndValue.getValue())));
}
else if (typeAndValue.getType() instanceof VarcharType) {
statement.setString(i + 1, ((Slice) typeAndValue.getValue()).toStringUtf8());
}
else {
throw new UnsupportedOperationException("Can't handle type: " + typeAndValue.getType());
}
}

return statement;
}

private static class TypeAndValue {
private final Type type;
private final Object value;

public TypeAndValue(Type type, Object value) {
this.type = requireNonNull(type, "type is null");
this.value = requireNonNull(value, "value is null");
}

public Type getType() {
return type;
}

public Object getValue() {
return value;
}
}
}

TypeAndValue内部类的封装,有效地体现了类型与值之间的映射关系,改进了代码的可读性。事实上,以上代码还有可堪改进的空间,例如我们可以利用类的封装性,将类型判断的语句封装为方法,例如isBigInt()isInteger()isDouble()isTimeWithTimeZone()isVarcharType()等方法,则前面的一系列分支语句会变得更流畅一些:

for (int i = 0; i < accumulator.size(); i++) {
TypeAndValue typeAndValue = accumulator.get(i);
if (typeAndValue.isBigInt())) {
statement.setLong(i + 1, (long) typeAndValue.getValue());
}
else if (typeAndValue.isInteger())) {
statement.setInt(i + 1, ((Number) typeAndValue.getValue()).intValue());
}
else if (typeAndValue.isSmallInt())) {
statement.setShort(i + 1, ((Number) typeAndValue.getValue()).shortValue());
}
//……
else {
throw new UnsupportedOperationException("Can't handle type: " + typeAndValue.getType());
}
}

作为内部Map的Key

假定一个类的内部需要用到Map集合,而该集合对象又仅仅在内部使用,并不会公开给外部调用者。对于这样的Map集合,倘若Java基本类型或者字符串无法承担key的作用,就可以定义一个内部静态嵌套类作为该Map的key。注意,由于嵌套类要作为唯一不重复的key,且该类型为引用类型,因而需要重写equals()方法与hashCode()方法,视情况还应该重写toString()方法。

例如在一个类中需要创建一个Map用以存储多个指标对象,但该指标的key由两个ByteBuffer对象联合组成,就可以封装为一个内部嵌套类:

private final Map<MetricsKey, Metric> metrics = new HashMap<>();

private static class MetricsKey {
public final ByteBuffer row;
public final ByteBuffer family;

public MetricsKey(ByteBuffer row, ByteBuffer family) {
requireNonNull(row, "row is null");
requireNonNull(family, "family is null");
this.row = row;
this.family = family;
}

@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if ((obj == null) || (getClass() != obj.getClass())) {
return false;
}

MetricsKey other = (MetricsKey) obj;
return Objects.equals(this.row, other.row)
&& Objects.equals(this.family, other.family);
}

@Override
public int hashCode() {
return Objects.hash(row, family);
}

@Override
public String toString() {
return toStringHelper(this)
.add("row", new String(row.array(), UTF_8))
.add("family", new String(row.array(), UTF_8))
.toString();
}
}

实现外部接口

当我们在一个类的内部需要使用一个外部接口,且该接口的实现逻辑又比较复杂,而在类的外部又不存在重用的可能,此时就可以定义一个私有的内部嵌套类去实现该接口。

Presto框架BenchmarkSuite类内部,调用了AbstractBenchmark类的runBenchmark()方法,该方法需要传入BenchmarkResultHook接口类型的对象:

public void runBenchmark(@Nullable BenchmarkResultHook benchmarkResultHook) {}

BenchmarkResultHook是一个独立定义的接口:

public interface BenchmarkResultHook {
BenchmarkResultHook addResults(Map<String, Long> results);
void finished();
}

在类的内部,定义了实现BenchmarkResultHook接口的内部嵌套类:

private static class ForwardingBenchmarkResultWriter implements BenchmarkResultHook {}

然后在BenchmarkSuite中就可以使用它:

benchmark.runBenchmark(
new ForwardingBenchmarkResultWriter(
ImmutableList.of(
new JsonBenchmarkResultWriter(jsonOut),
new JsonAvgBenchmarkResultWriter(jsonAvgOut),
new SimpleLineBenchmarkResultWriter(csvOut),
new OdsBenchmarkResultWriter("presto.benchmark." + benchmark.getBenchmarkName(), odsOut)
)
)
);

实现外部接口还有一种特殊情况是利用嵌套类实现一个函数接口。虽然在多数情况下,当我们在使用函数接口时,会使用Lambda表达式来实现该接口,其本质其实是一个函数。然而,当一个函数的实现相对比较复杂,且有可能被类的内部多处重用时,就不能使用Lambda表达式了。可是针对这种情形,确乎又没有必要定义单独的类去实现该函数接口,这时就是嵌套类的用武之地了。

例如在Presto框架HivePageSource类中有一个方法createCoercer(),它返回的类型是一个函数接口类型Function<Block, Block>。方法的实现会根据hive的类型决定返回的函数究竟是什么。目前支持以下四种情形:

  • Integer数字转换为Varchar
  • Varchar转换为Integer数字
  • Integer数字的提升
  • Float转换为Double

这四种情形对应的Coercer其实都是一个函数Block -> Block,但这个转换的逻辑比较复杂,尤其针对“Integer数字的提升”情形,还存在多个条件分支的判断。于是,Presto就在HivePageSource定义了如下四个嵌套类,并且都实现了函数接口Function<Block, Block>

public class HivePageSource implements ConnectorPageSource {
private static class IntegerNumberUpscaleCoercer implements Function<Block, Block> {
private final Type fromType;
private final Type toType;

public IntegerNumberUpscaleCoercer(Type fromType, Type toType)
{
this.fromType = requireNonNull(fromType, "fromType is null");
this.toType = requireNonNull(toType, "toType is null");
}

@Override
public Block apply(Block block)
{
//...
}
}

private static class IntegerNumberToVarcharCoercer implements Function<Block, Block> {
private final Type fromType;
private final Type toType;

public IntegerNumberToVarcharCoercer(Type fromType, Type toType)
{
this.fromType = requireNonNull(fromType, "fromType is null");
this.toType = requireNonNull(toType, "toType is null");
}

@Override
public Block apply(Block block)
{
//...
}
}

private static class VarcharToIntegerNumberCoercer implements Function<Block, Block> {
private final Type fromType;
private final Type toType;

private final long minValue;
private final long maxValue;

public VarcharToIntegerNumberCoercer(Type fromType, Type toType) {
//...
}

@Override
public Block apply(Block block) {
//...
}
}

private static class FloatToDoubleCoercer implements Function<Block, Block> {
@Override
public Block apply(Block block) {
//...
}
}
}

封装内部常量

如果一个类的内部需要用到大量常量,而这些常量却没有重用的可能,换言之,这些常量无需公开,仅作为内部使用。这时,我们可以通过嵌套类对这些常量进行归类,既便于调用,又提高了可读性,形成一种组织良好的代码结构。

public class MetadataLoader {
private static class IpAddressConstants {
// IPv4: 255.255.255.255 - 15 characters
// IPv6: FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF - 39 characters
// IPv4 embedded into IPv6: FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:255.255.255.255 - 45 characters
private static final int IPV4_STRING_MAX_LENGTH = 15;
private static final int IPV6_STRING_MAX_LENGTH = 39;
private static final int EMBEDDED_IP_ADDRESS_STRING_MAX_LENGTH = 45;
}

private static class TypeConstants {
private static final BigintType BIGINT = new BigintType();
private static final BooleanType BOOLEAN = new BooleanType();
private static final DoubleType DOUBLE = new DoubleType();
private static final TimestampType TIMESTAMP = new TimestampType();
}

//……
}

内部重用

有些重用代码块,它的重用单元比方法要多一些,却又不成其为单独的类,因为它只在类的内部被重用,这时就可以将其定义为嵌套类,以利于内部重用。

例如,在一个并发处理程序的Query类中,我们需要根据一个布尔标志来判断当前线程是否需要中断。中断线程的逻辑无法封装在一个私有方法中。如果将这个逻辑直接写到调用方法中,有可能干扰到主要逻辑的阅读,为了提供代码的可读性,Query类定义了一个内部嵌套类ThreadInterruptor,用以封装中断当前线程的逻辑:

public class Query implements Closeable {
private final AtomicBoolean ignoreUserInterrupt = new AtomicBoolean();
private final AtomicBoolean userAbortedQuery = new AtomicBoolean();

public void pageOutput(OutputFormat format, List<String> fieldNames) {
try (Pager pager = Pager.create();
ThreadInterruptor clientThread = new ThreadInterruptor();
Writer writer = createWriter(pager);
OutputHandler handler = createOutputHandler(format, writer, filedNames)) {
if (!pager.isNullPager()) {
ignoreUserInterrupt.set(true);
pager.getFinishFuture().thenRun(() -> {
userAbortedQuery.set(true);
ignoreUserInterrupt.set(false);
clientThread.interrupt();
});
}
handler.processRow(client);
} catch (RuntimeException | IOException e) {
if (userAbortedQuery.get() && !(e instanceOf QueryAbortedException)) {
throw new QueryAbortedException(e);
}
throw e;
}
}

private static class ThreadInterruptor implements Closeable {
private final Thread thread = Thread.currentThread();
private final AtomicBoolean processing = new AtomicBoolean(true);

public synchronized void interrupt() {
if (processing.get()) {
thread.interrupt();
}
}

@Override
public synchronized void close() {
processing.set(false);
}
}
}

注意,由于ThreadInterruptor对象要放到try语句块中,因而需要实现Closeable接口,且需要通过try调用close()方法时,将processing标志变量置为false

内部异常

这算是内部重用的一种特殊情形,即对异常的内部重用。当我们需要抛出一个异常,且希望抛出的消息能够体现该异常场景,又或者该异常需要携带的内容不仅仅包括消息或错误原因时,都需要我们自定义异常。倘若该自定义异常没有外部公开的必要,就可以通过嵌套类定义一个内部异常。

例如我们希望一个异常在抛出时能够携带类型信息,且该异常仅为内部使用,就可以这样来定义:

public class FailureInfo {
private static class FailureException
extends RuntimeException {
private final String type;

FailureException(String type, String message, FailureException cause) {
super(message, cause, true, true);
this.type = requireNonNull(type, "type is null");
}

public String getType() {
return type;
}

@Override
public String toString() {
String message = getMessage();
if (message != null) {
return type + ": " + message;
}
return type;
}
}
}

无论嵌套类的使用形式如何多样,体现的价值又如何地梅兰秋菊各擅胜场,根本的原理还是逃不开面向对象设计的基本思想,即通过封装体现内聚的概念,从而利于重用,应对变化,或者就是单纯地组织代码,让代码的结构变得更加清晰。尤其是在相对复杂的真实项目中,如何控制对象类型的数量,又不至于违背“单一职责原则”,使用嵌套类是一条不错的中间路线选择。

您的赞赏是我创作的动力!