[译] OpenGIS 开发教程

1 基本概念

OpenGIS 是一个开源的 GIS 实现,它定义了一组基于 Feature 的服务。Feature 简单来说就是一个独立的对象,在地图中可能呈现为一个多边形建筑物,而在数据库中则对应一个独立的条目。Feature 具有 几何信息属性信息这两个必要的组成部分:

  • OpenGIS 将几何信息分为点、边缘、面和几何集合四种:其中我们熟悉的线 (Linestring) 属于边缘的一个子类,而多边形 (Polygon) 是面的一个子类。也就是说 OpenGIS 定义的几何类型并不仅仅是我们常见的点、线、多边形三种,它提供了更复杂更详细的定义,增强了未来的可扩展性。另外,OpenGIS 在几何类型的设计中采用了组合模式 (Composite),将几何集合 (GeometryCollection) 也定义为一种几何类型(类似地,要素集合 FeatureCollection 也是一种要素)。
  • 属性信息没有做太大的限制,可以在实际应用中结合具体的实现进行设置。

本文译自 GeoTools 官方文档,并在原文的基础上补充了一些作者自己的理解。如有疏漏,请不吝赐教,让我们共同进步!

2 Feature 要素

2.1 Feature 数据结构

OpenGIS Feature 将信息存储在由 Feature 要素Property Attributes 属性Associations 关联组成的数据结构中:

OpenGIS Feature 数据结构

要创建 Feature,可以使用 OpenGIS 提供的 FeatureFactory 工厂类,或者使用 GeoTools 基于 FeatureFactory 封装的 SimpleFeatureBuilder 构建器,该构建器填充了默认值,使用起来更加方便:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
//create the builder
SimpleFeatureBuilder builder = new SimpleFeatureBuilder(featureType);

//add the values
builder.add( "Canada" );
builder.add( 1 );
builder.add( 20.5 );
builder.add( new Point( -124, 52 ) );

//build the feature with provided ID
SimpleFeature feature = builder.buildFeature( "fid.1" );

通过以上方法创建的封装好的 SimpleFeature 访问属性会更加便捷:

1
2
feature.setAttribute( "name", "test" );
Object value = feature.getAttribute( "name" );

2.2 SimpleFeature

大多数 GIS 数据不需要具备完整的动态类型系统,因此 GeoTools 设置了一个 FeatureFeatureType 的 “Simple” 扩展来简化 Feature 的构建。

SimpleFeature 可以表示不包含任何复杂内部结构的要素,它仅仅是 Key-Value 形式的“平面”记录,其中 Geometry 是必须包含的,并且 Key 列表是提前知道的。

SimpleFeature

2.3 SimpleFeatureType

SimpleFeature 对应的是 SimpleFeatureType,构造 SimpleFeatureType 的方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
SimpleFeatureTypeBuilder b = new SimpleFeatureTypeBuilder();
b.setName( "Flag" );
// 添加 SimpleFeature 中的 Attribute 列表
b.add( "name", String.class );
b.add( "classification", Integer.class );
b.add( "height", Double.class );
b.setCRS( DefaultGeographicCRS.WGS84 );
b.add( "location", Point.class );
// 构建
SimpleFeatureType type = b.buildFeatureType();

3 FeatureType

几何类型和属性类型的组合被称为 FeatureType(要素类型),要素类型可以用来描述一组具有相似属性的要素。在面向对象的模型中,可以将要素类型视为 Class,而要素则是该类的 Object 实例。通过 GIS 中间件可以从数据源中取出数据,供 WMS1 服务器和 WFS2 服务器使用。

3.1 PropertyType

PropertyType 架构包含 PropertyType, AttributeType, GeometryType, ComplexType, FeatureType 这些类,其关系如下:

PropertyType

以上架构形成了一个“动态类型系统”,可以在运行时描述新类型的信息。

3.2 PropertyDescriptor

如上一小节所示,ComplexType 包含一个属性列表,每个属性都表示为具有不同名称和属性类型的 PropertyDescriptor

PropertyDescriptor

4 Filter

Filter API 用于查询数据前的过滤操作,类似于 SQL WHERE 子句,其规范本身由 OGC 维护。Filter 规范定义了用于执行选择的过滤器数据结构,此数据结构的子集用于定义表达式以计算、定义或提取信息。以下是使用 filter 和表达式的示例:

  1. 通过 API 设定 filter

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    final FilterFactory ff = CommonFactoryFinder.getFilterFactory();
    Filter filter = ff.propertyLessThan( ff.property( "AGE"), ff.literal( 12 ) );
    //获取 future 时,引入我们定义好的 filter
    SimpleFeatureCollection features = featureSource.getFeatures( filter );
    features.accepts( new FeatureVisitor<SimpleFeature>() ){
       public void visit( SimpleFeature feature ){
           Expression expression = ff.property( "NAME" );
           String name = expression.evaulate( feature, String.class );
           System.out.println( feature.getID() + " is named: "+ name );
       }
    }, null );
  2. 通过 CQL(通用查询语言)构造 filter

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    Filter filter = CQL.toFilter( "AGE < 12 " );
    //获取 future 时,引入我们定义好的 filter
    SimpleFeatureCollection features = featureSource.getFeatures( filter );
    features.accepts( new FeatureVisitor<SimpleFeature>() ){
    public void visit( SimpleFeature feature ){
       String name = feature.getAttribute("NAME");
       System.out.println( feature.getID() + " is named: "+ name );
    }
    }, null );
  3. 使用过滤器“手动”评估 Feature 测试:

    1
    2
    3
    4
    
    if( filter.evaluate( feature ) ){
        // the feature was "selected" by the filter
        System.out.println( "Selected "+ feature.getId();
    }
    • filter.evaluate( feature ) 返回:
      • TRUE:包含在集合中
      • FALSE:从集合中排除

4.1 CQL(公共查询语言)

公共查询语言(CQL)是作为 OGC 目录规范的一部分出现的标准。它定义了一个类似于 SQL 的文本语法来定义过滤器:

1
2
Filter filter = CQL.toFilter("attName >= 5");
Expression percent = CQL.toExpression("ratio * 100");

除了基本标准之外,GeoTools 社区还允许使用 ECQL Class 进行一些扩展:

1
Filter filter = ECQL.toFilter("area( SHAPE ) BETWEEN 10000 AND 30000");
  • 该示例中,比较 area 的表达式是一种扩展语法,因为基本 CQL 规范只允许比较属性值。

4.2 FilterFactory

我们可以通过 FilterFactory 手动创建 Filter 对象。 FilterFactory 接口仅限于属性值比较

1
2
FilterFactory ff = CommonFactoryFinder.getFilterFactory();
Filter filter = ff.propertyLessThan( ff.property( "AGE"), ff.literal( 12 ) );

4.3 FilterFactory2

现实世界中的需求往往不仅仅满足于属性值的比较,因此 OpenGIS 提供了 FilterFactory2支持过滤 JTS Geometry 实例(ISO Geometry):

1
2
FilterFactory2 ff2 = CommonFactoryFinder.getFilterFactory2( GeoTools.getDefaultHints() );
Filter filter = ff2.contains( ff2.property( "THE_GEOM"), ff2.literal( geometry ) );

FilterFactory2 还允许以更自由的方式定义过滤器。在规范中,所有操作都必须首先具有 PropertyName 表达式。

4.4 XML Filter

Filter 本质上是一个 XML 标准,其文档如下所示:

1
2
3
4
5
6
<ogc:Filter xmlns:ogc="http://www.opengis.net/ogc" xmlns:gml="http://www.opengis.net/gml">
  <ogc:PropertyIsGreaterThanOrEqualTo>
    <ogc:PropertyName>attName</ogc:PropertyName>
    <ogc:Literal>5</ogc:Literal>
  </ogc:PropertyIsGreaterThanOrEqualTo>
</ogc:Filter>

解析 XML inputStream 示例:

1
2
3
Configuration configuration = new org.geotools.filter.v1_0.OGCConfiguration();
Parser parser = new Parser( configuration );
Filter filter = (Filter) parser.parse( inputStream );

解析 DOM 片段示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
InputSource input = new InputSource( reader );

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
Document dom = db.parse( input );

Filter filter = null;

// first grab a filter node
NodeList nodes = dom.getElementsByTagName("Filter");

for (int j = 0; j < nodes.getLength(); j++) {
    Element filterNode = (Element) nodes.item(j);
    NodeList list = filterNode.getChildNodes();
    Node child = null;

    for (int i = 0; i < list.getLength(); i++) {
        child = list.item(i);

        if ((child == null) || (child.getNodeType() != Node.ELEMENT_NODE)) {
            continue;
        }

        filter = FilterDOMParser.parseFilter(child);
    }
}
System.out.println( "got:"+filter );

4.5 Filter 核心抽象层

核心过滤器抽象层如下:

核心 filter 抽象层

这组接口是封闭的。

4.6 Comparison

Filter 数据模型的核心功能就是属性比较,以下是一些示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();
Filter filter;

// 最常见的选择标准是简单的相等测试
ff.equal(ff.property("land_use"), ff.literal("URBAN"));

// 快速测试属性是否具有值
filter = ff.isNull(ff.property("approved"));

// 数字、日期和字符串比较。
filter = ff.less(ff.property("depth"), ff.literal(300));
filter = ff.lessOrEqual(ff.property("risk"), ff.literal(3.7));
filter = ff.greater(ff.property("name"), ff.literal("Smith"));
filter = ff.greaterOrEqual(ff.property("schedule"), ff.literal(new Date()));

// 两个值之间的简短包容性测试
filter = ff.between(ff.property("age"), ff.literal(20), ff.literal("29"));
filter = ff.between(ff.property("group"), ff.literal("A"), ff.literal("D"));

// 不相等测试
filter = ff.notEqual(ff.property("type"), ff.literal("draft"));

// 模糊匹配
filter = ff.like(ff.property("code"), "2300%");
// 自由使用通配符
filter = ff.like(ff.property("code"), "2300?", "*", "?", "\\");

4.7 Null vs Nil

在大部分场景下,PropertyIsNull 可用于检查属性是否存在并且值为空。但有些场景一个属性会出现零次或多次,因此我们需要一种清晰的方法来检查属性是否根本不存在。

1
2
3
4
5
6
7
8
FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();
Filter filter;

// 测试 approved 值是否等于 “null”
filter = ff.isNull(ff.property("approved"));

// 检查 approved 是否存在
filter = ff.isNil(ff.property("approved"), "no approval available");

4.8 MatchCase

默认情况下,属性比较区分大小写,我们可以在构造 Filter 时覆盖此默认值,示例如下:

1
2
3
4
5
6
7
8
9
FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();

// 默认情况下 matchCase = true
Filter filter = ff.equal(ff.property("state"), ff.literal("queensland"));

// 手动覆盖
filter = ff.equal(ff.property("state"), ff.literal("new south wales"), false);

Filter welcome = ff.greater(ff.property("zone"), ff.literal("danger"), false);

4.9 MatchAction

所有实现了 MultiValuedFilter 接口的 filter 都支持对在计算时返回多个值的操作数进行筛选。

这些 filter 处理多个值的方式可以通过 MatchAction 属性进行修改,我们可以通过一个简单的 getter 检索该属性:

1
filter.getMatchAction()

MatchAction 有三个可选的值:

  1. MatchAction.ANY:

    如果任何可能的操作数组合计算结果为 true,则计算结果为 true:

    如果未指定任何匹配操作,则将其设置为默认的 MatchAction.ANY

    1
    2
    3
    4
    
    List<Integer> ages = Arrays.asList(new Integer[] {7, 8, 10, 15});
    
    Filter filter = ff.greater(ff.literal(ages), ff.literal(12), false, MatchAction.ANY);
    System.out.println("Any: " + filter.evaluate(null)); // prints Any: true
  2. MatchAction.ALL

    如果所有可能的操作数组合都计算为 true,则计算结果为 true:

    1
    2
    3
    4
    
    List<Integer> ages = Arrays.asList(new Integer[] {7, 8, 10, 15});
    
    Filter filter = ff.greater(ff.literal(ages), ff.literal(12), false, MatchAction.ALL);
    System.out.println("All: " + filter.evaluate(null)); // prints All: false
  3. MatchAction.ONE

    如果正好有一个可能的值组合计算结果为 true,则计算结果为 true:

    1
    2
    3
    4
    
    List<Integer> ages = Arrays.asList(new Integer[] {7, 8, 10, 15});
    
    Filter filter = ff.greater(ff.literal(ages), ff.literal(12), false, MatchAction.ONE);
    System.out.println("One: " + filter.evaluate(null)); // prints One: true

4.10 逻辑运算

Filter 可以使用 ANDORNOT 二进制逻辑进行组合,示例代码:

1
2
3
4
5
6
7
8
9
filter = ff.not(ff.like(ff.property("code"), "230%"));
filter =
        ff.and(
                ff.greater(ff.property("rainfall"), ff.literal(70)),
                ff.equal(ff.property("land_use"), ff.literal("urban"), false));
filter =
        ff.or(
                ff.equal(ff.property("code"), ff.literal("approved")),
                ff.greater(ff.property("funding"), ff.literal(23000)));

4.11 INCLUDES and EXCLUDES

Filter 类内部定义了两个常量(其本质也是特殊的 Filter,eg:filter = Filter.INCLUDE),可以用作 Sentinel 对象(或占位符):

  1. Filter.INCLUDES

    所有内容都包含在集合中。如果在查询中使用,将返回所有内容。

  2. Filter.EXCLUDES

    请勿包含任何内容。如果在查询中使用,将返回空集合。

INCLUDESEXCLUDES 通常用作其他数据结构中的默认值,例如 Query.getFilter() 的默认值为 Filter.INCLUDE

使用实例:

1
2
3
4
5
6
7
public void draw( Filter filter ){
   if( filter == Filter.EXCLUDES ) return; // draw nothing

   Query query = new Query( "roads", filter );
   FeatureCollection collection = store.getFeatureSource( "roads" ).getFeatures( filter );
   ...
}

4.12 Identifier

Filter 最多的应用场景就是 GIS 场景,这种场景下,我们往往直接匹配 FeatureId,而不是属性值。

Identifier

示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();
Filter filter =
    ff.id(
            ff.featureId("CITY.98734597823459687235"),
            ff.featureId("CITY.98734592345235823474"));
            
//通过 Set 集合:
Set<FeatureId> selected = new HashSet<>();
selected.add(ff.featureId("CITY.98734597823459687235"));
selected.add(ff.featureId("CITY.98734592345235823474"));

Filter filter2 = ff.id(selected);
  • 这种样式的 Id 匹配不应与传统的基于属性的过滤(如边界框筛选器)混合使用。

Identifier 的另一个场景是浏览版本化信息。

示例代码:使用由 fid 和 rid 组成的 ResourceId,ResourceId 可用于浏览版本化信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();
Filter filter;

// 获取特定的版本
filter = ff.id(ff.featureId("CITY.98734597823459687235", "A457"));

// 使用 ResourceId 获取特定版本
filter = ff.id(ff.resourceId("CITY.98734597823459687235", "A457", new Version()));

// 获取 A457 之前的版本
filter =
     ff.id(
          ff.resourceId(
               "CITY.98734597823459687235", "A457", new Version(Action.PREVIOUS)));

// 获取 A457 之后的版本
filter =
     ff.id(ff.resourceId("CITY.98734597823459687235", "A457", new Version(Action.NEXT)));

// 获取第一个版本
filter =
     ff.id(
          ff.resourceId(
               "CITY.98734597823459687235", "A457", new Version(Action.FIRST)));

// 利用索引获取 (ie index = 1 )
filter = ff.id(ff.resourceId("CITY.98734597823459687235", "A457", new Version(1)));

// 利用索引获取 (ie index = 12 )
filter = ff.id(ff.resourceId("CITY.98734597823459687235", "A457", new Version(12)));

// 获取接近 1985 年 1 月的条目
DateFormat df = DateFormat.getDateInstance(DateFormat.SHORT);
df.setTimeZone(TimeZone.getTimeZone("GMT"));
filter =
     ff.id(
       ff.resourceId(
         "CITY.98734597823459687235",
         "A457",
         new Version(df.parse("1985-1-1"))));

// 抓取 1990 年代的所有条目
filter =
     ff.id(
       ff.resourceId(
         "CITY.98734597823459687235",
         df.parse("1990-1-1"),
         df.parse("2000-1-1")));

4.13 空间过滤

Filter 支持空间过滤:

filter 空间过滤

示例:获取边界框中的 Feature:

1
2
3
4
FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();
ReferencedEnvelope bbox =
    new ReferencedEnvelope(x1, x2, y1, y2, DefaultGeographicCRS.WGS84);
Filter filter = ff.bbox(ff.property("the_geom"), bbox);

4.14 时态过滤

时态过滤器:

filter 时态过滤器

org.geotools.temporal 为我们提供了一些实现类:

  • DefaultIntant:这是即时的实现,用于表示单个时间点。
  • DefaultPeriod:这是用于表示时间范围的周期的实现。

示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// use the default implementations from gt-main

DateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
Date date1 = FORMAT.parse("2001-07-05T12:08:56.235-0700");
Instant temporalInstant = new DefaultInstant(new DefaultPosition(date1));

// Simple check if property is after provided temporal instant
Filter after = ff.after(ff.property("date"), ff.literal(temporalInstant));

// can also check of property is within a certain period
Date date2 = FORMAT.parse("2001-07-04T12:08:56.235-0700");
Instant temporalInstant2 = new DefaultInstant(new DefaultPosition(date2));
Period period = new DefaultPeriod(temporalInstant, temporalInstant2);

Filter within = ff.toverlaps(ff.property("constructed_date"), ff.literal(period));

4.15 Filter Expression (表达式)

上面提到的许多 Filter 都表示为两个(或多个)表达式之间的比较,表达式用于访问保存在要素(或 POJO、Record 等)中的数据。

核心表达式抽象图如下,这个集合是开放的,我们可以定义新的实现:

Filter Expression

针对 Feature 计算表达式:

1
Object value = expression.evaluate( feature );

针对 Java Bean,甚至是 java.util.Map:

1
Object value = expression.evaluate( bean );

默认的 Expression 是无类型的,它将尽最大努力将值转换为所需的类型,要自己执行此操作,我们可以使用其重载方法:

1
Integer number = expression.evaulate( feature, Integer.class );

示例:将字符串转换为颜色的表达式:

1
2
Expression expr = ff.literal("#FF0000")
Color color = expr.evaluate( null, Color.class );

Expression 作用非常丰富:

  • PropertyName

    PropertyName 表达式用于从数据模型中提取信息。

    最常见的用途是访问 Feature 属性:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2( GeoTools.getDefaultHints() );
    
    Expression expr = ff.property("name");
    Object value = expr.evaluate( feature ); // evaluate
    if( value instanceof String){
        name = (String) value;
    }
    else {
        name = "(invalid name)";
    }

    我们还可以将值专门要求作为字符串,如果无法将值强制转换为字符串,则返回 null:

    1
    2
    3
    4
    5
    6
    7
    
    FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2( GeoTools.getDefaultHints() );
    
    Expression expr = ff.property("name");
    String name = expr.evaluate( feature, String ); // evaluate
    if( name == null ){
        name = "(invalid name)";
    }
  • X-Paths and Namespaces

    我们可以在 Filter 中使用 XPath 表达式,这对于根据复杂 Feature 评估嵌套属性特别有用。

    若要计算 XPath 表达式,需要一个 org.xml.sax.helpers.NamespaceSupport 对象将前缀与命名空间 URI 相关联。

    FilterFactory2 支持创建具有关联命名空间上下文信息的 PropertyName 表达式:

    1
    2
    3
    4
    5
    6
    
    FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2( GeoTools.getDefaultHints() );
    
    NamespaceSupport namespaceSupport = new NamespaceSupport();
    namespaceSupport.declarePrefix("foo", "urn:cgi:xmlns:CGI:GeoSciML:2.0" );
    
    Filter filter = ff.greater(ff.property("foo:city/foo:size",namespaceSupport),ff.literal(300000));

    可以从现有的 PropertyName 表达式中检索命名空间上下文信息:

    1
    2
    3
    
    PropertyName propertyName = ff.property("foo:city/foo:size", namespaceSupport);
    NamespaceSupport namespaceSupport2 = propertyName.getNamespaceContext();
    // now namespaceSupport2 == namespaceSupport !

    当 PropertyName 表达式不包含或不支持命名空间上下文信息时,PropertyName.getNamespaceContext() 将返回 null。

  • Functions

    我们可以使用 FilterFactory2 创建函数:

    1
    2
    3
    4
    5
    
    FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2( GeoTools.getDefaultHints() );
    
    PropertyName a = ff.property("testInteger");
    Literal b = ff.literal( 1004.0 );
    Function min = ff.function("min", a, b );

    对于包含多个参数的函数,需要使用数组:

    1
    2
    3
    4
    5
    6
    7
    
    FilterFactory ff = CommonFactoryFinder.getFilterFactory(null);
    PropertyName property = ff.property("name");
    Literal search = ff.literal("foo");
    Literal replace = ff.literal("bar");
    Literal all = ff.literal( true );
    
    Function f = ff.function("strReplace", new Expression[]{property,search,replace,all});

    当找不到函数时,创建函数将会失败。

4.16 FilterVisitor

FilterVisitor 用于遍历 Filter 中的数据结构,常见用途包括:

  • 询问有关 Filter 内容的问题;
  • 对 Filter 执行分析和优化(例如,将"1+1"替换为"2");
  • 转换 Filter(考虑搜索和替换)。

所有这些活动都有一些共同点:

  • 需要检查 Filter 的内容;
  • 需要建立结果或答案。

使用 FilterVisitor 遍历数据结构的示例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// The visitor will be called on each object
// in your filter
class FindNames extends DefaultFilterVisitor {
    public Set<String> found = new HashSet<String>();
    /** We are only interested in property name expressions */
    public Object visit( PropertyName expression, Object data ) {
        found.add( expression.getPropertyName() );
        return found;
    }
}
// Pass the visitor to your filter to start the traversal
FindNames visitor = new FindNames();
filter.accept( visitor, null );

System.out.println("Property Names found "+visitor.found );

至此,OpenGIS 的主要内容就介绍完了。



  1. WMS 服务器会接收请求,根据请求内容的不同,可以返回不同格式的最终数据。例如,WMS 可以返回常用图片格式的地图片段供最终用户阅读(类似 Google Maps),其中地图是根据一个样式文件(SLD)生成的,它描述了地图的线划粗细,色彩等;WMS 也可以返回 GeoRSS 和 KML 用来和其它地图服务互通。 ↩︎

  2. WFS 服务器也可以接收请求,但会返回 GML 格式的地理信息数据。GML 是一种基于 XML 的数据格式,它可以完整的再现数据,也是 OpenGIS 数据源的重要形式。 ↩︎


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容