云计算百科
云计算领域专业知识百科平台

【仓颉语言入门 · 第24课】

【仓颉语言入门 · 第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 数据处理实战
  • 环境搭建与第一个仓颉程序
  • 变量与常量:let / var 与基本数据类型
  • 运算符与标准输入输出
  • 分支结构:if 与 match 表达式
  • 循环结构:while / for / Range
  • 字符串详解与字符串插值
  • 数组 Array 与区间 Range
  • 集合框架:ArrayList、HashMap、HashSet
  • 可空类型 ? 与 Option
  • 错误处理:异常机制与 Result
  • 函数定义、参数与返回值
  • Lambda 与高阶函数
  • 闭包、作用域与函数类型
  • 迭代器 Iterator 与 Sequence
  • 结构体 struct 与类 class
  • 构造函数、属性与方法
  • 接口 interface 与实现
  • 枚举 enum、代数数据类型与 match 模式匹配
  • 泛型编程
  • 扩展、类型别名与可见性控制
  • cjpm 包管理与多文件项目组织
  • 文件与目录 IO
  • JSON 处理(结合 stdx 扩展库)
  • 网络编程入门(本文)
  • 单元测试
  • 并发基础:线程的创建与等待
  • Channel 通道与同步原语
  • 并发实战:多线程任务处理
  • 实战一:带文件持久化的命令行小工具
  • 实战二: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 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 对照着记

    动作TCPUDP
    创建 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!

    三个要点:

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

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


    十、课后练习

  • 带时间戳的回显:改造 2.2 的 echo 服务,服务端回写时在消息前加上收到时的系统时间(提示:import std.time.* 后用 DateTime.now()),客户端打印完整回显。
  • UDP 问答机:写一对 UDP 程序(或单程序双 socket):发送端发一个数字字符串如 "7",接收端 receiveFrom 后把它解析成整数、计算平方,用来源地址 sendTo 回 "49";发送端再 receiveFrom 打印答案。
  • 响应头观察员:用 HTTP 客户端 GET 第五节自建的 /hello 服务,除了打印状态码和正文,再用 resp.headers.getFirst("Content-Type") 的 match 分支打印内容类型。
  • 自我回显服务:写一个 HTTP 服务注册 /reflect,handler 把请求的 method 和 url 拼成一行文本返回(如 你发的是 GET /reflect);用客户端分别以 GET 和 POST 访问验证。
  • 给待办 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
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【仓颉语言入门 · 第24课】
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!