【仓颉语言入门 · 第30课】
【仓颉语言入门 · 第30课】实战二:GeoJSON 数据处理程序:把地图数据读进来、算清楚、写出去
系列收官。第 29 课的待办清单数据来自键盘、格式是我们自己定的;这一次我们处理一种全世界通用的真实数据格式——GeoJSON 地图数据:文件里混着点、线、面,字段可能缺失、类型可能写错。程序要把它读进来,逐条转成我们自己的领域对象(坏数据在边界上挡住),做分类统计、包围盒、里程计算,最后导出一份 CSV。这就是真实工作中最常见的程序形态:一条"读 → 解析 → 统计 → 转换 → 写出"的数据处理流水线。第 22 课的文件 IO、第 23 课的 JSON、第 18 课的枚举与 match、第 15~16 课的 struct/class、第 7~8 课的集合、第 9 课的 Option、第 10 课的异常、第 19 课的泛型、第 21 课的多文件——全在这一个项目里会师。
本文所有代码与输出均在仓颉 SDK 1.2.0 + stdx 1.2.0-beta.02.1 下逐行实测编译运行。
目录(系列导航)
整套路线共 7 个模块、30 课:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、环境与入门 | 01~05 | 环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环 |
| 二、常用类型与数据组织 | 06~10 | 字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理 |
| 三、函数与函数式 | 11~14 | 函数、Lambda 与高阶函数、闭包、迭代器与惰性序列 |
| 四、面向对象与类型系统 | 15~20 | struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展 |
| 五、工程化与标准库 | 21~25 | cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试 |
| 六、并发编程 | 26~28 | 线程的创建与等待、线程同步、并发实战 |
| 七、项目实战 | 29~30 | 命令行小工具、GeoJSON 数据处理程序(本文,收官) |
- 环境搭建与第一个仓颉程序
- 变量、常量与基本数据类型
- 运算符与标准输入输出
- 分支结构:if 与 match 表达式
- 循环结构:while / for / Range
- 字符串详解与字符串插值
- 数组 Array 与区间 Range
- 集合框架:ArrayList、HashMap、HashSet
- 可空类型
?与 Option - 错误处理:异常机制与 Result
- 函数定义、参数与返回值
- Lambda 与高阶函数
- 闭包、作用域与函数类型
- 迭代器 Iterator 与 Sequence
- 结构体 struct 与类 class
- 构造函数、属性与方法
- 接口 interface 与实现
- 枚举 enum、代数数据类型与 match 模式匹配
- 泛型编程
- 扩展、类型别名与可见性控制
- cjpm 包管理与多文件项目组织
- 文件与目录 IO
- JSON 处理
- 网络编程入门
- 单元测试
- 并发基础:线程的创建与等待
- 线程同步:互斥锁、原子类型与条件变量
- 并发实战:多线程任务处理
- 实战一:带文件持久化的命令行小工具
- 实战二:GeoJSON 数据处理程序(本文)
一、GeoJSON 是什么
GeoJSON 是用 JSON 表达地理数据的通用格式(地图接口、GPS 轨迹、行政区划边界几乎都用它)。它只用到第 23 课的对象、数组、字符串、数字,但约定了固定的骨架:
FeatureCollection(要素集合,根对象)
└─ features: [ Feature, Feature, ... ]
├─ type: "Feature"
├─ properties: { "name": "天安门", ... } ← 业务属性,随便放
└─ geometry: { "type": "...", "coordinates": ... } ← 几何形状
geometry.type 决定坐标的嵌套层数,这是读 GeoJSON 最容易绕晕的地方:
| geometry.type | 含义 | coordinates 的形状 | 例子 |
|---|---|---|---|
Point | 一个点 | [经度, 纬度](一层数组) | [116.397, 39.908] |
LineString | 一条折线 | [[经度,纬度], [经度,纬度], ...](二层) | 道路、轨迹 |
Polygon | 一个面 | [[[经度,纬度], ...]](三层,外环+洞) | 行政区、湖泊 |
MultiPoint / MultiLineString / MultiPolygon | 同名形状的集合 | 再多套一层数组 | — |
两个必须先记住的约定:
- 坐标顺序是
[经度, 纬度](longitude, latitude),不是数学课习惯的"先横后竖"那么简单——很多人第一次都读反; properties和geometry都允许为null(GeoJSON 规范允许"无属性""无位置"的要素),真实数据里还可能缺字段、坐标写成字符串——这正是本课容错的重点。
为什么不用第 23 课的 Serializable 框架?
第 23 课 FAQ 里埋过伏笔:自己的业务类结构固定,用 Serializable<T>;外部来的、结构不统一的 JSON,用 JsonValue + kind()/match 动态解析。GeoJSON 就是典型的后者:同一个 features 数组里,点坐标是一层数组、线是二层、面是三层,还混着 geometry: null 的坏要素。本课全程手工解析,每一步都校验,坏一条只跳过一条,绝不让整个文件崩溃。
二、要做一个什么东西:一条数据处理流水线
输入工程里的 data/places.geojson(城市地标数据),输出两样东西:屏幕上的统计报告 + 一个新文件 data/points.csv(Excel 能直接打开的表格)。
读文件 解析 JSON 转成领域对象 统计 转换写出
File.readFrom → JsonValue.fromStr → parseFeature 逐条 → buildStats → exportPointsCsv
(第22课) (第23课) 坏的返回 None 跳过 分类/包围盒/里程 points.csv
和第 29 课一样,代码按职责拆成同包三个文件(都写 package geojson,互相不用 import):
geojson/
├── cjpm.toml
├── data/
│ └── places.geojson # 输入数据(手工准备)
└── src/
├── geo_feature.cj # 数据层:枚举、坐标点、要素、JSON→对象的解析
├── geo_pipeline.cj # 流水线层:加载、统计、CSV 导出
└── main.cj # 表现层:把四步串起来,打印报告
三、建工程、配 stdx、备数据
3.1 建工程并配 stdx
GeoJSON 解析用的是 stdx 的 JSON 库(第 23 课配过的可直接跳过本步):
cjpm init --name geojson
在生成的 cjpm.toml 末尾追加(路径换成你自己第 23 课的 stdx 解压位置):
[target.x86_64-w64-mingw32]
[target.x86_64-w64-mingw32.bin-dependencies]
path-option = ["D:\\software\\cangjie\\cangjie_stdx_libs\\stdx"]
接下来三个源文件首行都写 package geojson。
3.2 输入数据 data/places.geojson
在工程根目录新建 data 文件夹,里面建 places.geojson,内容如下(7 个要素:4 个点、1 条线、1 个面、1 个故意放进去的坏要素 geometry: null):
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "name": "天安门" },
"geometry": { "type": "Point", "coordinates": [116.397, 39.908] }
},
{
"type": "Feature",
"properties": { "name": "故宫博物院" },
"geometry": { "type": "Point", "coordinates": [116.397, 39.918] }
},
{
"type": "Feature",
"properties": { "name": "东方明珠" },
"geometry": { "type": "Point", "coordinates": [121.499, 31.239] }
},
{
"type": "Feature",
"properties": { "name": "广州塔" },
"geometry": { "type": "Point", "coordinates": [113.324, 23.106] }
},
{
"type": "Feature",
"properties": { "name": "示例路线" },
"geometry": {
"type": "LineString",
"coordinates": [
[116.397, 39.908],
[116.420, 39.910],
[116.450, 39.928]
]
}
},
{
"type": "Feature",
"properties": { "name": "示例区域" },
"geometry": {
"type": "Polygon",
"coordinates": [
[
[116.300, 39.800],
[116.500, 39.800],
[116.500, 40.000],
[116.300, 40.000],
[116.300, 39.800]
]
]
}
},
{
"type": "Feature",
"properties": { "name": "未知位置" },
"geometry": null
}
]
}
四、数据层:geo_feature.cj
这个文件回答一个问题:一个外部 JSON 要素,怎么变成我们程序里安全、好用的对象?
src/geo_feature.cj(完整代码):
package geojson
import stdx.encoding.json.*
import std.collection.ArrayList
// 几何类型:本课只处理点和线,其余(Polygon、MultiXxx 等)统一记为 Other
public enum GeoType {
Point | LineString | Other
}
// 一个经纬度坐标点:经度 lon、纬度 lat
public struct Pos {
public let lon: Float64
public let lat: Float64
public init(lon: Float64, lat: Float64) {
this.lon = lon
this.lat = lat
}
}
// 一个地理要素:名字 + 几何类型 + 一串有序坐标点(点要素只有 1 个,线要素至少 2 个)
public class GeoFeature {
public let name: String
public let kind: GeoType
public let points: Array<Pos>
public init(name: String, kind: GeoType, points: Array<Pos>) {
this.name = name
this.kind = kind
this.points = points
}
}
// 从 JSON 的 [lon, lat] 数组解析一个坐标点;缺字段或类型不对一律返回 None
func parsePos(v: JsonValue): Option<Pos> {
if (!(v is JsonArray)) {
return None
}
let arr = v.asArray()
if (arr.size() < 2) {
return None
}
try {
let lon = arr[0].asFloat().getValue()
let lat = arr[1].asFloat().getValue()
return Some(Pos(lon, lat))
} catch (e: Exception) {
return None
}
}
// 解析单个 Feature。
// 正常要素返回 Some(GeoFeature);坏要素(geometry 缺失/为 null、坐标损坏、类型字段丢失)返回 None
public func parseFeature(fv: JsonValue): Option<GeoFeature> {
if (!(fv is JsonObject)) {
return None
}
let f = fv.asObject()
// 名字:properties.name 缺失时给"未命名"
var name = "未命名"
match (f.get("properties")) {
case Some(pv) where pv is JsonObject =>
match (pv.asObject().get("name")) {
case Some(nv) where nv is JsonString =>
name = nv.asString().getValue()
case _ => ()
}
case _ => ()
}
// geometry:缺失或为 null 都是坏要素(GeoJSON 允许无位置要素,但本课不收)
let gv = match (f.get("geometry")) {
case Some(g) where !(g is JsonNull) => g
case _ => return None
}
if (!(gv is JsonObject)) {
return None
}
let g = gv.asObject()
let typeName = match (g.get("type")) {
case Some(tv) where tv is JsonString => tv.asString().getValue()
case _ => return None
}
let coords = match (g.get("coordinates")) {
case Some(cv) where cv is JsonArray => cv.asArray()
case _ => return None
}
if (typeName == "Point") {
match (parsePos(coords)) {
case Some(p) => return Some(GeoFeature(name, GeoType.Point, [p]))
case None => return None
}
}
if (typeName == "LineString") {
let list = ArrayList<Pos>()
for (item in coords.getItems()) {
match (parsePos(item)) {
case Some(p) => list.add(p)
case None => return None
}
}
if (list.size < 2) {
return None
}
return Some(GeoFeature(name, GeoType.LineString, list.toArray()))
}
// 认识但本课不展开处理的类型(Polygon、MultiPoint……)
return Some(GeoFeature(name, GeoType.Other, []))
}
4.1 三个领域类型:枚举 + struct + class
GeoType是枚举(第 18 课):几何种类就这三种,用枚举比用字符串"Point"安全——拼错字符串编译器不管,写错枚举构造器编译器立刻报错;Pos用 struct(第 15 课):坐标点只有两个 Float64、创建后不变,是典型的"小数据值对象";GeoFeature用 class:要素是程序里传递、汇总的主体。点要素points只有 1 个元素,线要素至少 2 个,其他类型为空数组。
4.2 枚举不能直接用 == 比较
统计时你可能直觉想写 if (f.kind == GeoType.Point)。实测编译报错:
error: invalid binary operator '==' on type 'Enum-GeoType' and 'Enum-GeoType'
枚举默认不支持 ==(第 18 课讲过,要自己实现 Equatable<T>)。本课统一用 match 判别类型,读起来反而更清楚:
match (f.kind) {
case Point => pointCount += 1
case LineString => lineCount += 1
case Other => otherCount += 1
}
注意一个写法细节:构造枚举值要写全 GeoType.Point;而在 match 的 case 模式里直接写短名 Point。这是仓颉的固定规矩。
4.3 parsePos:把"可能坏掉"关在一个函数里
坐标数组 [116.397, 39.908] 可能变成任何东西:不是数组、只有一个数、里面塞了字符串。parsePos 把这些意外全兜住:
v is JsonArray是第 17 课的类型判断,先确认是数组;arr.size() < 2拦住只有一个坐标的残数据;asFloat()在元素不是数字时会抛JsonException(第 23 课实测),整段包在 try-catch 里,出错就返回None。
整数坐标(如 116)也能被 asFloat() 接受(第 23 课验证过),不用额外处理。
4.4 parseFeature:match + where 守卫
读 properties.name 不能一上来就链式 getOrThrow().asObject().asString()——properties 可能是 null、name 可能缺失、可能是数字。写法是 match 加守卫 where:
case Some(pv) where pv is JsonObject =>
意思是"匹配 Some,并且里面的值确实是 JsonObject,才进这个分支"。守卫关键字是 where——实测写成 when 编译报错:error: expected '=>' in case, found 'when'。
整个函数的返回策略只有一句话:字段缺失给温和默认值(名字 → “未命名”),结构坏了才返回 None(geometry 缺失/null、坐标损坏、线不足两点)。None 不是崩溃信号,而是"这一条跳过"的业务信号。
🚫 另一个实测坑:不要把变量命名为
type——type是仓颉关键字,let type = ...报error: expected identifier or pattern after 'let', found keyword 'type'。课文用的名字是typeName。
五、流水线层:geo_pipeline.cj
这个文件管四件事:加载文件、距离公式、统计、导出 CSV。
src/geo_pipeline.cj(完整代码):
package geojson
import std.fs.{Path, File}
import stdx.encoding.json.*
import std.collection.ArrayList
import std.math.{cos, sqrt}
// 圆周率(1.2.0 的 std.math 没有直接导出 PI 常量,自己定义一个)
const PI: Float64 = 3.141592653589793
// 加载结果:成功解析的要素列表 + 原始要素总数 + 被跳过的坏要素数
public class LoadResult {
public let features: ArrayList<GeoFeature>
public let total: Int64
public let skipped: Int64
public init(features: ArrayList<GeoFeature>, total: Int64, skipped: Int64) {
this.features = features
this.total = total
this.skipped = skipped
}
}
// 统计结果:各类型数量、经纬度包围盒、线要素总长度(公里)
public class Stats {
public let pointCount: Int64
public let lineCount: Int64
public let otherCount: Int64
public let minLon: Float64
public let maxLon: Float64
public let minLat: Float64
public let maxLat: Float64
public let lineLengthKm: Float64
public init(
pointCount: Int64, lineCount: Int64, otherCount: Int64,
minLon: Float64, maxLon: Float64, minLat: Float64, maxLat: Float64,
lineLengthKm: Float64
) {
this.pointCount = pointCount
this.lineCount = lineCount
this.otherCount = otherCount
this.minLon = minLon
this.maxLon = maxLon
this.minLat = minLat
this.maxLat = maxLat
this.lineLengthKm = lineLengthKm
}
}
// 读文件 → 解析 FeatureCollection → 逐条转换,坏要素跳过
public func loadGeoJson(path: String): LoadResult {
let content = String.fromUtf8(File.readFrom(Path(path)))
let root = JsonValue.fromStr(content).asObject()
let typeName = root.get("type").getOrThrow().asString().getValue()
if (typeName != "FeatureCollection") {
throw Exception("不是 FeatureCollection 文件,根类型是 ${typeName}")
}
let arr = root.get("features").getOrThrow().asArray()
let ok = ArrayList<GeoFeature>()
var skipped: Int64 = 0
for (item in arr.getItems()) {
match (parseFeature(item)) {
case Some(feat) => ok.add(feat)
case None => skipped += 1
}
}
return LoadResult(ok, arr.size(), skipped)
}
// 两个相邻坐标点之间的平面近似距离(公里)
// 纬度方向 1°≈111 公里;经度方向 1° 的距离随纬度收缩,要乘 cos(纬度)
func segmentKm(a: Pos, b: Pos): Float64 {
let midLatRad = (a.lat + b.lat) / 2.0 * PI / 180.0
let dx = (b.lon - a.lon) * 111.0 * cos(midLatRad)
let dy = (b.lat - a.lat) * 111.0
return sqrt(dx * dx + dy * dy)
}
// 对全部要素做统计
public func buildStats(features: ArrayList<GeoFeature>): Stats {
var pointCount: Int64 = 0
var lineCount: Int64 = 0
var otherCount: Int64 = 0
var minLon: Float64 = 180.0
var maxLon: Float64 = -180.0
var minLat: Float64 = 90.0
var maxLat: Float64 = -90.0
var lineLengthKm: Float64 = 0.0
for (f in features) {
var isLine = false
match (f.kind) {
case Point => pointCount += 1
case LineString =>
lineCount += 1
isLine = true
case Other => otherCount += 1
}
for (p in f.points) {
if (p.lon < minLon) { minLon = p.lon }
if (p.lon > maxLon) { maxLon = p.lon }
if (p.lat < minLat) { minLat = p.lat }
if (p.lat > maxLat) { maxLat = p.lat }
}
if (isLine) {
for (i in 0..(f.points.size - 1)) {
lineLengthKm += segmentKm(f.points[i], f.points[i + 1])
}
}
}
return Stats(pointCount, lineCount, otherCount,
minLon, maxLon, minLat, maxLat, lineLengthKm)
}
// 把点要素导出成 CSV(name,lon,lat),返回导出条数
public func exportPointsCsv(features: ArrayList<GeoFeature>, path: String): Int64 {
let lines = ArrayList<String>()
lines.add("name,lon,lat")
var count: Int64 = 0
for (f in features) {
var isPoint = false
match (f.kind) {
case Point => isPoint = true
case _ => ()
}
if (isPoint) {
let p = f.points[0]
lines.add("${f.name},${p.lon},${p.lat}")
count += 1
}
}
var text = ""
for (line in lines) {
text = "${text}${line}\n"
}
File.writeTo(Path(path), text.toArray())
return count
}
5.1 loadGeoJson:文件级错误抛异常,要素级错误只跳过
错误在这里分成了两层,不要搞混:
| 错误层级 | 例子 | 处理方式 |
|---|---|---|
| 文件级(整个文件没法处理) | 文件不是合法 JSON、根不是 FeatureCollection、文件不存在 | 抛异常,交给 main 的 try-catch 报错退出 |
| 要素级(个别条目坏) | geometry 为 null、坐标少一位、坐标是文字 | parseFeature 返回 None,跳过这一条,其余照常 |
根类型校验是自己 throw Exception(...)——把底层 JSON 错误翻译成一句人能看懂的话。arr.size() 是原始要素总数,skipped 是跳过数,两个数一起返回给上层对账。
5.2 包围盒:把初始值设成"最糟的值"
经纬度的合法范围是经度 ±180、纬度 ±90。统计最小/最大值时把最小值初始化成 180、最大值初始化成 -180(故意反着设),这样第一个真实坐标一定能更新它们,不用单独处理"第一条数据"的分支。最后 4 个数圈出的矩形就是所有要素的包围盒(bounding box),地图软件"缩放到全部数据"用的就是它。
5.3 线里程:平面近似
相邻两点的距离用中学几何就能算:纬度方向 1° 约 111 公里;经度方向 1° 的实际距离随纬度收缩(经线在两极汇聚),要乘 cos(平均纬度);最后勾股定理合成。三角函数在 std.math(cos、sqrt),角度先换成弧度。
📌 实测 1.2.0 的
std.math通配导入没有PI常量(写PI报error: undeclared identifier 'PI'),课文在文件顶自己定义了一个。这是近似公式,适合几十公里跨度;跨省/跨国轨迹要换 Haversine 球面公式,原理类似。
5.4 导出 CSV:ArrayList 拼文本
CSV 就是"每行字段用逗号分隔"的纯文本。先逐行放进 ArrayList<String>,再用字符串插值拼成一个大字符串,File.writeTo 一次性写出(第 22、23 课同款)。中文按 UTF-8 直写,Excel 打开不乱码;如果名字里本身含逗号,正规做法要给字段加双引号——本课数据没有这种情况,先不引入复杂度。
Float64 插值打印固定 6 位小数(第 23 课见过 3.140000),所以 116.397 写出来是 116.397000。
六、表现层:main.cj
src/main.cj(完整代码):
package geojson
import std.fs.{Path, Directory, exists}
main(): Int64 {
if (!exists(Path("data"))) {
Directory.create(Path("data"))
}
let src = "data/places.geojson"
println("正在读取:${src}")
try {
// ① 读 + 解析(坏要素在内部被跳过)
let result = loadGeoJson(src)
println("解析完成:共 ${result.total} 个要素,成功 ${result.features.size} 个,跳过坏要素 ${result.skipped} 个")
// ② 统计
let st = buildStats(result.features)
println("")
println("---- 统计报告 ----")
println("点要素:${st.pointCount} 线要素:${st.lineCount} 其他几何:${st.otherCount}")
println("经度范围:${st.minLon} ~ ${st.maxLon}")
println("纬度范围:${st.minLat} ~ ${st.maxLat}")
println("线要素总长度:${st.lineLengthKm} 公里")
// ③ 点要素清单
println("")
println("---- 点要素清单 ----")
for (f in result.features) {
var isPoint = false
match (f.kind) {
case Point => isPoint = true
case _ => ()
}
if (isPoint) {
let p = f.points[0]
println("- ${f.name}(${p.lon}, ${p.lat})")
}
}
// ④ 转换成 CSV 并写新文件
let csv = "data/points.csv"
let n = exportPointsCsv(result.features, csv)
println("")
println("已导出点要素 ${n} 个到 ${csv}")
} catch (e: Exception) {
println("处理失败:${e.message}")
return 1
}
return 0
}
main 里没有任何解析细节,只有四行流程:加载 → 统计 → 列清单 → 导出。最外层一个 try-catch 兜住所有文件级异常,return 1 是给操作系统的"非正常退出"信号(命令行脚本据此判断成败)。
判别"是不是点要素"这里用了一个小手法:枚举不能 ==,就在 match 里给局部变量 isPoint 赋值,再配合普通 if——比把整段打印塞进 match 分支更适合"循环里还要继续干别的事"的场景。
七、第一次联跑
cjpm run,实测完整输出:
正在读取:data/places.geojson
解析完成:共 7 个要素,成功 6 个,跳过坏要素 1 个
---- 统计报告 ----
点要素:4 线要素:1 其他几何:1
经度范围:113.324000 ~ 121.499000
纬度范围:23.106000 ~ 39.928000
线要素总长度:5.213492 公里
---- 点要素清单 ----
- 天安门(116.397000, 39.908000)
- 故宫博物院(116.397000, 39.918000)
- 东方明珠(121.499000, 31.239000)
- 广州塔(113.324000, 23.106000)
已导出点要素 4 个到 data/points.csv
对账:7 个要素 = 成功 6(4 点 + 1 线 + 1 面)+ 跳过 1(geometry: null 的"未知位置")。面要素进了"其他几何"计数,但因为本课不解析它的坐标,不参与包围盒和里程。
生成的 data/points.csv 内容(用记事本或 Excel 打开,中文正常):
name,lon,lat
天安门,116.397000,39.908000
故宫博物院,116.397000,39.918000
东方明珠,121.499000,31.239000
广州塔,113.324000,23.106000
八、容错实验:喂三份"问题文件"
第 29 课给 todo 做过破坏实验,这一课如法炮制。下面三个文件放进 data/,把 main 里的 src 临时改成对应路径运行(看完改回来)。
8.1 文件本身是坏 JSON
data/bad1.geojson(features 数组没写完):
{ "type": "FeatureCollection", "features": [
实测输出:
正在读取:data/bad1.geojson
处理失败:The json data is Non-standard, please check:
Parse Error: [Line]: 1, [Pos]: 45, [Error]: Unexpected character: '\n'.
JSON 库给出的错误精确到行列号;外层 try-catch 没有让程序打出堆栈崩溃,而是"处理失败:"加原文。
8.2 根节点类型不对
data/bad2.geojson(这是一个 Point,不是 FeatureCollection):
{ "type": "Point", "coordinates": [116.0, 39.0] }
实测输出:
正在读取:data/bad2.geojson
处理失败:不是 FeatureCollection 文件,根类型是 Point
命中我们在 loadGeoJson 里手写的校验——底层抛的通用异常被翻译成了业务话术。
8.3 一个文件里塞十种坏要素
data/bad3.geojson:11 个要素里混进字符串元素、缺字段、坐标是文字、线只有一个点等各种状况:
{
"type": "FeatureCollection",
"features": [
"我根本不是对象",
{ "type": "Feature", "geometry": { "type": "Point", "coordinates": [116.0, 39.0] } },
{ "type": "Feature", "properties": { "name": 12345 }, "geometry": { "type": "Point", "coordinates": [117.0, 40.0] } },
{ "type": "Feature", "properties": null, "geometry": { "type": "Point", "coordinates": [118.0, 41.0] } },
{ "type": "Feature", "properties": { "name": "坐标只有经度" }, "geometry": { "type": "Point", "coordinates": [116.0] } },
{ "type": "Feature", "properties": { "name": "坐标里是文字" }, "geometry": { "type": "Point", "coordinates": ["东经", 39.0] } },
{ "type": "Feature", "properties": { "name": "坐标不是数组" }, "geometry": { "type": "Point", "coordinates": 123 } },
{ "type": "Feature", "properties": { "name": "没有类型" }, "geometry": { "coordinates": [116.0, 39.0] } },
{ "type": "Feature", "properties": { "name": "geometry 是数字" }, "geometry": 123 },
{ "type": "Feature", "properties": { "name": "线只有一个点" }, "geometry": { "type": "LineString", "coordinates": [[116.0, 39.0]] } },
{ "type": "Feature", "properties": { "name": "正常的线" }, "geometry": { "type": "LineString", "coordinates": [[116.0, 39.0], [117.0, 40.0]] } }
]
}
实测输出:
正在读取:data/bad3.geojson
解析完成:共 11 个要素,成功 4 个,跳过坏要素 7 个
---- 统计报告 ----
点要素:3 线要素:1 其他几何:0
经度范围:116.000000 ~ 118.000000
纬度范围:39.000000 ~ 41.000000
线要素总长度:140.203348 公里
---- 点要素清单 ----
- 未命名(116.000000, 39.000000)
- 未命名(117.000000, 40.000000)
- 未命名(118.000000, 41.000000)
已导出点要素 3 个到 data/points.csv
逐条对账容错策略:
| 坏法 | 命运 |
|---|---|
数组元素是字符串 "我根本不是对象" | 不是 JsonObject → None,跳过 |
| 没有 properties | 名字给默认值"未命名",要素保留 |
properties: null | 守卫 pv is JsonObject 不成立 → “未命名”,要素保留 |
name 是数字 12345 | 守卫 nv is JsonString 不成立 → “未命名”,要素保留 |
| 坐标只有经度 / 坐标是文字 / 坐标不是数组 | parsePos 三道防线 → None,跳过 |
| geometry 缺 type、geometry 是数字 | 结构校验失败 → None,跳过 |
| 线只有一个点 | 不足两点不构成线 → None,跳过 |
文件再烂,程序不崩,好数据一条不丢——这就是外部数据处理程序的及格线。
九、这个项目串起了哪些课
| 本课用到的 | 来自 |
|---|---|
File.readFrom / File.writeTo / exists / Directory.create | 第 22 课 文件 IO |
JsonValue.fromStr / asObject / asArray / asFloat / get / getItems | 第 23 课 JSON |
enum + match + where 守卫 + is 类型判断 | 第 17、18 课 |
struct Pos / class GeoFeature / 构造器 / 属性 | 第 15、16 课 |
Array<Pos> / ArrayList<GeoFeature>(泛型)/ Range 0..n | 第 7、8、19 课 |
Option / Some / None / match 取空 | 第 9 课 |
try-catch / throw Exception / e.message | 第 10 课 |
同包多文件、import 选择器 std.fs.{Path, File} | 第 21 课 |
cos / sqrt 浮点运算 | 第 2、3 课 |
五条可以迁移到任何数据处理项目的经验:
- 先建领域模型,再写解析。
Pos、GeoFeature、GeoType定下来之后,统计和导出层根本不知道 JSON 存在——以后输入格式从 GeoJSON 换成别的,只动 parseFeature; - 错误分层。文件级错误抛异常、记录级错误返回 Option 跳过——"整个输入无效"和"其中一条无效"是两回事;
- 外部数据每个字段都当作可能坏掉。get 配 match、类型先
is再asXxx、数字转换包 try-catch; - 结果对象代替散落的返回值。
LoadResult(列表+总数+跳过数)、Stats(八个统计量)让函数签名一眼可读; - 输出格式选最通用的。CSV 任何表格软件都开,UTF-8 直写中文不转义——第 23 课验证过的经验直接复用。
十、CIDE 实操
cjpm init --name geojson,按 3.1 配好 stdx 的bin-dependencies;- 新建
data/places.geojson,粘贴 3.2 的内容; - 新建
src/geo_feature.cj、src/geo_pipeline.cj,改写src/main.cj; - 点运行(或
cjpm run),对照第七节逐行核对输出; - 在左侧文件树打开生成的
data/points.csv看内容,也可以拖进 Excel; - 做第八节的三个容错实验,记得把
src路径改回data/places.geojson。
十一、常用 API 速查
| 功能 | 写法 | 备注 |
|---|---|---|
| 读整个文件为字符串 | String.fromUtf8(File.readFrom(Path(p))) | 文件不存在抛异常,message 含 does not exist or permission denied! |
| 写文本文件 | File.writeTo(Path(p), text.toArray()) | UTF-8,中文直写 |
| 目录不存在则建 | if (!exists(Path(d))) { Directory.create(Path(d)) } | std.fs |
| 解析 JSON | JsonValue.fromStr(s) | 非法文本抛 JsonException,带行列号 |
| 取对象字段 | obj.get(k) 返回 Option | 配 match { case Some(v) => } 最稳 |
| 带守卫的匹配 | case Some(v) where v is JsonObject => | 守卫关键字是 where |
| 类型判断 | v is JsonArray / v is JsonNull | 先判断再 asArray() |
| 取数字 | .asFloat().getValue() | JsonInt 也能当 Float 读 |
| 遍历 JsonArray | for (x in arr.getItems()) | 不要依赖 JsonObject 字段顺序 |
| 枚举判别 | match (f.kind) { case Point => ... } | 枚举默认不能 == |
| 构造枚举值 | GeoType.Point | 表达式里必须带枚举名前缀 |
| 三角函数 | import std.math.{cos, sqrt} | 角度先 /180.0*PI 换弧度;1.2.0 无 PI 常量,自定义 |
| 动态集合 | ArrayList<T>() → add / toArray / size | 泛型第 19 课 |
| 顶层常量 | const PI: Float64 = 3.14... | 写在文件顶层 |
十二、常见问题 FAQ
Q1:coordinates 为什么是 [经度, 纬度]?反过来行不行?
GeoJSON 规范(RFC 7946)强制顺序为经度在前、纬度在后,对应地图上的 x、y 顺序。读反了程序照样跑,但点会落到地球的另一个角落——这是 GeoJSON 新手最常见的事故,没有之一。
Q2:为什么 geometry 为 null 要跳过?它不是合法 GeoJSON 吗?
合法。规范允许"无位置要素"(只挂属性不挂几何)。本课的程序是做几何统计的,没有几何的要素参与不了包围盒和里程,所以记为坏要素跳过;真实项目里更常见的做法是单独保留它们、仅排除出几何统计。
Q3:match 的守卫为什么用 where 而不是 if / when?
仓颉 1.2.0 实测 match 守卫关键字就是 where。写 when 编译报 expected '=>' in case, found 'when';需要多个条件时用 &&/|| 连在同一个 where 后面。
Q4:Polygon 想支持怎么办?
坐标多一层嵌套:Polygon 的 coordinates 是"环的数组",每个环是"点的数组",即三层 [[[lon,lat],...]],第一个环是外环。扩展方式:在 GeoType 加 Polygon,parseFeature 里遍历外环的点存进 points,即可参与包围盒统计;面积计算(鞋带公式)留作以后进阶。
Q5:线总长度 5.213492 是精确值吗?
不是。segmentKm 是平面近似(固定 1°≈111 公里 + cos 修正),城市内部几十公里误差很小;精确的球面距离要用 Haversine 公式,需要的三角函数 std.math 都提供,结构和本课一样:逐段算、循环累加。
Q6:CSV 用 Excel 打开数字列变成科学计数法/丢精度怎么办?
经纬度 6 位小数在 Excel 精度内不受影响;若以后导很长的编号(如身份证),把那一列用 ="123..." 包裹或告诉用户"导入时指定文本列"即可,这是 CSV + Excel 的通用问题,不是仓颉的问题。
Q7:双击 exe 报 0xC0000135 启动失败?
和第 23、24 课一样:动态版 stdx 运行时需要 DLL,用 CIDE 运行或 cjpm run 会自动处理,别直接双击 target 里的 exe。
十三、课后练习
- (必做)按名字关键字搜点。在
geo_pipeline.cj里新增一个筛选函数,只保留点要素且名字包含关键字:
// 提示:String 的 contains 方法第 29 课 FAQ 用过;点要素的判别照抄 main.cj 里 isPoint 的 match 写法
public func findPoints(features: ArrayList<GeoFeature>, keyword: String): Array<GeoFeature>
在 main 里调用 findPoints(result.features, "广州"),对返回数组逐个打印 ${f.name} 和 f.points[0] 的经纬度。期望输出:
搜到 1 个点要素:
- 广州塔(113.324000, 23.106000)
- (必做)统计报告加一行"经纬度跨度"。在 main 的统计报告里(
纬度范围那行后面)直接用Stats已有字段相减并打印,不需要改 Stats 类:
println("经纬度跨度:${st.maxLon - st.minLon} × ${st.maxLat - st.minLat}")
期望输出(经度跨度 8.175°、纬度跨度 16.822°):
经纬度跨度:8.175000 × 16.822000
-
(必做)破坏实验:把 8.3 节的
bad3.geojson准备好,把 main 的输入路径临时改成它,运行。确认:程序不崩溃;关键两行是共 11 个要素,成功 4 个,跳过坏要素 7 个、点要素:3 线要素:1 其他几何:0;点清单里三个点都叫"未命名"(坐标分别是 116/39、117/40、118/41),而"正常的线"被保留。验证完把路径改回data/places.geojson。 -
(选做)给线要素也导一份 CSV。照
exportPointsCsv再写一个函数,文件头换成name,points,lengthKm:
public func exportLinesCsv(features: ArrayList<GeoFeature>, path: String): Int64
提示:判别线要素用 match (f.kind) { case LineString => ... };折点数取 f.points.size;长度复用同文件的 segmentKm,照 buildStats 里 for (i in 0..(f.points.size - 1)) 的循环累加。main 里导出到 data/lines.csv,期望文件内容:
name,points,lengthKm
示例路线,3,5.213492
系列结语:30 课之后
从第 1 课在屏幕上打出第一行 你好,仓颉,到今天把一份真实的 GeoJSON 读进来、容错解析、统计计算、写出 CSV——你已经走完了一门语言入门阶段最完整的闭环:语法、类型系统、集合、错误处理、面向对象、泛型、工程化、并发,最后落成两个能天天用的小程序。
学编程没有"读完"的那一刻,接下来的路径很朴素:
- 把身边的小麻烦写成程序——批量改文件名、整理导出的数据、定时跑的检查脚本,第 29、30 课的两个骨架(交互式工具、数据处理流水线)足以装下大多数需求;
- 给程序写测试——第 25 课的
@Test不是装饰,项目越大它越值钱; - 读真实项目的代码——遇到不懂的 API 先查官方文档,再写五行小探针验证,这正是整套教程一直要求你做的事。
后续进阶系列(标准库补遗、数据库、C 互操作、更完整的实战项目)也会在本专栏继续。谢谢你跟着走到这里,去写你自己的第一个非教程程序吧。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)+ stdx(1.2.0-beta.02.1)编写,所有代码均已实际编译运行通过。如遇 SDK / stdx 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
📥 工具下载
本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
- 打开页面后进入 发行版(Releases),两种包任选其一:
- 安装版:下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable):下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版:下载
- 仓颉 SDK 与扩展库 stdx 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
更多推荐



所有评论(0)