ReScript 真好玩-写个 CSV 转 JSON 的 CLI

156 阅读7分钟

ReScript 是一门函数式编程语言,而且它跟 OCaml 有很多相似的地方,语言本身有模式匹配,转出来的 JS 代码也有很高的性能,还能很轻松绑定 JS 的库,同时也是强类型语言,最最重要的是它语言本身支持 React JSX,非常适合 React。总之,ReScript 真好玩。

起因

公司有个客户端项目要做个省市区街级联的功能,后端同事给了我一个 CSV 文件,刚好该客户端我选择的技术栈是 Bun + Tauri + ReScript + Vite,那就用 ReScript + Bun 的方式做一个 CLI 工具。

项目配置

先装好 Bun,因为我讨厌 Cpp,所以我运行时更倾向于选择 Deno/Bun,由于上个月系统学习了一下 Zig,加之我了解到 Bun 是可以直接运行 TypeScript 及支持很多 ES 的新特性,所以我就想尝试下 Bun

curl -fsSL https://bun.sh/install | bash

装好 Bun,然后正常创建一个前端项目,也就是包含 package.json 的项目,无论你是用 bun init 创建还是手动弄创建文件都可以,项目名我这里叫 csv2json
创建完成后,在 package.json 文件内写以下内容后,再执行 bun i 来安装依赖。
这里主要核心库除了 rescript 外,还要用到 nodemon 守护进程方便开发调试,还有 ReScript 版的 NodeJS 绑定包 rescript-nodejs,譬如读写文件这类操作就要用到它,以及 csv-parser 解析 CSV 文件用。

{
  "name": "csv2json",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "run-p start:**",
    "start:re": "rescript build -w",
    "start:dev": "nodemon --exec node src/index.js",
    "install:re": "rescript build -with-deps",
    "postinstall": "run-s install:**",
    "exec": "rescript build -with-deps && node src/index.js"
  },
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "nodemon": "^3.1.7",
    "npm-run-all": "^4.1.5",
    "rescript-nodejs": "^16.1.0"
  },
  "dependencies": {
    "@rescript/core": "^1.6.1",
    "csv-parser": "^3.0.0",
    "rescript": "^11.1.4"
  }
}

总体项目的目录结构长这样

.
├── area.csv
├── area.json
├── bun.lockb
├── package.json
├── rescript.json
└── src
    ├── CSV.res
    ├── Index.res
    └── index.js

这里有个 rescript.json 的文件需要注意,里面是关于 ReScript 的配置,详细配置请看 ReScript 官网
主要关注点在 bs-dependenciessuffixbs-dependencies 是说明哪些依赖是给 ReScript 使用,suffix 是表示生成文件的文件副档名长啥样。

{
  "name": "csv2json",
  "sources": {
    "dir": "src",
    "subdirs": true
  },
  "package-specs": {
    "module": "esmodule",
    "in-source": true
  },
  "suffix": ".bs.js",
  "ppx-flags": [],
  "jsx": {
    "version": 4,
    "mode": "automatic"
  },
  "uncurried": true,
  "namespace": true,
  "bsc-flags": [
    "-open RescriptCore"
  ],
  "bs-dependencies": [
    "@rescript/core",
    "rescript-nodejs"
  ],
  "warnings": {
    "error": "+101"
  }
}

当然还有个非常关键的 .gitignore 文件

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

dist-ssr
*.local
out/*

# Editor directories and files
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

# ReScript
lib
.merlin
.bsb.lock
src/**/*.bs.js

# Intellij
.idea

# vscode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace

# Vite
dist

# npm
node_modules

# macOS
.DS_Store

area.json
area.plist

挨下来打开 src/index.js 文件,因为最终生成文件已经设置成 .bs.js 结尾的,所以直接写上这行代码

import "./Index.bs.js"

分析 CSV 内容及目标 JSON 格式定义

来分析一下我们后端给我的 CSV 文件长啥样子

id,pid,deep,name,pinyin_prefix,pinyin,ext_id,ext_name
11,0,0,"北京","b","bei jing","110000000000","北京市"
1101,11,1,"北京","b","bei jing","110100000000","北京市"
110101,1101,2,"东城","d","dong cheng","110101000000","东城区"
110101001,110101,3,"东华门","d","dong hua men","110101001000","东华门街道"

然后我们需求是把最终上述文件内容转成以下内容的 JSON

[
    {
        "id": "11",
        "name": "北京市",
        "header": "B",
        "children": [
            {
                "id": "1101",
                "name": "北京市",
                "header": "B",
                "children": [
                    {
                        "id": "110101",
                        "name": "东城区",
                        "header": "D",
                        "children": [
                            {
                                "id": "110101001",
                                "name": "东华门街道",
                                "header": "D",
                                "children": []
                            }
                        ]
                    }
                ]
            }
        ]
    }
]

实际编码

首先打开 csv-parser 在 npmjs 上的文档,可以看到基础使用是这副样子

const csv = require('csv-parser')
const fs = require('fs')
const results = [];

fs.createReadStream('data.csv')
  .pipe(csv())
  .on('data', (data) => results.push(data))
  .on('end', () => {
    console.log(results);
  });

所以首先是使用 createReadStream 从一个路径以 Stream 的形式打开,然后 pipe 到 csv 实例,之后就是简单的取数据,结束进行一些后续操作。
所以变成 ReScript 代码就是下面这样,open NodeJs 模块,再用 打开后的 Fs 模块内的 createReadStream 先读取一下文件,结果暂时使用 ignore 忽略,因为后面我也不知道写啥

open NodeJs

Fs.createReadStream("./area.csv")
->ignore

Module

我现在假装还不知道 csv() 对应到 ReScript 应该写成啥。先找到 ReScript Module 这一章节。
一般的 Module 是这样的

module T = {
  let message = "hello"
}

let msg = T.message

Module 里还能套 Module

module T = {
  module T1 = {
    let message = "hello"
  }
}

let msg = T.T1.message

Module 还能有 Signatures,虽然乍一看不知道能干嘛

module type Read = {
  type read
}

但是如果模块还有 Functions 会怎样?

module type Read = {
  type read
}

module MakeRead = (T: Read) => {
  let parse = (): T.read => {
    // ...
  }
}

module T = {
  type read = {
    id: string
  }
}

module Read = MakeRead(T)
let read = Read.parse()
Console.log(read.id)

甚至有种泛型的感觉,而且 ReScript 这语言是强类型的,满满的安全感,所以我们现在其实可以用 ReScript 绑定一下 csv-parser
将 CSV.res 文件内容补充完整,这里涉及到一个知识点,包装第三方库要用到 @moduleexternal 的组合拳,因为在 JavaScript 里应该是下面这种用法

import csv from "csv-parser";
csv();

现在的情况是我不知道 csv() 调用后的类型,幸好 csv-parser 是有 TypeScript 的类型文件的

import { Transform } from 'stream';
declare namespace csvParser {
  type CsvParser = Transform;
  interface Options {
    readonly headers?: ReadonlyArray<string> | boolean;
    // ...
  }
}

declare const csvParser: (
  optionsOrHeaders?: csvParser.Options | ReadonlyArray<string>
) => csvParser.CsvParser;

export = csvParser;

由于 ReScript 是不支持 TypeScript 的 Union Types,我决定只选保留 Options 的那块,同时 Transform 在 rescript-nodejs 包里是一个 Stream 的 subtype,那对应 ReScript 就要写成下面这种,甚至还利用上 Module Functions 特性从外部读取返回值类型

module type Read = {
  type read
}

module MakeParser = (R: Read) => {
  open NodeJs
  type t

  type options = {headers: array<string>}

  @module("csv-parser")
  external make: (~opts: options=?) => Stream.subtype<Stream.transform<'w, R.read>> = "default"
}

先回到 src/Index.res 文件,现在把内容改成以下内容,主要是增加了原始 csv 文件对应 ReScript record 的类型声明,同时调用 Stream.pipe 接收 csv-parser 实例,同时读取文件内容及取出内容后续解析操作

open NodeJs

module T = {
  type read = {
    id: string,
    pid: string,
    deep: string,
    name: string,
    pinyin_prefix: string,
    pinyin: string,
    ext_id: string,
    ext_name: string,
  }
}

module Parser = CSV.MakeParser(T)

let origin = []

Fs.createReadStream("./area.csv")
->Stream.pipe(
  Parser.make(
    ~opts={headers: ["id", "pid", "deep", "name", "pinyin_prefix", "pinyin", "ext_id", "ext_name"]},
  ),
)
->Stream.onData(row => origin->Array.push(row))
->Stream.onEnd(() => {
  // ...
})
->ignore

解析并映射 JSON 数据

现在主要任务是在 Stream.onEnd 里做点微小的工作。先用 origin->Array.sliceToEnd(~start=1) 过滤掉列头,同时把数据全 map 到另一个 record item 类型去,因为有些原始数据其实并没有用,譬如 ext_id 这种,那这种就简单地 Array.map 一下,这里又涉及一个支持点,因为我现在加了一个 children 属性,它是一个 array,同时 array 的内容又是 item 本身,属于递归声明了,所以加个 rec 关键字

type rec item = {
  id: string,
  pid: string,
  name: string,
  pinyin_prefix: string,
  children: array<item>,
}

let fill = (a: array<T.read>) => {
  a->Array.map(item => {
    {
      id: item.id,
      pid: item.pid,
      pinyin_prefix: item.pinyin_prefix,
      name: item.ext_name,
      children: [],
    }
  })
}

// ...
->Stream.onEnd(() => {
  let filled = fill(origin->Array.sliceToEnd(~start=1))
})
->ignore

因为后端同事给我的数据是 pid 跟部分 id 是对应关系,所以我们要把上面的 filled 数组通过 Map 数据结构来解决这桩事,那这里又涉及一个知识点 Variantm->Map.get(v.pid) 这个操作,拿到的其实是一个类型 option<item> 的值(这里可以理解成 Rust 的 enum Option,它们完全没啥大差别),我们可以通过 switch 来进行模式匹配用于代码防御,这里是把为 None 的不处理了,只处理 get 到的 item

let buildTree = (nodes: array<item>) => {
  let m = Map.make()
  nodes->Array.forEach(v => {
    m->Map.set(v.id, v)
  })
  let results = []
  nodes->Array.forEach(v => {
    if v.pid == "0" {
      results->Array.push(v)
    } else {
      switch m->Map.get(v.pid) {
      | Some(node) => node.children->Array.push(v)
      | _ => ()
      }
    }
  })
  results
}
// ...
->Stream.onEnd(() => {
  let filled = fill(origin->Array.sliceToEnd(~start=1))
  let tree = filled->buildTree
})
->ignore

其实到上面那一步已经完工了,但是由于数据量太大,最后生成的文件非常大,只好做一个瘦身操作。那此时又涉及到另一个知识点,那就是函数也是可以使用 rec 关键字来声明它具有递归行为

type rec results = {
  id: string,
  name: string,
  header: string,
  children: array<results>,
}

let rec slim = (a: array<item>) => {
  a->Array.map(v => {
    id: v.id,
    name: v.name,
    header: v.pinyin_prefix->String.toUpperCase,
    children: slim(v.children),
  })
}

// ...
->Stream.onEnd(() => {
  let filled = fill(origin->Array.sliceToEnd(~start=1))
  let tree = filled->buildTree->slim
})
->ignore

写入文件

最后是把处理好的内容写入到文件,那这里再介绍好玩的操作,我们先是把转出来的数据用 JSON.stringifyAny 来生成一个 JSON 对象,由于这个函数可能会失败,因为不用处理为 None 的情况,所以直接用 Option.map 来处理 Some(x) 的情况,挨下来就是常规操作了,利用 NodeJS 的 Fs.writeFileSync(...) 写文件就完事了。

// ...
->Stream.onEnd(() => {
  let filled = fill(origin->Array.sliceToEnd(~start=1))
  let tree = filled->buildTree->slim
  JSON.stringifyAny(tree)
  ->Option.map(v => Fs.writeFileSync("area.json", Buffer.fromString(v)))
  ->ignore
})
->ignore

最后执行一下 bun run exec 来生成实际文件。