1. 如何展示大模型返回的数据?换行符,空格是怎么处理的?
使用pre
标签+white-space:pre-warp
pre
标签默认保留换行符和空格,但是不会根据容器的宽度进行换行。
所以需要设置white-space:pre-warp
根据容器宽度进行换行。
white-space有哪些取值?
了解这 5 种即可,核心就在于,是否保留换行符,是否合并多个空白符,是否会随着元素宽度而换行:
(1) normal
: 换行符和多个空格,都会展示成1个空格,会因为超出宽度而换行
(2) nowrap
: 换行符和多个空格符,会展示一个空格,超出容器宽度不换行
(3) pre
: 保留换行符和空格,但是不会因为超出宽度而换行
(4) pre-wrap
: 保留空格和换行,会因为超出宽度而换行
(5) pre-line
: 空格会被合并,保留换行符,会因为超出宽度而换行
实际上对于普通元素来讲,white-space 设置为 pre-wrap,也可以实现类似 pre 标签的效果。在这里也并非一定要使用 pre 标签才行。
那么使用了 white-space 后,还有遇到换行问题吗?
实际上,虽然 white-space: pre-wrap
能够达到我们想要的效果,但是在遇到长的英文单词的时候,还是可能会出现问题(单词超过一行,会超出宽度),这就涉及到另一个属性 word-break
. 它是用来控制单词换行的。有几个取值:(可以对照这里,演示查看:https://developer.mozilla.org/zh-CN/docs/Web/CSS/word-break)
(1) normal
: 默认值,当最后一个英文单词在本行放不下的时候,会自动换到下一行。如果一行也放不下,那就会超出。
(2) break-all
: 直接拆分换行。只要遇到末尾,就会拆开单词换行。一般不常用
(3) keep-all
: 除了英文单词,没有被空格分割的汉语,日语等,都会在本行放不下的时候,自动换到下一行。如果下一行放不下,就会超出。几乎不会用到。
(4) break-word
: 对于英文单词,本行结尾放不下,且下一行也放不下的时候,会进行拆分。否则不拆分。是一般我们会选择的属性。
关于换行,还会涉及到一个 word-wrap
,但是已经属于早期的一些兼容属性,它的作用和 word-break: break-word
一样。知道即可。
为什么要用 pre,不用 code 标签呢?
code 不会保留换行和空格,都会折叠,一般只是用来展示一行代码。而且 code 本身是内联元素,和这里的需求不太一致。
而 pre 保留换行和空格,对服务端返回的数据处理更加友好。且是块级元素,在此处使用起来方便快捷,成本更低。
那么,除了 pre, 还有更好的方案吗?还有优化的空间吗?
服务端返回的是 markdown 格式的文本,可以使用 markdown.js 对内容处理后进行渲染。可以更好的展示文本中的代码块。
同时可以增加 watch (vue) 来减少 markdown 的不必要的转换。
2. 登录注册的实现方案是 JWT,描述一下过程
Koa 实现了基于洋葱模型的中间件,在处理请求时,总是从外层中间件开始,到最内层,最后回到最外层。基于这个结论,我们可以使用一个数组来顺序存储中间件,并依次调用。
中间件,什么是中间件,为什么有中间件,具体跨域和鉴权中间件怎么实现的
(1)中间件是什么?
异步函数,可以执行一些操作,用来处理http请求。有两个参数ctx和next,ctx用来获取请求信息、发送响应内容,next用来将控制权传递给下一个中间件函数。
(2)为什么要有中间件?
处理 Web 请求时,服务端常常需要进行验证请求来源、检查登录状态、确定是否有足够权限、打印日志等操作,而这些重复的操作如果写在具体的路由处理函数中,明显会导致代码冗余,这个时候,我们就可以将这些通用的流程抽象为中间件函数,减少重复代码。
(3)具体跨域和鉴权中间件怎么实现的
跨域中间件:对Access-Control-Allow-Origin
、Access-Control-Allow-Methods
、Access-Control-Allow-Headers
进行设置,默认情况下,它允许所有域、所有方法和所有请求头的跨域访问(设置为*),但可以通过传递不同的 options 参数进行定制。
鉴权中间件:首先通过请求头的authorization获取token,对获取到的 token 进行验证,验证时使用一个 secretKey(密钥),这个密钥在应用程序中应该是保密的。如果验证成功,token中的用户信息将被解码并存储在 ctx.state.user 中,供后续的中间件或路由处理器使用。验证失败返回401状态码。
主要通过检查 JWT 令牌来实现用户的鉴权,并根据令牌的有效性决定是否允许请求继续处理。
Koa 中间件采用的是洋葱圈模型,每次执行下一个中间件传入两个参数 ctx 和 next,参数 ctx 是由 koa 传入的封装了 request 和 response 的变量,可以通过它访问 request 和 response,next 就是进入下一个要执行的中间件 。
koa2的中间件可以有好几层,在每一次请求与响应的时候,都可以在中间拦截,
做登录态管理、状态码管理、错误处理…总之每个中间件都可以做一次拦截来搞事情。
中间件功能是可以访问请求对象(request),响应对象(response)和应用程序的请求-响应周期中通过 next 对下一个中间件函数的调用。通俗来讲,利用这个特性在 next 之前对 request 进行处理,在 next 函数之后对 response 处理。Koa 的中间件模型可以非常方便的实现后置处理逻辑。
那么 jwt 的原理是什么呢?
使用层面,我们就使用 json web token
库来完成 token
的生成和验证:
jwt.sign({ userName: username, userId: user.userId}, secretKey, { expiresIn: '240h' })
jwt.verify(token, secretKey);
除了指定过期时间,还可以指定加密算法。
除此之外,secretKey
是一串字符,可以称之为 密钥,只有服务端知道。其中,加密算法可以采用对称加密(密钥相同,加密解密均是用同一个密钥),或者非对称加密(两个 secretKey
不同,只能用来加密或者解密)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyTmFtZSI6Im1heGlhb2JvIiwidXNlcklkIjoiNDAxYTQ5NWUtNGZiZC0xMWVmLTk0OGUtNmRkNmViMTdhZjI4IiwiaWF0IjoxNzIyNTkyNTMxLCJleHAiOjE3MjM0NTY1MzF9.6-oy186e7Bcqry0xaF165sdGiTiPnsYc9iMliW9QLWE
base64 解密后:(Base64,就是包括小写字母a-z、大写字母A-Z、数字0-9、符号"+“、”/"一共64个字符的字符集,(任何符号都可以转换成这个字符集中的字符,这个转换过程就叫做base64编码。 Base64编码是从二进制到字符的过程,可用于在HTTP环境下传递较长的标识信息。采用Base64编码具有不可读性,需要解码后才能阅读。)
{"alg":"HS256","typ":"JWT"}{"userName":"maxiaobo","userId":"401a495e-4fbd-11ef-948e-6dd6eb17af28","iat":1722592531,"exp":1723456531}2Ξ_x0017_*-1h]zF8_x001C_#%oP-a
由 header, payload, signature
,三部分组成
header
就是加密的算法和 token
类型,jwt 统一写为 JWT
payload
就是用户的自定义信息,同时还有一些常用字段,iat 签发时间,exp 过期时间,nbf 生效时间。
这两部分通过 base64 就可以解密。所以不应该放隐私信息,比如用户密码。
最后一部分是签名,是加密最关键的部分。
这个签名,是通过密钥加密算法 对 header
和 payload
信息加密后生成。此时服务端拿到 token
后,只需要对 token
进行解密然后对比 header
和 payload
,即可验证此 token
无误。由于攻击者无法知道密钥,所以可以防止 token
伪造的情况出现
根据用户选择的算法,自定义数据,过期时间,生成 header, payload
,然后通过用户选择的算法,对 header.payload
进行加密后,组成新的 header.payload.sign
,之后经过 base64 加密。得到 token
。
由于攻击者不知道加密密钥,所以自己伪造的 token
,也只能伪造 header.payload
,但是由于签名信息无法伪造,也就无法通过服务器校验。从而保证了 token
的不可伪造性。
通过将用户的 id 信息等存储在 token 中,每次客户端发起请求都携带 token
。服务器便知道访问者的 userId
,从而实现用户身份的校验。
那么 token 如果泄漏,能够通过校验吗?
能,但是如果用户是正常使用 https 访问,那么信息是加密的,不会被人截取,攻击者是不会有机会获取到的。如果能被截取到,那么用户本身就暴漏在很大的风险中(可能信任了不该信任的证书), 这种情况下,cookie-session 本身也会被获取到。这不是 jwt 算法的问题。
那么这个 token,我只能存在 localStorage 中吗?不能存在 cookie 中吗?
能,可以存在 cookie 中,只是 cookie 存在 csrf 的风险和跨域不能使用的问题。token 可以避免这个问题。
那么用户登出怎么做呢?登出了,此时 token 还有效吗?
一般登出清除客户端的 token 即可。
但是此时 token 没有到有效期,那么确实可以被继续使用。
我们这个程序里确实没有处理这一点,如果想要更加完善,那么可以:
(1) 登出的时候,向服务端发起请求,服务端对 token 进行作废记录
(2) 当遇到 token 时,去数据库中比对,如果在作废表中,那么就不生效。返回 401
(3) 否则,走正常流程即可。
为什么使用 jwt,还有什么其他方案吗?
比较常见的就是 cookie-session 方案。其与 jwt 方案的区别在于:
(1) 客户端处理不同。cookie 是浏览器默认同域携带的,客户端无需干涉。但是与此同时,有 csrf 风险,需要额外处理。而 jwt 的 token 一般放在 localStorage 中,需要每次请求都携带过去,但是好处也是直接杜绝了 csrf 攻击。
(跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。)
(2) 服务端存储不同。session 所对应的信息,需要在服务端进行存储,可能是内存中,数据库中,或者文件中。这些在多机器部署时,信息难以共享,可能需要 redis 进行 session 信息的共享。另一方面,对服务端也有内存压力。而 jwt 信息本身存放在 token 中,服务端无需存储。从而在多机器部署时,不存在共享问题。也没有存储压力。
中间件用的挺熟呀,能讲讲原理吗?看过 koa 的源码吗?
中间件思想,在服务端开发和前端开发中,都用的非常频繁。
它的本质是一个洋葱模型。请求会经过一个一个的中间件,并在执行完成之后又一个一个执行剩余的代码。
放弃 for 循环,我们一个一个执行。
通过递归,可以实现想要的洋葱模型。
注意,这里将 next 的控制权,交给了当前的中间件,所以如果我们在中间件中,不调用 next,那么就可以中断后续中间件的执行。
class Koa {
constructor() {
this.middlewares = [];
}
use(middleware) {
this.middlewares.push(middleware);
}
async exec() {
const context = {};
const dispatch = async (i) => {
if (i === this.middlewares.length) {
return;
}
const middleware = this.middlewares[i];
const next = async () => {
await dispatch(i + 1);
};
await middleware(context, next);
};
await dispatch(0);
return context;
}
}
const app = new Koa();
app.use(async (ctx, next) => {
console.log("-->1");
await next();
ctx.mid1 = "executed";
console.log("<--1");
});
app.use(async (ctx, next) => {
console.log("-->2");
await next();
ctx.mid2 = "executed";
console.log("<--2");
});
app.use(async (ctx, next) => {
console.log("real logic");
ctx.result = 1 + 2;
});
app.exec().then(console.log);
vue-router 用过是吧?知道原理吗?
本质就是前端路由:
有几种方式:
(1) location.hash
和 hashchange
事件
(2) history.pushState
和 popState
事件
本质都是,用户跳转,使用 history.pushState/location.hash
修改页面 path/hash
,此时会触发 popState/hashchange
事件,router
监听对应事件,渲染不同的组件。即可完成前端路由。
这种路由,由于不会向服务端发起 html
请求,所以页面跳转非常丝滑。用户等待时间很短。
hash
的问题,在于链接比较丑,同时由于使用了 hash
,所以页面中不能通过 hash
进行矛点定位。
而 pushstate
则没有这个问题。但是由于修改了链接。但浏览器刷新的时候,会项服务端发起对应 path
下的 html
请求。
如果服务端没有特殊配置,那么就会出现 404 找不到 html
文件的问题。
这种方案需要服务端做对应支持,如果当前路径下找不到,需要回退到上一 path 寻找,直到找到为止。一直找不到,才返回 404.
3. 为什么要使用流式传输?(流式传输,是什么,好处是什么,缺陷是什么,怎么解决的,有没有更好的方案)
如果返回的数据过长,用户等待时间较久,体验很差。
所以使用了 SSE (Server Send Events) 技术进行一段一段的传输。
SSE(Server-Sent Events)是一种用于实现服务器主动向客户端推送数据的技术,也被称为“事件流”(Event Stream)。它基于 HTTP 协议,利用了其长连接特性,在客户端与服务器之间建立一条持久化连接,并通过这条连接实现服务器向客户端的实时数据推送。
具体流程:
一些细节:
(1)客户端 new EventSource
,因为不能传递自定义头或者 body
信息,所以通过 :clientId
这样的动态路由 params
传递 clientId
,而鉴权通过 query
进行传递 token
(2) 惰性建立连接,只有当用户发起聊天时,才会执行建立连接代码。内部通过闭包存储实例,来判断是否曾经建立过连接,如果建立过,则返回旧实例。否则建立新连接。从而达到惰性的效果,减轻服务端压力。
(3)服务端流式信息的遍历: 百度提供的返回 可以通过 for await (const chunk of resp)
来依此读取片段信息。这种方式称为 遍历异步可迭代对象。需要 resp
具备一个 [Symbol.asyncIterator]
方法。具体可不了解,这是百度自己的实例。
(4)client
为什么使用 new stream.PassThrough()
? 接下来重点讲解 node
中的流。
sse 的流,node 怎么实现呢?
百度大模型返回的是一个 异步可迭代对象,我们通过 for await of
的用法,可以拿到一段一段的数据,我们只需要将该数据返回给前端即可。
所以我们需要的是 一个转换流,但是并不需要处理数据,所以我们使用了 Passthrough
。 直接将数据返回给前端。
除此之外还有几种流:
(1) 可读流
(2) 可写流
(3) 可读可写流
(4) 转换流(特殊的一种,就是 passthrough
,是转换流,但是数据并不会转换)
但是 node 流本身是一个复杂的概念,如果面试官深究,建议回答不了解。只是这里用到了,简单了解了一下流的种类,有 xxx, xxx 4种。大概知道,但并没有深入了解。
这里面还有一个细节,就是下面这段代码,可以作为一个需要排查的问题介绍给面试官:
我们在 c 端监听了 onmessage 事件,服务端也发送了 data,但是就是不触发回调。
原因就是数据必须要按照格式来:
第一行,id,第二行 event,第三行 data。第一行可以省略,但是第二行不能,必须指定 event 为 message,onmessage 事件才能正确响应。也算是一个很细的坑点了。
除了 sse,还有什么方案吗?
为什么我们要用 sse?
http 请求一般由客户端发起,服务端响应,服务端不能主动发起。
而 sse,由客户端发起,服务端不是直接响应,而是可以片段片段的推送消息。从而实现流式的效果。
那么其实还可以使用轮询,每隔 500ms 向服务端请求,每次有新内容就返回,没有新内容或者结束就返回标志停止轮询。
除此之外,还有 websocket,由于 websocket 是双方通信,那么用户消息,也可以直接借助 websocket 发出。它的缺点在于对服务器的压力会稍大一些,可能场景复杂后,使用 websocket 会是更好的方案。
前端如何实现文字的逐字输出
大模型返回的是一段一段的,那么此时,如果不加以处理,前端文字就会一段一段的出现,看起来卡卡的,体验很差。需要优化。
核心原理:
接收外部 props.message,中间有一个字段,stream (实际项目中是通过 isEnd 作为标识,本质一样) 如果需要流式显示,那么内部定义一个 aniContent,每隔 40ms 给其加一个字符。直到长度达到字符长度一样为止。
(为什么是 40ms,自己试的,这种人眼体验最丝滑,requestAnimationFrame 有点快,逐帧输出,大概16.7毫秒)
逐字输出有出现什么问题吗
问题:逐字输出:偶现输出速度越来越快
每次 message 变化后,我们都启动了一个递归,40ms 后,增加一个字符。
当 message 变化频次加快,而上一次的递归并未结束,此时,会再次启动一个递归,也是每隔 40ms 增加一个字符。
如此一来,可能会出现 40ms 增加两个字符的情况,看起来就会速度越来越快。
这种情况,message 越长,返回的频次越高,就越容易复现。
所以我们每次执行前,都将之前的 timer 清除。如此,保证同一时间,永远只有一个递归。
小结:
这个项目,遇到哪些难题?
(1)sse 消息回调无法正确触发
(2)sse 鉴权问题,引申到 jwt, cookie-session 的讨论
(3)文字逐字展示后,速度越来越快的问题
这个项目,你都有哪些优化?
(1) 流式输出
(2) 闭包惰性建立连接
(3) 逐字丝滑动画实现