【仓颉语言入门 · 第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~20struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展
五、工程化与标准库21~25cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试
六、并发编程26~28线程的创建与等待、线程同步、并发实战
七、项目实战29~30命令行小工具、GeoJSON 数据处理程序(本文,收官)
  1. 环境搭建与第一个仓颉程序
  2. 变量、常量与基本数据类型
  3. 运算符与标准输入输出
  4. 分支结构:if 与 match 表达式
  5. 循环结构:while / for / Range
  6. 字符串详解与字符串插值
  7. 数组 Array 与区间 Range
  8. 集合框架:ArrayList、HashMap、HashSet
  9. 可空类型 ? 与 Option
  10. 错误处理:异常机制与 Result
  11. 函数定义、参数与返回值
  12. Lambda 与高阶函数
  13. 闭包、作用域与函数类型
  14. 迭代器 Iterator 与 Sequence
  15. 结构体 struct 与类 class
  16. 构造函数、属性与方法
  17. 接口 interface 与实现
  18. 枚举 enum、代数数据类型与 match 模式匹配
  19. 泛型编程
  20. 扩展、类型别名与可见性控制
  21. cjpm 包管理与多文件项目组织
  22. 文件与目录 IO
  23. JSON 处理
  24. 网络编程入门
  25. 单元测试
  26. 并发基础:线程的创建与等待
  27. 线程同步:互斥锁、原子类型与条件变量
  28. 并发实战:多线程任务处理
  29. 实战一:带文件持久化的命令行小工具
  30. 实战二: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同名形状的集合再多套一层数组—

两个必须先记住的约定:

  1. 坐标顺序是 [经度, 纬度](longitude, latitude),不是数学课习惯的"先横后竖"那么简单——很多人第一次都读反;
  2. 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 把这些意外全兜住:

  1. v is JsonArray 是第 17 课的类型判断,先确认是数组;
  2. arr.size() < 2 拦住只有一个坐标的残数据;
  3. 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 课

五条可以迁移到任何数据处理项目的经验:

  1. 先建领域模型,再写解析。Pos、GeoFeature、GeoType 定下来之后,统计和导出层根本不知道 JSON 存在——以后输入格式从 GeoJSON 换成别的,只动 parseFeature;
  2. 错误分层。文件级错误抛异常、记录级错误返回 Option 跳过——"整个输入无效"和"其中一条无效"是两回事;
  3. 外部数据每个字段都当作可能坏掉。get 配 match、类型先 is 再 asXxx、数字转换包 try-catch;
  4. 结果对象代替散落的返回值。LoadResult(列表+总数+跳过数)、Stats(八个统计量)让函数签名一眼可读;
  5. 输出格式选最通用的。CSV 任何表格软件都开,UTF-8 直写中文不转义——第 23 课验证过的经验直接复用。

十、CIDE 实操

  1. cjpm init --name geojson,按 3.1 配好 stdx 的 bin-dependencies;
  2. 新建 data/places.geojson,粘贴 3.2 的内容;
  3. 新建 src/geo_feature.cj、src/geo_pipeline.cj,改写 src/main.cj;
  4. 点运行(或 cjpm run),对照第七节逐行核对输出;
  5. 在左侧文件树打开生成的 data/points.csv 看内容,也可以拖进 Excel;
  6. 做第八节的三个容错实验,记得把 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
解析 JSONJsonValue.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 读
遍历 JsonArrayfor (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。


十三、课后练习

  1. (必做)按名字关键字搜点。在 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)
  1. (必做)统计报告加一行"经纬度跨度"。在 main 的统计报告里(纬度范围 那行后面)直接用 Stats 已有字段相减并打印,不需要改 Stats 类:
println("经纬度跨度:${st.maxLon - st.minLon} × ${st.maxLat - st.minLat}")

期望输出(经度跨度 8.175°、纬度跨度 16.822°):

经纬度跨度:8.175000 × 16.822000
  1. (必做)破坏实验:把 8.3 节的 bad3.geojson 准备好,把 main 的输入路径临时改成它,运行。确认:程序不崩溃;关键两行是 共 11 个要素,成功 4 个,跳过坏要素 7 个、点要素:3 线要素:1 其他几何:0;点清单里三个点都叫"未命名"(坐标分别是 116/39、117/40、118/41),而"正常的线"被保留。验证完把路径改回 data/places.geojson。

  2. (选做)给线要素也导一份 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——你已经走完了一门语言入门阶段最完整的闭环:语法、类型系统、集合、错误处理、面向对象、泛型、工程化、并发,最后落成两个能天天用的小程序。

学编程没有"读完"的那一刻,接下来的路径很朴素:

  1. 把身边的小麻烦写成程序——批量改文件名、整理导出的数据、定时跑的检查脚本,第 29、30 课的两个骨架(交互式工具、数据处理流水线)足以装下大多数需求;
  2. 给程序写测试——第 25 课的 @Test 不是装饰,项目越大它越值钱;
  3. 读真实项目的代码——遇到不懂的 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
Logo

一站式 AI 云服务平台

更多推荐