【spark sedona geospark】sedona1.5.1
读取shp
文件,字段类型都是string
的问题
引言
最近在使用
sedona
读取shp
文件中数据,但是遇到了两个问题,一个是中文乱码的问题,一个就是这个类型都是string
的问题了,中文乱码的问题我解决了,但是这个类型的问题没有真正从根本上解决。
给一个我解决中文乱码的博客地址吧,点击跳转
一、问题重现
先贴出代码(Java):
SparkSession sedona = SedonaContext.builder()
.master("local[*]")
.appName("test")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.kryo.registrator", SedonaVizKryoRegistrator.class.getName())
.getOrCreate();
SedonaContext.create(sedona);
SpatialRDD<Geometry> geomRDD = ShapefileReader.readToGeometryRDD(JavaSparkContext.fromSparkContext(
sedona.sparkContext()),shpPath);
Dataset<Row> df = Adapter.toDf(geomRDD, sedona);
df.printSchema();
打印出的结果是:
类型都是
string
,可是我的数据中肯定不止string
这个类型啊,OBJECTID
为integer
是我改过的,它原本也是string
。
二、原因
我上
GitHub
提了一个issue
,官方给的答案真是让人无奈(看代码分析直接跳转到目录第四),直接贴出issue
的地址,点击跳转
官方回答:
**
这个回答的上部分包含了解决中文乱码的办法。
官方的回答简单来说就是他们意识到了这个问题,但是现在暂时没有解决。他们在读取
shp
文件的时候,可能是因为某种原因,将所有的字段都作string
处理了,官方也承认这是不足之处。我觉得后续可能会改进。
当下官方给出的解决办法是自己实现一个基于
Spark DataSourceV2
的shp
读取器,直接将shp
加载为DataFrame
。
我觉得按照我的水平应该是完成不了了,所以我选择另辟蹊径。
三、我的解决办法(可能有更好)
虽然官方在读取
shp
时丢失了字段的所有类型信息,但是官方有提供类型转换的接口,也就是说如果你知道某个字段是什么类型,那么其实你可以手动转它的类型,类似:
Dataset<Row> df = Adapter.toDf(geomRDD, sedona);
df = df.withColumn("OBJECTID", df.col("OBJECTID").cast("int"));
那么我们就可以手动转换类型了,可是类型怎么来呢?
GeoTools
,我们可以使用Geotools
来获取字段的类型,再通过判断的形式,转换所有字段的类型不就可以了吗
引入
gt-shapefile
依赖:
<dependency>
<groupId>org.geotools</groupId>
<artifactId>gt-shapefile</artifactId>
<version>25.2</version>
</dependency>
代码部分(仅示例,详细自己写):
FileDataStore dataStore = FileDataStoreFinder.getDataStore(new File(shpPath));
SimpleFeatureSource featureSource = dataStore.getFeatureSource();
SimpleFeatureType featureType = featureSource.getSchema();
List<AttributeDescriptor> attributeDescriptors = featureType.getAttributeDescriptors();
for (AttributeDescriptor descriptor : attributeDescriptors) {
String fieldName = descriptor.getLocalName();
String fieldType = descriptor.getType().getBinding().getSimpleName();
System.out.println("字段名: " + fieldName + ", 字段类型: " + fieldType);
}
根究代码我们可以得到每个字段的类型的,后面如何处理就不多说了吧
四、导致问题的代码
扒了一下源代码,结合开发人员说的内容,我大概知道了为什么他明知不可为而为之了。
1、ShapefileReader
读取数据的入口他放在了
ShapefileReader
中,提供了一个静态方法,读取到数据后返回SpatialRDD
,方法签名:
public class ShapefileReader {
public static SpatialRDD<Geometry> readToGeometryRDD(JavaSparkContext sc, String inputPath);
}
跟着调用链我们往下跟踪。发现这个方法调用的是:
public static SpatialRDD<Geometry> readToGeometryRDD(JavaSparkContext sc,
String inputPath,
GeometryFactory geometryFactory)
这个方法内部主要读取数据的代码是:
顺着这个
readShapefile
方法往下,发现它是调用SparkContext
的newAPIHadoopFile
方法,让Hadoop
给它处理并分配文件,它直接获取文件路径读取就行:
我们来看看方法签名,当然,这是
Scala
代码:
def newAPIHadoopFile[K, V, F <: NewInputFormat[K, V]](
path: String,
fClass: Class[F],
kClass: Class[K],
vClass: Class[V],
conf: Configuration): JavaPairRDD[K, V]
Scala
我也不熟,不过我们能从调用处窥探到一点门径:
JavaPairRDD<ShapeKey, PrimitiveShape> shapePrimitiveRdd = sc.newAPIHadoopFile(
inputPath,
ShapeInputFormat.class,
ShapeKey.class,
PrimitiveShape.class,
sc.hadoopConfiguration());
*
第一个参数传了一个路径,它是数据文件的路径了。
*
第二个参数是一个转换类,它定义了如何分片数据以及如何读取数据的逻辑。
*
第三个参数是读取数据时的键
,如果不懂键值对
的概念,可以先学学Hadoop
。
*
第四个当然就是值
了。
*
第五个参数是Hadoop
的配置,固定写法。
情况现在比较明了了,我们只要去看它是如何转换数据的就行了。跳转到
ShapeInputFormat
类。
2、ShapeInputFormat
ShapeInputFormat
继承了CombineFileInputFormat
,CombineFileInputFormat
又继承了FileInputFormat
,这个类是Hadoop
定义来读取数据的。继承它并且自己实现的话,可以自定义数据的读取逻辑。
别的我们都可以暂时不用看,
getSplits
是定义分片的,不管。createRecordReader
是真正定义数据如何读取的,也就是说数据读取的真实实现在这里面。
我们看看
Sedona
是如何定义的:
好嘛,接着跳过去。
3、CombineShapeReader
这个类就是真正读取数据的地方,也就是每个分区中真实干活的类,篇幅有限,我们直接跳到
getCurrentValue
方法:
public PrimitiveShape getCurrentValue() throws IOException, InterruptedException {
PrimitiveShape value = new PrimitiveShape(this.shapeFileReader.getCurrentValue());
if (this.hasDbf && this.hasNextDbf) {
value.setAttributes(this.dbfFileReader.getCurrentValue());
}
return value;
}
读取每行
Feature
字段值的代码就是this.dbfFileReader.getCurrentValue()
,我们继续跳过去:
4、DbfFileReader
、DbfParseUtil
跳过来后是一个获取值的方法:
public String getCurrentValue() throws IOException, InterruptedException {
return this.value;
}
当然,这不是读取数据的方法,直接找到
nextKeyValue
方法:
public boolean nextKeyValue() throws IOException, InterruptedException {
String curbytes = this.dbfParser.parsePrimitiveRecord(this.inputStream);
if (curbytes == null) {
this.value = null;
return false;
} else {
this.value = curbytes;
this.key = new ShapeKey();
this.key.setIndex((long)(this.id++));
return true;
}
}
接着跳… :
跳过来后我也懒得说了,继续跳:
好家伙,跳了半天,终于找到真实实现了:
public String primitiveToAttributes(ByteBuffer buffer) throws IOException {
byte[] delimiter = new byte[]{
9};
Text attributes = new Text();
for(int i = 0; i < this.fieldDescriptors.size(); ++i) {
FieldDescriptor descriptor = (FieldDescriptor)this.fieldDescriptors.get(i);
byte[] fldBytes = new byte[descriptor.getFieldLength()];
buffer.get(fldBytes, 0, fldBytes.length);
String charset = System.getProperty("sedona.global.charset", "default");
Boolean utf8flag = charset.equalsIgnoreCase("utf8");
byte[] attr = utf8flag ? fldBytes : fastParse(fldBytes, 0, fldBytes.length).trim().getBytes();
if (i > 0) {
attributes.append(delimiter, 0, 1);
}
attributes.append(attr, 0, attr.length);
}
return attributes.toString();
}
***
到这里我们就很清楚了,它用了字符串拼接的方式来直接读取的dbf
文件,而不是使用GeoTools
读取字段值的。也就是说它是直接读取dbf
文件中的字符,一行一行的读,然后拼接字符串,因为直接读取的文本文件,所以它无法获取到字段的类型信息:
这点我们可以去
Sedona
底层Scala
代码中去验证。
5、Adapter.toDf
要想验证,它是否真的无法获取到字段类型,我们得去找
RDD
是如何成为DataFrame
的。
想将
ShapefileReader.readToGeometryRDD
读取到的SpatialRDD
转为DataFrame
,一般都是调方法:
SpatialRDD<Geometry> geomRDD = ShapefileReader
.readToGeometryRDD(JavaSparkContext.fromSparkContext(
sedona.sparkContext()), shpPath);
Dataset<Row> df = Adapter.toDf(geomRDD, sedona);
Adapter.toDf
就是将SpatialRDD
转为DataFrame
的关键点,点进去后就是Scala
代码了,我的编译器无法下载源代码,可能是没开启Scala
的扩展插件,我上GitHub
上看:
我们能看到,
Adapter.toDf
需要一个SpatialRDD
和一个SparkSession
,而且,它做了一个判空,调用了下面的那个toDf
方方法:
def toDf[T <: Geometry](spatialRDD: SpatialRDD[T], sparkSession: SparkSession): DataFrame = {
import scala.jdk.CollectionConverters._
if (spatialRDD.fieldNames != null)
return toDf(spatialRDD, spatialRDD.fieldNames.asScala.toList, sparkSession)
toDf(spatialRDD = spatialRDD, fieldNames = null, sparkSession = sparkSession);
}
看来下面这个
toDf
才是真正干活的,我们先看看它的代码:
def toDf[T <: Geometry](
spatialRDD: SpatialRDD[T],
fieldNames: Seq[String],
sparkSession: SparkSession): DataFrame = {
val rowRdd = spatialRDD.rawSpatialRDD.rdd.map[Row](geom => {
val stringRow = extractUserData(geom)
Row.fromSeq(stringRow)
})
var cols: Seq[StructField] = Seq(StructField("geometry", GeometryUDT))
if (fieldNames != null && fieldNames.nonEmpty) {
cols = cols ++ fieldNames.map(f => StructField(f, StringType))
}
val schema = StructType(cols)
sparkSession.createDataFrame(rowRdd, schema)
}
结果已经显而易见了,破绽就是:
fieldNames.map(f => StructField(f, StringType))
它直接写死了,每个字段的类型就是
StringType
…
五、导致问题的原因
结合代码,还有之前开发人员说的:
一个更恰当的支持 Shapefile 的方式是实现一个基于 Spark DataSourceV2 的 Shapefile 读取器
我猜测将类型写死在代码中的原因是拿不到字段类型(这不废话嘛),拿不到字段类型的原因是没有使用
GeoTools
读取shp
文件,而是直接读取的dbf
文件,所以拿不到字段类型。
那为什么不使用
GeoTools
来读取数据呢,因为这涉及到Hadoop分片
的问题,之前一个文本文件我们可以简单的拆分,但是现在是一个shp
文件,我们无法通过简单的文本拆分来分片。
如果要达到分片的效果,我们只能拆分图层,一个分片读取图层的一部分。
例如一个图层中有10行要素,我们将它分为两片,那么第一片就是1 ~ 5行要素,用一个List
存起来,第二片就是6 ~ 10行要素,也用一个List
存起来。
但是如果数据量很大,那么我们就无法拆分数据放到
List
中并传输了,我们拆分的必须只是一个索引信息而非真实数据,每个分区根据索引读取整个图层中属于自己分区的部分。
但是这就涉及到一个问题,也就是说每个分区都得有一个完整
shp
的备份,这就又涉及到dfs
了。
总结下来,我猜官方是因为不想引入更复杂的实现(还得引入
dfs
等),迫不得已选择了直接读取dbf
文件而不是用GeoTools
读取数据。换句话说就是Sedona
当前使用的数据源获取实现不够好,导致获取数据源在某些地方存在掣肘的情况,所以官方开发人员才说:
实现一个基于 Spark DataSourceV2 的 Shapefile 读取器
如果你有实力自己实现一个,那么肯定能避免很多不必要的麻烦,我就算了,我没那个实力也没时间。
好了,以上
写在最后
如果你觉得对你有所帮助,请不吝点赞,Thanks!!!