【仓颉语言入门 · 第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~20struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展
五、工程化与标准库21~25cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试
六、并发编程26~28线程、Channel 通道与同步原语、并发实战
七、项目实战29~30命令行小工具、GeoJSON 数据处理实战
  1. 环境搭建与第一个仓颉程序
  2. 变量与常量:let / var 与基本数据类型
  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 处理(结合 stdx 扩展库)
  24. 网络编程入门(本文)
  25. 单元测试
  26. 并发基础:线程的创建与等待
  27. Channel 通道与同步原语
  28. 并发实战:多线程任务处理
  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:两种性格迥异的运输方式

网络传输层有两个最常用的协议:

TCPUDP
比喻打电话:先接通再聊寄信:写好地址就扔出去
连接需要先建立连接无连接
可靠性保证送达、保证顺序、不丢包不保证(可能丢、可能乱序)
流式字节流(没有天然的消息边界)数据报(一发一收,边界清晰)
典型应用网页、聊天、文件传输视频直播、DNS 查询、游戏状态同步

简单记:要可靠用 TCP,要快且丢一点无所谓用 UDP。本课两个都会写。

1.3 HTTP:跑在 TCP 之上的"应用层普通话"

TCP 只负责把字节安全送达,但"这些字节是什么意思"需要应用层自己来约定。HTTP 就是全世界约定俗成的那套应用层协议:客户端发一段带格式的文本(方法、路径、头部、正文),服务端回一段带格式的文本(状态码、头部、正文)。你在浏览器地址栏敲网址,背后就是一次 HTTP 请求。

三个层次的关系:

应用层:  HTTP(规定"请求/响应"的格式)      ← 本课第四~六节
传输层:  TCP(可靠字节流) / UDP(数据报)   ← 本课第二、三节
网络层:  IP + 端口(找到那台机器那个程序)

1.4 仓颉里的对应物

层次仓颉的包在哪
TCP / UDP socketstd.net标准库自带,无需任何配置
HTTP 客户端 / 服务端stdx.net.httpstdx 扩展库(第 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 对照着记

动作TCPUDP
创建TcpServerSocket(bindAt: port) / TcpSocket(ip, port)UdpSocket(bindAt: port)
建立关系服务端 accept()、客户端 connect()没有,双方各自 bind() 即可
发write(bytes)sendTo(地址, bytes)
收read(buf): Int64receiveFrom(buf): (SocketAddress, Int64)
地址对象构造时直接给 ip、portIPSocketAddress.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!

三个要点:

  1. 客户端由 ClientBuilder().build() 创建,不是直接 Client()(那样编译会报 no matching constructor)。Builder 上还能链式设置超时等:ClientBuilder().readTimeout(Duration.second * 10).build()。
  2. resp.body 是 InputStream 接口,不能直接插值打印(编译器会提示 should implement interface 'ToString'),必须 read 到字节数组再转字符串——和 socket 读数据是同一套思路。
  3. 用完 client.close()。

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 实操

  1. 新建工程:CIDE 菜单"文件 → 新建项目",类型选"可执行程序",项目名如 netdemo。
  2. HTTP 部分要配 stdx:本课第二、三节(TCP/UDP)只用 std.net,不配也能跑;第四~六节用到 stdx.net.http,需要按第 23 课第二节在 cjpm.toml 末尾追加 bin-dependencies 的 path-option 指向 stdx 解压目录。
  3. 粘贴代码运行:把任一节代码粘贴进 src/main.cj,点击运行(或终端 cjpm run)。
  4. 浏览器联动:第五节的最小服务跑起来后(终端停在 serve 阻塞),打开浏览器访问 http://127.0.0.1:8080/hello 亲眼看响应;在 CIDE 里按"停止"结束服务。
  5. 防火墙提示:首次绑定端口时 Windows 可能弹出"是否允许此应用访问网络"——本课都是本机回环(127.0.0.1),允许或拒绝都不影响本机实验。

八、常用 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。


十、课后练习

  1. 带时间戳的回显:改造 2.2 的 echo 服务,服务端回写时在消息前加上收到时的系统时间(提示:import std.time.* 后用 DateTime.now()),客户端打印完整回显。
  2. UDP 问答机:写一对 UDP 程序(或单程序双 socket):发送端发一个数字字符串如 "7",接收端 receiveFrom 后把它解析成整数、计算平方,用来源地址 sendTo 回 "49";发送端再 receiveFrom 打印答案。
  3. 响应头观察员:用 HTTP 客户端 GET 第五节自建的 /hello 服务,除了打印状态码和正文,再用 resp.headers.getFirst("Content-Type") 的 match 分支打印内容类型。
  4. 自我回显服务:写一个 HTTP 服务注册 /reflect,handler 把请求的 method 和 url 拼成一行文本返回(如 你发的是 GET /reflect);用客户端分别以 GET 和 POST 访问验证。
  5. 给待办 API 加两个端点:在第六节实战基础上:① 注册 /todos/count,GET 返回 {"count": 3}(当前条数);② 把 405 分支的响应也改成 JSON:{"error":"method not allowed"},并把状态码常量写在顶部 let STATUS_METHOD_NOT_ALLOWED: UInt16 = 405。

下节预告

本课的待办 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
Logo

一站式 AI 云服务平台

更多推荐