【仓颉语言入门 · 第24课】网络编程入门:从 socket 到 HTTP,让程序开口说话
第 23 课结尾说过:JSON 最常见的出场位置不是文件,而是网络——浏览器向服务器要数据,服务器把结果以 JSON 返回。那"网络"这一层到底长什么样?本课从最底层的 TCP/UDP socket 讲起,亲手写一个回显服务;再上到应用层的 HTTP,用 stdx 扩展库发请求、搭服务;最后把第 23 课的 JSON 接上来,做一个真正的"待办清单 API":客户端 POST 一条 JSON,服务端存下来,GET 时再返回完整列表。
本文所有代码与报错文案均在仓颉 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 | 线程、Channel 通道与同步原语、并发实战 |
| 七、项目实战 | 29~30 | 命令行小工具、GeoJSON 数据处理实战 |
一、网络编程到底在做什么
前几课的程序都是"单机自闭环":数据在内存里转,最多读写自己硬盘上的文件。而现实中的程序几乎都要跨进程、跨机器交换数据:浏览器访问网站、手机 App 调后端接口、游戏联机对战……这背后就是网络编程。
先建立四个概念,后面全靠它们:
1.1 IP 地址与端口:网络上的"门牌号 + 房间号"
- IP 地址标识网络上的一台机器,如 192.168.1.10。有一个特殊的 IP 要记住:127.0.0.1,叫回环地址,永远指向"本机自己"——本课所有实验都在自己电脑上完成,全靠它。
- 一台机器上同时跑着很多程序,光有 IP 还不够。端口(port) 是 0~65535 的编号,用来区分同一台机器上的不同程序。比如约定俗成:HTTP 服务用 80,HTTPS 用 443,你自己写的服务随便挑一个没被占用的,如 8080。
所以"连到某台机器的某个程序" = “IP + 端口”,例如 127.0.0.1:8080。
1.2 TCP 与 UDP:两种性格迥异的运输方式
网络传输层有两个最常用的协议:
| 比喻 | 打电话:先接通再聊 | 寄信:写好地址就扔出去 |
| 连接 | 需要先建立连接 | 无连接 |
| 可靠性 | 保证送达、保证顺序、不丢包 | 不保证(可能丢、可能乱序) |
| 流式 | 字节流(没有天然的消息边界) | 数据报(一发一收,边界清晰) |
| 典型应用 | 网页、聊天、文件传输 | 视频直播、DNS 查询、游戏状态同步 |
简单记:要可靠用 TCP,要快且丢一点无所谓用 UDP。本课两个都会写。
1.3 HTTP:跑在 TCP 之上的"应用层普通话"
TCP 只负责把字节安全送达,但"这些字节是什么意思"需要应用层自己来约定。HTTP 就是全世界约定俗成的那套应用层协议:客户端发一段带格式的文本(方法、路径、头部、正文),服务端回一段带格式的文本(状态码、头部、正文)。你在浏览器地址栏敲网址,背后就是一次 HTTP 请求。
三个层次的关系:
应用层: HTTP(规定"请求/响应"的格式) ← 本课第四~六节
传输层: TCP(可靠字节流) / UDP(数据报) ← 本课第二、三节
网络层: IP + 端口(找到那台机器那个程序)
1.4 仓颉里的对应物
| TCP / UDP socket | std.net | 标准库自带,无需任何配置 |
| HTTP 客户端 / 服务端 | stdx.net.http | stdx 扩展库(第 23 课已配好) |
std.net 随 SDK 开箱即用,这是它和第 23 课 JSON 最大的不同——写 socket 不需要动 cjpm.toml。HTTP 则需要 stdx 的 bin-dependencies 配置,第 23 课配过的工程可以直接接着用,没配过的回到第 23 课第二节照做一次即可。
二、TCP:写一个回显服务
最经典的网络程序入门题:echo 服务——客户端发什么,服务端原样回什么。它能一次跑通 TCP 的完整流程。
2.1 服务端与客户端各自的三步曲
TCP 是"打电话"模型,两边角色不同:
服务端(接电话的) 客户端(打电话的)
───────────────────── ─────────────────────
① TcpServerSocket 创建 ① TcpSocket 创建
② bind() 绑定端口开始监听 ② connect() 拨号建立连接
③ accept() 等来电,得到一个连接 ③ connect 返回 = 接通
⇅ 然后双方 read/write 互发字节 ⇅
注意 accept() 和 read() 都是阻塞的:没有客户端来,accept 就一直等;对方没发数据,read 就一直等。为了让一个程序里同时跑服务端和客户端,需要把服务端放进一个新线程——用仓颉的 spawn { … } 关键字,花括号里的代码会在新线程里并发执行(第 26 课会专门讲并发,这里先照抄)。
2.2 完整代码
新建工程,把 src/main.cj 写成:
package netdemo
import std.net.*
main(): Int64 {
// 服务端:bindAt: 0 表示"让系统挑一个空闲端口",避免和别的程序撞车
let server = TcpServerSocket(bindAt: 0)
server.bind()
// localAddress 是抽象的 SocketAddress,取端口号要转成 IPSocketAddress
let port = (server.localAddress as IPSocketAddress)?.port ?? 0
println("服务端已启动,监听端口:${port}")
// 服务端逻辑放进新线程:accept/read 都会阻塞等待
spawn {
let conn = server.accept() // 等到一个客户端连进来
let buf = Array<Byte>(1024, {_ => 0})
let n = conn.read(buf) // 读客户端发来的字节,返回实际读到的个数
let msg = String.fromUtf8(buf[0..n])
println("服务端收到:${msg}")
conn.write("echo: ${msg}".toArray()) // 加个前缀写回去
conn.close()
server.close()
}
// 主线程扮演客户端:向服务端拨号
let client = TcpSocket("127.0.0.1", port)
client.connect()
println("客户端已连接:本地 ${client.localAddress} → 对端 ${client.remoteAddress}")
client.write("ping".toArray()) // 发送的是字节数组
let buf = Array<Byte>(1024, {_ => 0})
let n = client.read(buf)
println("客户端收到:${String.fromUtf8(buf[0..n])}")
client.close()
return 0
}
运行输出(端口号每次运行都不同,这是系统分配的):
服务端已启动,监听端口:53013
客户端已连接:本地 127.0.0.1:53014 → 对端 127.0.0.1:53013
服务端收到:ping
客户端收到:echo: ping
2.3 逐段解读
几个第一次见面的知识点:
① 网络上的数据只有字节,没有字符串。 发送前要把字符串变成字节数组:"ping".toArray()(UTF-8 编码,第 6 课讲过);收到后把字节切片转回字符串:String.fromUtf8(buf[0..n])。这个"字符串 ↔ 字节"的转换是所有网络程序的标配动作。
② read(buf) 的返回值很重要。 它把数据填进你给的缓冲区,返回实际读到的字节数 n——不一定填满整个缓冲区!所以切片要用 buf[0..n] 而不是整个 buf。正式程序里要按协议约定循环读到"收满"为止(见 FAQ)。
③ 用完要 close()。 socket 是操作系统资源(句柄),不关就会泄漏。客户端关 client,服务端每个连接关 conn,监听口关 server。
④ 端口 0 的妙用。 bindAt: 0 让操作系统自动分配空闲端口,测试时不用操心端口被占用;通过 localAddress 能查实际分到几号。localAddress 的类型是抽象的 SocketAddress,要用第 17 课的 as 向下转型成 IPSocketAddress 才有 .port 属性。
⑤ 连接失败抛 SocketException。 比如连一个没人监听的端口,会得到这样的异常(实测文案):
SocketException: Failed to connect 10061: No connection could be made because the target machine actively refused it.
错误码 10061 是 Windows 的"目标机器拒绝连接"。用第 10 课的 try-catch 把它兜住,就能给出友好提示而不是直接崩溃。
三、UDP:不打电话,寄信
UDP 没有连接概念:不需要 accept、不需要 connect,写上收件地址就发,对方绑定端口就能收。
3.1 一发一收的最小例子
package netdemo
import std.net.*
main(): Int64 {
// 接收端:绑定 9999 端口等着收
let receiver = UdpSocket(bindAt: 9999)
receiver.bind()
// 发送端:bindAt: 0,端口让系统随便分
let sender = UdpSocket(bindAt: 0)
sender.bind()
// 指定收件地址,直接发——没有"建立连接"这一步
let target = IPSocketAddress.parse("127.0.0.1:9999")
sender.sendTo(target, "hello udp".toArray())
// 收:receiveFrom 返回 (来源地址, 实际字节数) 元组
let buf = Array<Byte>(1024, {_ => 0})
let (from, n) = receiver.receiveFrom(buf)
println("来自 ${from} 的 ${n} 字节:${String.fromUtf8(buf[0..n])}")
sender.close()
receiver.close()
return 0
}
输出(来源端口是系统随机分的):
来自 127.0.0.1:53619 的 9 字节:hello udp
3.2 和 TCP 对照着记
| 创建 | TcpServerSocket(bindAt: port) / TcpSocket(ip, port) | UdpSocket(bindAt: port) |
| 建立关系 | 服务端 accept()、客户端 connect() | 没有,双方各自 bind() 即可 |
| 发 | write(bytes) | sendTo(地址, bytes) |
| 收 | read(buf): Int64 | receiveFrom(buf): (SocketAddress, Int64) |
| 地址对象 | 构造时直接给 ip、port | IPSocketAddress.parse("ip:port") |
注意 receiveFrom 返回的是元组(第 11 课):第一个分量是发件人地址——UDP 没连接,不靠这个返回值你根本不知道这包是谁寄来的;想回信,把刚才的 from 当收件地址 sendTo 回去就行。
UDP 的简单是有代价的:发出去的数据报可能丢失、可能乱序、重复到达,协议一概不管。所以它只适合"丢一帧无所谓"的场景。需要可靠传输时,回到 TCP。
四、HTTP 客户端:三行代码发一个请求
socket 要自己设计"字节怎么解释",而 HTTP 已经替你定好了格式。日常开发中你接触最多的其实是这一层。仓颉的 HTTP 能力在 stdx 扩展库里(第 23 课配过 bin-dependencies 的工程直接用)。
4.1 GET 请求
本节的客户端代码访问的是 http://127.0.0.1:8080 上的本地服务——它由第五节的最小 HTTP 服务提供。阅读顺序不受影响,但要亲手跑通的话,请先跳到第五节把服务端跑起来(终端停在 serve 阻塞),再回到本节运行客户端。
package netdemo
import stdx.net.http.*
main(): Int64 {
let client = ClientBuilder().build() // 客户端用 Builder 造
let resp = client.get("http://127.0.0.1:8080/hello")
println("状态码:${resp.status}") // UInt16,比如 200
// body 是 InputStream(输入流),要读出来才是内容
let buf = Array<Byte>(8192, {_ => 0})
let n = resp.body.read(buf)
println("正文:${String.fromUtf8(buf[0..n])}")
client.close()
return 0
}
配合第五节的服务运行,实测输出:
状态码:200
正文:Hello from Cangjie!
三个要点:
4.2 POST 提交数据
post 有三个重载:正文可以给 String、Array<UInt8> 或 InputStream。传字符串最常用:
let json = "{\\"name\\":\\"cangjie\\",\\"lesson\\":24}"
let resp = client.post("http://127.0.0.1:8080/echo", json)
println("提交结果:${resp.status}") // 服务端处理成功一般回 200 或 201
其他常用方法一一对应 HTTP 动词:client.put(url, body)、client.delete(url)、client.head(url)、client.options(url)。
4.3 读响应头
响应头按名字取,getFirst 返回 Option<String>(同名头可能有多个值,也有不带头的响应,所以是 Option):
match (resp.headers.getFirst("Content-Type")) {
case Some(ct) => println("内容类型:${ct}")
case None => println("响应没有 Content-Type 头")
}
4.4 一个实测出来的硬限制:HTTPS 要配 TLS
如果直接 client.get("https://…"),会收到这个异常(实测文案):
HttpException: TLS must be configured when HTTPS requests are sent.
意思是:访问 HTTPS 站点必须先给客户端配置 TLS 证书(ClientBuilder().tlsConfig(…))。证书配置涉及 CA 证书文件,超出入门课范围——本课所有实验都用 http://127.0.0.1 本机服务,不踩这个坑。知道有这回事即可。
五、HTTP 服务端:注册路径,写下响应
服务端同样是 Builder 创建,核心动作只有一个:给路径注册处理函数(handler)。
5.1 最小 HTTP 服务
package netdemo
import stdx.net.http.*
main(): Int64 {
let server = ServerBuilder().addr("127.0.0.1").port(8080).build()
// 注册:凡是请求路径 /hello 的,都交给这个 lambda 处理
server.distributor.register("/hello", { ctx =>
ctx.responseBuilder.body("Hello from Cangjie!").build()
})
println("服务已启动:http://127.0.0.1:8080/hello (按 Ctrl+C 停止)")
server.serve() // 阻塞在这里,一直接客
return 0
}
跑起来后,打开浏览器访问 http://127.0.0.1:8080/hello,页面上就能看到 Hello from Cangjie!。访问任何没注册的路径(如 /abc),服务端会自动返回 404——distributor(分发器)内置了这个兜底行为。
三个核心对象:
| server.distributor | 分发器:register(路径, 处理函数) 把 URL 路径和 handler 绑定 |
| ctx(HttpContext) | 一次请求的上下文:ctx.request 拿请求,ctx.responseBuilder 造响应 |
| ctx.responseBuilder | 响应构造器:status()、header()、body() 链式设置,最后 build() 发出 |
handler 的最后一个表达式就是返回动作,build() 调用即完成响应;仓颉允许 lambda 末尾表达式的返回值被自动丢弃,所以不需要额外写 return。
5.2 读请求:方法、路径、正文
在 handler 里通过 ctx.request 能拿到客户端发来的一切:
server.distributor.register("/echo", { ctx =>
let req = ctx.request
println("方法=${req.method} 路径=${req.url} 协议=${req.version}")
// 请求正文同样是输入流,读出来
let buf = Array<Byte>(8192, {_ => 0})
let n = req.body.read(buf)
let text = String.fromUtf8(buf[0..n])
ctx.responseBuilder
.status(201) // 自定义状态码
.header("X-Reply", "cangjie") // 自定义响应头
.body("you sent: ${text}")
.build()
})
用客户端验证(client.post("http://127.0.0.1:8080/echo", "ping body")),实测结果:
服务端打印:方法=POST 路径=/echo 协议=HTTP/1.1
(以及)req body: ping body
客户端拿到:status=201、X-Reply=Some(cangjie)、正文 "you sent: ping body"
5.3 按 HTTP 方法分流
REST 风格的服务习惯"同一路径、不同方法干不同事":GET /todos 查列表、POST /todos 新增。在 handler 里 if (req.method == "GET") 分支即可,下一节的实战就是这么做的。
六、综合实战:待办清单 JSON API
把前三课的东西串起来:HTTP(本课)传数据 + JSON(第 23 课)做格式 + 集合(第 8 课)存内存。做一个迷你后端:
- GET /todos → 返回全部待办的 JSON 数组;
- POST /todos → 请求体是 {"title":"…"},服务端解析后加进列表,返回 201;
- 其他方法 → 405 Method Not Allowed。
为了让你一条 cjpm run 看到完整流程,服务端用 spawn 起在后台线程,主线程当客户端连打三个请求:
package netdemo
import stdx.net.http.*
import stdx.encoding.json.*
import std.collection.*
main(): Int64 {
// 内存里的待办列表(程序退出即丢失,持久化是第 22 课文件 IO 的活)
let todos = ArrayList<String>(["学习仓颉", "写第24课"])
// ── 服务端 ──────────────────────────────
let server = ServerBuilder().addr("127.0.0.1").port(8082).build()
server.distributor.register("/todos", { ctx =>
let req = ctx.request
if (req.method == "GET") {
// 列表 → JSON 数组
let arr = JsonArray()
for (t in todos) {
arr.add(JsonString(t))
}
ctx.responseBuilder
.header("Content-Type", "application/json; charset=utf-8")
.body(arr.toJsonString())
.build()
return
}
if (req.method == "POST") {
// 读请求体 → 解析 JSON → 取出 title
let buf = Array<Byte>(8192, {_ => 0})
let n = req.body.read(buf)
let obj = JsonValue.fromStr(String.fromUtf8(buf[0..n])).asObject()
let title = obj["title"].asString().getValue()
todos.add(title)
ctx.responseBuilder.status(201).body("{\\"added\\":\\"${title}\\"}").build()
return
}
ctx.responseBuilder.status(405).body("Method Not Allowed").build()
})
spawn { server.serve() }
sleep(500 * Duration.millisecond) // 等服务就绪(sleep 与 Duration 来自 std.core)
// ── 客户端 ──────────────────────────────
let client = ClientBuilder().build()
// ① GET 列表
let r1 = client.get("http://127.0.0.1:8082/todos")
let b1 = Array<Byte>(8192, {_ => 0})
let n1 = r1.body.read(b1)
println("GET /todos → ${r1.status}: ${String.fromUtf8(b1[0..n1])}")
// ② POST 新增一条
let r2 = client.post("http://127.0.0.1:8082/todos", "{\\"title\\":\\"发布教程\\"}")
let b2 = Array<Byte>(8192, {_ => 0})
let n2 = r2.body.read(b2)
println("POST /todos → ${r2.status}: ${String.fromUtf8(b2[0..n2])}")
// ③ 再 GET,确认新条目真的存进去了
let r3 = client.get("http://127.0.0.1:8082/todos")
let b3 = Array<Byte>(8192, {_ => 0})
let n3 = r3.body.read(b3)
println("GET /todos → ${r3.status}: ${String.fromUtf8(b3[0..n3])}")
// ④ 发个 DELETE,触发 405 分支
let r4 = client.delete("http://127.0.0.1:8082/todos")
println("DELETE /todos → ${r4.status}")
client.close()
server.close()
return 0
}
实测输出:
GET /todos → 200: [
"学习仓颉",
"写第24课"
]
POST /todos → 201: {"added":"发布教程"}
GET /todos → 200: [
"学习仓颉",
"写第24课",
"发布教程"
]
DELETE /todos → 405
(JsonArray.toJsonString() 默认输出带换行的美化格式,所以数组是多行的。)
运行末尾你可能还会看到一行 WARN … ConnectionException: Socket is closed.——这是 server.close() 关闭监听时内部日志的正常噪音,不影响结果,无视即可。
这一个程序里其实已经藏着真实 Web 后端的完整骨架:路由注册、方法分流、请求体解析、业务数据读写、JSON 响应、状态码语义。把它拆成两个工程(一个只 serve、一个只发请求),就是生产形态。
七、CIDE 实操
八、常用 API 速查
std.net(TCP/UDP,标准库自带)
| TcpServerSocket(bindAt: port) | 创建 TCP 监听 socket |
| .bind() / .accept(): TcpSocket | 绑定并监听 / 阻塞等待一个连接 |
| .localAddress: SocketAddress | 本地地址(转 IPSocketAddress 后取 .port) |
| TcpSocket(ip, port) | 创建 TCP 客户端 socket |
| .connect() | 建立连接(失败抛 SocketException) |
| .write(Array<Byte>) / .read(Array<Byte>): Int64 | 发送 / 接收(返回实际字节数) |
| .remoteAddress / .localAddress | 对端 / 本地地址 |
| UdpSocket(bindAt: port) | 创建 UDP socket |
| .sendTo(SocketAddress, Array<Byte>) | 向指定地址发一个数据报 |
| .receiveFrom(buf): (SocketAddress, Int64) | 收一个数据报,返回(来源地址,字节数) |
| IPSocketAddress.parse("ip:port") | 解析地址字符串(另有 tryParse 返回 Option) |
| 通用 .close() / .isClosed() | 释放资源 / 查状态 |
stdx.net.http(HTTP,需 stdx)
| ClientBuilder().build(): Client | 创建客户端(可加 .readTimeout(…) 等) |
| client.get(url) / client.delete(url) | 无正文的请求 |
| client.post(url, body) / client.put(url, body) | body 支持 String / Array<UInt8> / InputStream |
| resp.status: UInt16 | 状态码(200 / 404 / 500…) |
| resp.body: InputStream | 响应正文,需 read |
| resp.headers.getFirst(name): Option<String> | 取响应头 |
| client.close() | 关闭客户端连接池 |
| ServerBuilder().addr(ip).port(p).build(): Server | 创建服务端 |
| server.distributor.register(path, handler) | 注册路径处理函数(未注册路径自动 404) |
| ctx.request.method / .url / .body / .headers | 读请求 |
| ctx.responseBuilder.status(…).header(…).body(…).build() | 造响应 |
| server.serve() / server.close() | 启动(阻塞)/ 停止 |
九、常见问题 FAQ
Q1:运行 TCP 代码卡在 accept 或 read 不动? 这两个方法都是阻塞等待:没有连接进来 accept 不返回,没有数据到达 read 不返回。检查客户端是否真的发起了 connect/write;本课示例用 spawn 把服务端放到另一个线程,就是为了避免主线程被它卡死。
Q2:read(buf) 为什么有时读到的比预期少? TCP 是字节流,不保证一次 read 正好收满对方一次 write 的内容(可能拆成多次,也可能几次合并)——这就是所谓"粘包/半包"。正式协议要自带边界(如固定长度、分隔符、或像 HTTP 那样先声明 Content-Length),然后循环 read 直到收满。入门阶段的小消息大概率一次收齐,知道这个坑存在即可。
Q3:重复启动服务没报"端口被占用",但新实例一个请求都收不到? 这不是玄学,而是一个比报错更隐蔽的坑(已实测):仓颉的 TcpServerSocket 默认开启端口复用(SO_REUSEADDR),两个实例 bind 同一个端口都会成功——用 netstat -ano 能看到两条 LISTENING 记录——但客户端的连接会被其中一个实例(实测为先启动的那个)全部接走,另一个实例静默收不到任何请求。所以开发时别让两个实例用同一端口;测试图省事就用 bindAt: 0 让系统自动分配。
补充:如果绑定系统保留或需要特权的端口(实测用 135、445),会收到 10013 错误而不是"端口占用": SocketException: Failed to bind 10013: An attempt was made to access a socket in a way forbidden by its access permissions.
Q4:访问 https 网址抛 HttpException: TLS must be configured…? 见 4.4 节:HTTPS 需要用 ClientBuilder().tlsConfig(…) 配置证书。入门实验请用本机 http://127.0.0.1 服务。
Q5:resp.body 直接 println 报 should implement interface 'ToString'? body 是 InputStream 流,不是字符串。先 read 进字节数组再 String.fromUtf8,本课每个示例都是这么做的。
Q6:程序结束时打印一行 WARN … ConnectionException: Socket is closed.? 这是关闭服务端时底层日志的正常输出,不是错误,忽略即可。
Q7:字符串和字节数组之间怎么转? 发送:"…".toArray()(UTF-8 编码);接收:String.fromUtf8(buf[0..n])。注意切片要用实际读到的长度 n。
十、课后练习
下节预告
本课的待办 API 是"人肉验证"的——跑一次、看输出、凭眼睛判断对不对。功能一多,这种方式既不靠谱也没法回归。第 25 课 单元测试 将介绍仓颉标准库自带的 std.unittest:@Test 标记测试用例、@Assert 断言结果、cjpm test 一键跑全套——让你给函数写一份"自动化的考卷",以后每改一行代码都能立刻知道有没有把旧功能碰坏。
系列说明:本系列基于 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
网硕互联帮助中心




评论前必须登录!
注册