2024年2月

云计算 - 负载均衡SLB方案全解与实战,介绍SLB的核心技术、用户最佳实践、阿里云 SLB产品举例、应用场景。

关注【TechLeadCloud】,分享互联网架构、云服务技术的全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人。

file

一、引言

云计算作为现代信息技术的基石,正在以前所未有的速度推动着各行各业的数字化进程。其中,负载均衡(Server Load Balancer, SLB)技术是保证云服务高效、稳定运行的重要组成部分。它通过分配网络或应用流量到多个服务器,确保了服务的高可用性和高性能。在本篇引言中,我们将探讨云计算与负载均衡的关系以及SLB的重要性,并通过实际的例子来揭示其背后的技术原理。

云计算与负载均衡的关系

云计算提供了一种灵活、可扩展的服务运行环境,使得企业和开发者能够快速响应市场变化,优化资源配置。而负载均衡技术在其中起到了至关重要的作用。

例如,假设一个电子商务网站在黑色星期五这一天迎来了巨大的流量激增。如果没有负载均衡技术,单一的服务器可能会因为超负荷而崩溃,导致用户无法访问网站,从而造成严重的经济损失。而有了负载均衡技术,网络流量会被均匀分配到多个服务器上,确保每个服务器的负载都保持在一个可接受的范围内,从而保证了网站的正常运行和用户的访问体验。

SLB的重要性

SLB作为负载均衡技术在云计算环境中的具体实现,它不仅能够保证服务的高可用性,还能通过优化资源分配,提升服务的响应速度和处理能力。

以一个在线视频平台为例,平台需要保证无论用户数量多少,视频的播放都要流畅无卡顿。通过SLB,平台可以将用户的请求分配到不同的服务器上,确保每个服务器的负载都在可控范围内,从而为用户提供高质量的观看体验。同时,当某个服务器发生故障时,SLB能够自动将流量重新分配到其他健康的服务器上,保证了服务的持续可用。

通过以上两个实际的例子,我们可以看到负载均衡技术和SLB在云计算环境中的重要作用。它们为企业和开发者提供了强大的工具,以应对网络流量的波动和系统负载的变化,是实现高效、稳定云服务的关键。


二、SLB核心技术解析

Server Load Balancer (SLB) 是一种负载均衡技术,它在云计算环境中扮演着至关重要的角色。通过SLB,可以将网络流量和请求有效地分配到多个服务器上,从而保证了应用的高可用性和高性能。在本节中,我们将深入解析SLB的核心技术,包括负载均衡算法、会话保持技术以及健康检查。

2.1 负载均衡算法

负载均衡算法是SLB的核心,它决定了如何将流量分配到不同的服务器上。常见的负载均衡算法有轮询法、最少连接法和IP Hash法。

2.1.1 轮询法

轮询法是最简单也最直接的负载均衡算法,它将每个新的请求按照顺序分配到服务器列表中的服务器上。

例如,假设有三个服务器A、B和C,轮询法会依次将请求分配给A、B、C、A、B、C,如此循环。这种方法简单公平,但可能不适用于服务器性能不均的场景。

2.1.2 最少连接法

最少连接法是一种动态的负载均衡算法,它会将新的请求分配给当前连接数最少的服务器。

举例来说,假设在一个购物网站的高峰期,服务器A已经有30个连接,而服务器B只有10个连接,最少连接法会将新的请求分配给服务器B,从而尽量保持服务器之间的负载均衡。

2.1.3 IP Hash法

IP Hash法根据客户端的IP地址计算一个哈希值,然后根据哈希值将请求分配给特定的服务器。

这种方法能够保证来自同一IP的请求总是被分配到同一个服务器,有助于保持会话的一致性。例如,在一个在线游戏场景中,玩家的所有请求需要被发送到同一个服务器以保证游戏状态的一致。

2.2 会话保持技术

会话保持是负载均衡中的另一个重要概念,它能保证一个用户的多个请求被发送到同一个服务器。

2.2.1 Cookie保持

通过在HTTP响应中设置特定的Cookie,SLB可以识别来自同一用户的请求,并将它们路由到同一服务器。这对于保持用户登录状态和购物车信息等非常重要。

2.2.2 IP绑定

IP绑定是另一种会话保持技术,它根据用户的IP地址将所有请求路由到同一服务器。与IP Hash法类似,这种方法适用于需要保持会话状态的应用。

2.3 健康检查

健康检查是SLB中用于监控服务器状态的机制。通过定期检查服务器的响应,SLB能够判断服务器是否健康,从而确保流量只被路由到健康的服务器。

2.3.1 TCP健康检查

通过发送TCP探测包来检查服务器的可达性和响应时间,是判断服务器健康状态的一种简单有效的方法。

2.3.2 HTTP健康检查

HTTP健康检查则通过发送HTTP请求,并检查HTTP响应的状态码和内容,来判断服务器的健康状态。

例如,在一个在线订餐平台中,通过HTTP健康检查,SLB能够实时监控每个服务器的状态,一旦发现某个服务器的响应时间超过预设的阈值或返回错误码,SLB会将该服务器标记为不健康,从而避免用户请求被路由到出现问题的服务器,保证了服务的高可用性和用户体验。

通过以上深入的技术解析和实际例子,我们可以更清晰地理解SLB的核心技术和其在云计算环境中的重要作用。


三、SLB用户最佳实践

在实际的云计算环境中,有效地使用Server Load Balancer (SLB) 是确保应用高可用性和高性能的关键。本节将为读者展示一些SLB的用户最佳实践,包括SLB的配置和优化,以及如何应对常见的问题和挑战。

3.1 部署与配置SLB

部署和配置SLB是最基本也是最重要的步骤。正确的配置能确保流量得到有效分配,同时保证应用的稳定运行。

3.1.1 选择合适的负载均衡算法

不同的负载均衡算法适用于不同的场景。例如,对于请求处理时间相对固定的应用,轮询法可能是一个合适的选择。而对于处理时间波动较大的应用,最少连接法可能更为合适。

举例来说,在一个在线视频处理服务中,由于视频文件大小和编码复杂度的不同,处理时间可能会有很大的波动。在这种情况下,最少连接法能够保证新的请求更可能被分配到当前负载较低的服务器,从而实现更好的负载均衡。

3.1.2 设置合理的会话保持

会话保持对于需要保持用户状态的应用非常重要。通过配置合理的会话保持,可以确保用户的连续请求被发送到同一服务器,从而保持应用的状态一致。

例如,在一个在线购物平台中,用户的购物车信息需要在多个请求之间保持一致。通过使用Cookie保持或IP绑定,可以保证用户的所有请求都被路由到同一服务器,从而保持购物车状态的一致。

3.2 优化SLB性能

SLB的性能直接影响到应用的响应时间和用户体验。通过一些优化措施,可以进一步提升SLB的性能。

3.2.1 调优负载均衡算法

根据应用的实际负载和服务器性能,调整负载均衡算法的参数,以实现更好的负载均衡效果。

3.2.2 优化健康检查配置

合理的健康检查配置能够及时发现服务器的故障,同时避免对服务器造成额外的负担。例如,可以通过调整健康检查的间隔和超时时间,来平衡健康检查的准确性和资源消耗。

3.2.3 使用高效的会话保持机制

选择高效的会话保持机制,如使用Cookie保持而非IP绑定,可以减少服务器的负担,同时保证应用的状态一致。

3.3 处理常见问题

在实际使用SLB时,可能会遇到一些常见的问题和挑战,如服务器故障、流量激增等。通过一些预防和应对措施,可以减少这些问题对应用的影响。

例如,在遭遇流量激增时,可以通过预先扩展服务器资源,或使用自动扩展功能,来应对可能的服务器超负荷问题。同时,通过合理的流量控制和优先级设置,可以保证关键服务的可用性和性能。

通过以上的最佳实践,用户可以更有效地利用SLB,提升云应用的可用性和性能,同时应对实际运营中可能遇到的各种问题和挑战。


四、阿里云SLB多种规格举例

file
file


五、应用场景

file
file
file
file

关注【TechLeadCloud】,分享互联网架构、云服务技术的全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人。
如有帮助,请多关注
TeahLead KrisChang,10+年的互联网和人工智能从业经验,10年+技术和业务团队管理经验,同济软件工程本科,复旦工程管理硕士,阿里云认证云服务资深架构师,上亿营收AI产品业务负责人。

写在前面

最近大模型发展迅速,与之对应的向量化需求也被带动起来了,由此社区也衍生出很多模型,本文选几款,简单做下评测。

前置概念

为方便读者,先简单介绍几个概念。

概念1:Vector Embedding

也即向量化嵌入,举个例子:

想象一下,你是一位市场研究员,职责是分析消费者的购买行为,并为你的客户提供针对性的营销策略。在你的数据库中,有成千上万的消费者交易记录,每条记录都包含了消费者的个人信息、购买的商品、购买的时间和地点等信息。

在没有Vector Embedding的情况下,如果你想找出哪些消费者可能对新产品感兴趣,你可能需要手动查看每条交易记录,然后根据消费者的购买历史和商品的特点来进行判断。然而,这种方法可能忽略了消费者的其他重要特征,如他们的收入水平、兴趣爱好、生活方式等,导致分析的结果不够准确。

现在引入Vector Embedding,此时你就可以将每位消费者的个人信息和购买历史转化为一个多维的“消费者画像向量”。这个向量不仅包括了消费者的基本信息和购买历史,还包含了他们的收入水平、兴趣爱好、生活方式等各个方面的元素。换句话说,这些信息能反映出消费者的复杂特征。例如,一位经常购买高端护肤品的消费者画像向量,大概率与追求高品质生活的向量相近,而一位经常购买户外运动装备的消费者画像向量,大概率与追求健康生活方式和热爱自然的向量相近。此时,若想找出哪些消费者可能对新产品感兴趣,只需要计算出新产品的向量,并与大量消费者画像的向量进行对比,即可快速筛选出潜在的目标客户(目标客户的向量相似度高)。这个过程不再需要逐个查看消费者的个人信息和交易记录,大大简化了数据的处理过程。

更深入的概念可阅读附录[1]进行学习。

概念2:文档切分中的Chunk和Overlap

在处理较长文档或文本时,将其分割成若干小块,每块称为一个Chunk。在这个特定的切分策略中,每个Chunk由最多N个Token组成。Token通常指文本中的单词或符号,取决于具体语境。而Overlap是相邻Chunk之间共有的Token数。举个例子:

每Chunk 200 Token,Overlap 20。在这个例子中,每个Chunk由最多200个Token组成。Overlap为20,意味着相邻的Chunks会有20个Token是重复的,从而确保文本的连贯性。例如,如果某个文本段落共有230个Token,它将被分成两个Chunks:第一个Chunk将有200个Token,第二个Chunk将有30个Token(因为230-200=30),并且这两个Chunks之间将有20个Token重叠。

这种切分方式有助于确保在将长文档送入诸如大型语言模型进行Embedding或处理时,能够保持文本的语义连贯性,同时又能满足模型处理长度有限的输入的要求。切分文档时考虑Chunk的大小和Overlap的数量,对于提高模型处理效率和文本的语义理解都是十分重要的。

模型调研

与大模型类似,Embedding也是使用模型来实现的,只不过Embedding模型更为轻量。一般都在2G以内。

经调研(附录[6~10]),发现以下模型对中文的支持效果较好,且已经开源方便本地私有化部署:

可以看得出m3模型的优势是支持多语言,并且字符数扩展到了8192,这意味着BGE-M3能够高效地处理长篇幅的文档,满足对于长文档检索的需求。

以上几类模型的链接请参考附录[2~5],下文针对这几种模型进行效果评测。

模型部署

为了部署Embedding模型,我们需要引入对应的工具库,目前主要有几类:

  1. Sentence-Transformers
    : Sentence-Transformers库是基于HuggingFace的Transformers库构建的,它专门设计用于生成句子级别的嵌入。它引入了一些特定的模型和池化技术,使得生成的嵌入能够更好地捕捉句子的语义信息。Sentence-Transformers库特别适合于需要计算句子相似度、进行语义搜索和挖掘同义词等任务。

  2. HuggingFace Transformers
    : HuggingFace的Transformers库是一个广泛使用的NLP库,它提供了多种预训练模型,如BERT、GPT-2、RoBERTa等。这些模型可以应用于各种NLP任务,如文本分类、命名实体识别、问答系统等。Transformers库支持多种编程语言,并且支持模型的微调和自定义模型的创建。虽然Transformers库的功能强大,但它主要关注于模型的使用,而不是直接提供句子级别的嵌入。

  3. Langchain集成
    的HuggingFaceBgeEmbeddings。与3一样。

  4. FlagEmbedding:
    这是一个相对较新的库,其核心在于能够将任意文本映射到低维稠密向量空间,以便于后续的检索、分类、聚类或语义匹配等任务。FlagEmbedding的一大特色是它可以支持为大模型调用外部知识,这意味着它不仅可以处理纯文本数据,还能整合其他类型的信息源,如知识图谱等,以提供更丰富的语义表示。

总的来说,FlagEmbedding强调的是稠密向量的生成和外部知识的融合;HuggingFace Transformers提供了一个广泛的预训练模型集合,适用于多种NLP任务;而Sentence-Transformers则专注于生成高质量的句子嵌入,适合那些需要深入理解句子语义的应用场景。

结合上述说明,以及翻阅网上各类文章,发现使用 Sentence-Transformers 居多,因此本文选用它。

安装 sentence-transformers(在Linux机器安装吧,Windows机器各种报错):

pip install -U sentence-transformers

基于Sentence-Transformers的向量化方法:

from sentence_transformers import SentenceTransformer
sentences_1 = ["样例数据-1", "样例数据-2"]
sentences_2 = ["样例数据-3", "样例数据-4"]
model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
embeddings_1 = model.encode(sentences_1, normalize_embeddings=True)
embeddings_2 = model.encode(sentences_2, normalize_embeddings=True)
similarity = embeddings_1 @ embeddings_2.T
print(similarity)

上面的例子演示了句子的向量化过程。额外解释一下倒数第二行:

embeddings_1 @ embeddings_2.T
是 Python 中的一种矩阵乘法运算。这里使用了
@
符号来表示矩阵的点积(dot product)操作。具体来说:

  • embeddings_1
    是一个二维数组(或称为矩阵),其中每一行代表一个句子在向量空间中的嵌入表示。

  • embeddings_2
    也是一个二维数组,其结构与
    embeddings_1
    相同,但包含不同的句子的嵌入表示。

  • embeddings_2.T

    embeddings_2
    的转置,这意味着将它的行列互换。这样,原来的每行变成了每列,原来的每列变成了每行。

当执行
embeddings_1 @ embeddings_2.T
时,Python 会计算两个矩阵的点积。结果是一个新的二维数组,其中的每个元素是
embeddings_1
中的一行和
embeddings_2.T
中对应列的乘积之和。在这个上下文中,它实际上是在计算两组句子嵌入之间的相似度矩阵。

例如,如果
embeddings_1

embeddings_2.T
分别是以下的矩阵:

embeddings_1: [ [e11, e12, e13], [e21, e22, e23] ]
embeddings_2.T: [ [f11, f21], [f12, f22], [f13, f23] ]

那么点积的结果将是:

similarity: [ [e11*f11 + e12*f12 + e13*f13, e11*f21 + e12*f22 + e13*f23],
              [e21*f11 + e22*f12 + e23*f13, e21*f21 + e22*f22 + e23*f23] ]

这个结果矩阵中的每个元素代表了原始句子对之间的某种形式的相似度分数。

有了工具集后,把模型文件下载到本地并加载即可。

评测方法

到网上随便找一篇文章:

据传,菜煎饼起源于13世纪中期,当时明军与元军在峄州展开激战,当地人民死伤惨重。后来,从山西洪洞一带移民至此的民众,仅靠官府发放的半斤粮食无法充饥,便将五谷掺水,用石磨研磨成浆糊,放在铁板上,用竹片摊成“薄纸”,并大量包装蔬菜、野菜、草根和树叶,以此充饥。

菜煎饼是山东鲁南地区的一种大众食品,制作原料主要有面粉、杂粮、鸡蛋等,老少兼宜,俗称“中国热狗”,流行于枣庄、济宁、临沂、徐州等鲁南地,后传布周围省市。上个世纪七十年代,枣庄农村的生活还是很匮乏的,老百姓的主食以煎饼为主,煎饼的主要原料是地瓜干,条件好一点的可稍放点小麦,刚烙煎饼时鏊子凉,需把鏊子烧热擦些油才容易把煎饼从鏊子上揭下来,这样烙出的煎饼就很厚,稍等一会儿,煎饼凉了又板又硬,很难下咽。因此我们枣庄人把烙煎饼时前几张和后几张煎饼称为滑鏊子煎饼或滑塌子。这样的煎饼很难下咽,但丢了又可惜,精明的母亲们就将大白菜,土豆丝,粉条,豆腐切碎加点猪油,放上辣椒面,花椒面和盐,做成了所谓的菜煎饼,这样一来不但滑鏊子煎饼解决了,并且做出的煎饼还特别好吃,这样一传十、十传百,于是菜煎饼就在农村各家各户传开了!

八十年代末期,农村土地实行了联产承包责任制已有多年,农民在农忙季节忙耕种,农闲时便有了剩余时间,有的农村妇女就到街上摆地摊卖菜煎饼挣点零花钱。一辆三轮车,一盘小饼鍪,一个蜂窝炉,一个切菜板,几样时令蔬菜,食客现场点菜,业主现场烙制,简简单单的营生,成为枣庄街头一道风景。许许多多的农村人多了一个贴补家用的挣钱机会,人们生活也多了一道风味小吃。到了九十年代末期,就连一些男人也走上了街头卖起了菜煎饼。

1993年5月,山东省劳动厅在枣庄举办特级厨师培训班,聘请江苏省淮安商业技工学校一行6人赴枣庄讲学,这六人当中有校领导、高级讲师、特级厨师,途中经台儿庄区招待所午餐,席上菜肴丰盛,但惟有“菜煎饼”被其六人齐呼:“天下第一美食”。

山东菜煎饼如何做呢?首先要热锅,放油(油要多),下豆腐中火翻炒至金黄,放入之前切好的粉条,继续翻炒几分钟,加入适量的盐。再放入切好的韭菜,翻炒几下搅匀即可(千万不可炒过了,韭菜要生生的),撒味精出锅。将煎饼摊开,用勺子舀上适量的韭菜馅儿,用勺背整匀。好了之后,可以将两边向中间折叠形成长方形,一张煎饼就做好了。

按照前文的样例,写一段脚本进行评测(为方便演示,简单地根据段落进行拆分):

import sys
import torch
from sentence_transformers import SentenceTransformer

# 加载预训练的句子嵌入模型
model = SentenceTransformer(sys.argv[1])
# 定义句子列表
sentences_1 = ["据传,菜煎饼起源于13世纪中期,当时明军与元军在峄州展开激战,当地人民死伤惨重。后来,从山西洪洞一带移民至此的民众,仅靠官府发放的半斤粮食无法充饥,便将五谷掺水,用石磨研磨成浆糊,放在铁板上,用竹片摊成“薄纸”,并大量包装蔬菜、野菜、草根和树叶,以此充饥。"]
sentences_2 = ["菜煎饼是山东鲁南地区的一种大众食品,制作原料主要有面粉、杂粮、鸡蛋等,老少兼宜,俗称“中国热狗”,流行于枣庄、济宁、临沂、徐州等鲁南地,后传布周围省市。上个世纪七十年代,枣庄农村的生活还是很匮乏的,老百姓的主食以煎饼为主,煎饼的主要原料是地瓜干,条件好一点的可稍放点小麦,刚烙煎饼时鏊子凉,需把鏊子烧热擦些油才容易把煎饼从鏊子上揭下来,这样烙出的煎饼就很厚,稍等一会儿,煎饼凉了又板又硬,很难下咽。因此我们枣庄人把烙煎饼时前几张和后几张煎饼称为滑鏊子煎饼或滑塌子。这样的煎饼很难下咽,但丢了又可惜,精明的母亲们就将大白菜,土豆丝,粉条,豆腐切碎加点猪油,放上辣椒面,花椒面和盐,做成了所谓的菜煎饼,这样一来不但滑鏊子煎饼解决了,并且做出的煎饼还特别好吃,这样一传十、十传百,于是菜煎饼就在农村各家各户传开了!"]
sentences_3 = ["八十年代末期,农村土地实行了联产承包责任制已有多年,农民在农忙季节忙耕种,农闲时便有了剩余时间,有的农村妇女就到街上摆地摊卖菜煎饼挣点零花钱。一辆三轮车,一盘小饼鍪,一个蜂窝炉,一个切菜板,几样时令蔬菜,食客现场点菜,业主现场烙制,简简单单的营生,成为枣庄街头一道风景。许许多多的农村人多了一个贴补家用的挣钱机会,人们生活也多了一道风味小吃。到了九十年代末期,就连一些男人也走上了街头卖起了菜煎饼。"]
sentences_4 = ["1993年5月,山东省劳动厅在枣庄举办特级厨师培训班,聘请江苏省淮安商业技工学校一行6人赴枣庄讲学,这六人当中有校领导、高级讲师、特级厨师,途中经台儿庄区招待所午餐,席上菜肴丰盛,但惟有“菜煎饼”被其六人齐呼:“天下第一美食”。"]
sentences_5 = ["山东菜煎饼如何做呢?首先要热锅,放油(油要多),下豆腐中火翻炒至金黄,放入之前切好的粉条,继续翻炒几分钟,加入适量的盐。再放入切好的韭菜,翻炒几下搅匀即可(千万不可炒过了,韭菜要生生的),撒味精出锅。将煎饼摊开,用勺子舀上适量的韭菜馅儿,用勺背整匀。好了之后,可以将两边向中间折叠形成长方形,一张煎饼就做好了。"]
# 获取句子的嵌入向量表示、
sentences_embeddings_1 = torch.from_numpy(model.encode(sentences_1, normalize_embeddings=True))
sentences_embeddings_2 = torch.from_numpy(model.encode(sentences_2, normalize_embeddings=True))
sentences_embeddings_3 = torch.from_numpy(model.encode(sentences_3, normalize_embeddings=True))
sentences_embeddings_4 = torch.from_numpy(model.encode(sentences_4, normalize_embeddings=True))
sentences_embeddings_5 = torch.from_numpy(model.encode(sentences_5, normalize_embeddings=True))
# 合并所有的句子嵌入表示
all_sentences_embeddings = torch.cat([sentences_embeddings_1, sentences_embeddings_2, sentences_embeddings_3, sentences_embeddings_4, sentences_embeddings_5], dim=0)

# 定义查询句子
queries_1 = ["菜煎饼的制作原料有哪些?"]
queries_2 = ["菜煎饼的组成是什么?"]
queries_3 = ["做菜煎饼需要什么?"]
# 获取查询句子的嵌入向量表示
queries_embeddings_1 = torch.from_numpy(model.encode(queries_1, normalize_embeddings=True))
queries_embeddings_2 = torch.from_numpy(model.encode(queries_2, normalize_embeddings=True))
queries_embeddings_3 = torch.from_numpy(model.encode(queries_3, normalize_embeddings=True))

# 计算查询句子与所有句子的相似度
similarity_queries_1_sentences = queries_embeddings_1 @ all_sentences_embeddings.T
similarity_queries_2_sentences = queries_embeddings_2 @ all_sentences_embeddings.T
similarity_queries_3_sentences = queries_embeddings_3 @ all_sentences_embeddings.T

# 打印numpy size
print("sentences_vector dimension:", sentences_embeddings_1.size())
print("sentences_vector dimension:", queries_embeddings_1.size())
# 打印相似度结果(几个问题都是在问制作原料,从字面来看,我们预期三个查询与sentences_2 和 sentences_5 的相似度较高)
print("Query 1 Similarity:", similarity_queries_1_sentences)
print("Query 2 Similarity:", similarity_queries_2_sentences)
print("Query 3 Similarity:", similarity_queries_3_sentences)

执行时,把模型本地路径作为第一个参数传入即可,如:

time python test.py ./bge-large-zh-v1.5
time python test.py ./bge-m3
time python test.py ./m3e-base
time python test.py ./tao-8k

其中的time是为了测试程序运行耗时。

评测结果

作者在本机CPU执行,机器配置为:4核CPU(i7)、16GB内存。

评测结果如下:

看起来,bge-m3效果最好,tao-8k也不错。但需要注意的是,这两个模型执行耗时也最高。

总结

本文选用常见的几类中文友好的开源Embedding模型进行了简单效果评测,发现bge-m3和tao-8k的效果不错。有条件的读者可以将其部署在GPU上进行评测,应该会更快。另外,也可以使用更为全面的数据集进行评估,以得出更为权威的结论。

在实际的生产环境中,还要进行压力测试,以评估文档向量化的性能。

最后,欢迎关注微信公众号xiaoxi666,一起交流一起玩儿~

附录

[1] 向量数据库: https://guangzhengli.com/blog/zh/vector-database/

[2] bge-large-zh-v1.5: https://hf-mirror.com/BAAI/bge-large-zh-v1.5

[3] bge-m3: https://hf-mirror.com/BAAI/bge-m3

[4] m3e-base: https://hf-mirror.com/moka-ai/m3e-base

[5] tao-8k: https://hf-mirror.com/amu/tao-8k

[6] 智源开源最强语义向量模型BGE: https://zhuanlan.zhihu.com/p/648448793

[7] 新一代通用向量模型BGE-M3: https://zhuanlan.zhihu.com/p/680537154

[8] 实战对比OpenAI、BGE-Large以及阿里Embedding模型效果: https://zhuanlan.zhihu.com/p/658775304​

[9] Embedding模型的选择: https://zhuanlan.zhihu.com/p/673483110

[10] 中文Embedding模型优劣数据评测: https://zhuanlan.zhihu.com/p/679166797

目录:

  1. OpenID 与 OAuth2 基础知识
  2. Blazor wasm Google 登录
  3. Blazor wasm Gitee 码云登录
  4. Blazor OIDC 单点登录授权实例1-建立和配置IDS身份验证服务
  5. Blazor OIDC 单点登录授权实例2-登录信息组件wasm
  6. Blazor OIDC 单点登录授权实例3-服务端管理组件
  7. Blazor OIDC 单点登录授权实例4 - 部署服务端/独立WASM端授权
  8. Blazor OIDC 单点登录授权实例5 - 独立SSR App (net8 webapp)端授权
  9. Blazor OIDC 单点登录授权实例6 - Winform 端授权
  10. Blazor OIDC 单点登录授权实例7 - Blazor hybird app 端授权

(目录暂时不更新,跟随合集标题往下走)

源码

BlazorSSRAppOIDC

十分钟搞定单点登录

单点登录(SSO)简化了用户体验,使用户能够在访问多个应用时只需一次登录。这提高了用户满意度,减少了密码遗忘的风险,同时增强了安全性。但是,实现单点登录并不容易,需要应用程序实现和认证服务器的交互逻辑,增加了应用程序的开发工作量。例子中的安全策略中提供了 OpenID Connect (OIDC) 的能力,无需对应用做过多的修改,在十分钟内即可立刻实现单点登录。

当采用单点登录之后,用户只需要登录一次,就可以访问多个应用系统。SSO 通常由一个独立的身份管理系统来完成,该系统为每个用户分配一个全局唯一的标识,用户在登录时,只需要提供一次身份认证,就可以访问所有的应用系统。我们在使用一些网站时,经常会看到“使用微信登录”、“使用 Google 账户登录”等按钮,这些网站就是通过 SSO 来实现的。

采用单点登录有以下几个好处:

用户只需要登录一次,就可以访问多个应用系统,不需要为每个应用系统都单独登录。
应用系统不需要自己实现用户认证,只需将认证工作交给单点登录系统,可以大大减少应用系统的开发工作量。

使用 OIDC 单点登录, 可以简化客户端编写流程, 专注于功能实现而不用重复撰写登录部分功能代码, 也不用直接接触身份验证数据库, 剥离繁琐的重复劳动部分.

建立 net8 webapp ssr 工程

引用以下库

    <ItemGroup>
        <PackageReference Include="BootstrapBlazor" Version="8.*" />
        <PackageReference Include="Densen.Extensions.BootstrapBlazor" Version="8.*" />
        <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.*" />
        <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.*" />
        <PackageReference Include="Microsoft.Extensions.Http" Version="8.*" />
    </ItemGroup>

_Imports.razor 加入引用

@using BootstrapBlazor.Components
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization

App.razor 加入必须的UI库引用代码

完整文件

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="/" />
    <Link Href="_content/BootstrapBlazor.FontAwesome/css/font-awesome.min.css" />
    <Link Href="_content/BootstrapBlazor/css/bootstrap.blazor.bundle.min.css" />
    <Link Href="_content/BootstrapBlazor/css/motronic.min.css" />
    <link rel="stylesheet" href="app.css" />
    <link rel="stylesheet" href="BlazorSSRAppOIDC.styles.css" />
    <HeadOutlet @rendermode="new InteractiveServerRenderMode(false)" />
</head>

<body>
    <Routes @rendermode="new InteractiveServerRenderMode(false)" />
    <ReconnectorOutlet ReconnectInterval="5000" @rendermode="new InteractiveServerRenderMode(false)" />
    <Script Src="_content/BootstrapBlazor/js/bootstrap.blazor.bundle.min.js"></Script>
    <script src="_framework/blazor.web.js"></script>
</body>

</html>

Routes.razor 加入授权

完整代码

<Router AppAssembly="typeof(Program).Assembly">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
            <NotAuthorized>
                <p role="alert">您无权访问该资源.</p>
            </NotAuthorized>
            <Authorizing>
                <p>正在验证您的身份...</p>
            </Authorizing>
        </AuthorizeRouteView>
    </Found>
</Router>

添加Oidc授权配置

新建 OidcProfile.cs 文件

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Security.Claims;

namespace OidcClientShared;

public class OidcProfile
{ 

    public static void OidcDIY(OpenIdConnectOptions options)
    {
        var authority = "https://ids2.app1.es/"; //由于时间的关系,已经部署有一个实际站点, 大家也可以参考往期文章使用本机服务器测试
        //authority = "https://localhost:5001/"; 
        var clientId = "Blazor5002";
        var callbackEndPoint = "http://localhost:5002";

        options.Authority = authority;
        options.ClientId = clientId;
        options.ResponseType = OpenIdConnectResponseType.Code;
        options.ResponseMode = OpenIdConnectResponseMode.Query;

        options.SignedOutRedirectUri = callbackEndPoint;
        options.CallbackPath = "/authentication/login-callback";
        options.SignedOutCallbackPath = "/authentication/logout-callback";
        options.Scope.Add("BlazorWasmIdentity.ServerAPI openid profile");

        options.GetClaimsFromUserInfoEndpoint = true;
        options.SaveTokens = true;
        options.MapInboundClaims = false;
        options.ClaimActions.MapAll();
        options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
        options.ClaimActions.MapJsonKey(ClaimValueTypes.Email, "email", ClaimValueTypes.Email);
        options.ClaimActions.MapJsonKey(ClaimTypes.Role, "role");

        options.Events = new OpenIdConnectEvents
        {
            OnAccessDenied = context =>
            {
                context.HandleResponse();
                context.Response.Redirect("/");
                return Task.CompletedTask;
            },

            OnTokenValidated = context =>
            {
                var token = context.TokenEndpointResponse?.AccessToken;
                if (!string.IsNullOrEmpty(token))
                {
                    if (context.Principal?.Identity != null)
                    {
                        var identity = context.Principal!.Identity as ClaimsIdentity;
                        identity!.AddClaim(new Claim("AccessToken", token)); 
                    }

                }

                return Task.CompletedTask;
            }

        };

    }

}

Program.cs 加入授权相关

其中要加入Razor的cshtml支持, 因为登录要依靠管道跳转. 上下有两行都注释在文件内了.

完整代码

using BlazorSSRAppOIDC.Components;
using OidcClientShared;

var builder = WebApplication.CreateBuilder(args);

//在具有 Blazor Web 应用程序模板的 .NET 8 中,需要将其更改为, 由于该Pages文件夹已移至该Components文件夹中,因此您需要指定新位置的根目录,或将该Pages文件夹移回项目的根级别
builder.Services.AddRazorPages().WithRazorPagesRoot("/Components/Pages");
// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpClient();
builder.Services.AddDensenExtensions();
builder.Services.ConfigureJsonLocalizationOptions(op =>
{
    // 忽略文化信息丢失日志
    op.IgnoreLocalizerMissing = true;

});

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = "Cookies";
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie("Cookies")
    .AddOpenIdConnect("oidc", OidcProfile.OidcDIY); 

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
//但出于某种原因,这还不够。在 Blazor Web 应用程序模板中,您明确需要调用
app.MapRazorPages();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

Pages 文件夹新建登录Razor页实现登录和注销跳转

展开 Login.cshtml 文件组合三角箭头, 编辑 Login.cshtml.cs

Login.cshtml.cs

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace PersonalToolKit.Server.Components.Pages;

public class LoginModel : PageModel
{
    public async Task OnGet(string redirectUri)
    {
        await HttpContext.ChallengeAsync("oidc", new AuthenticationProperties { RedirectUri = redirectUri });
    }

}

Logout.cshtml.cs

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace PersonalToolKit.Server.Components.Pages;

public class LogoutModel : PageModel
{
    public async Task OnGet(string redirectUri)
    {
        await HttpContext.SignOutAsync("Cookies");
        await HttpContext.SignOutAsync("oidc", new AuthenticationProperties { RedirectUri = redirectUri });
    }

}

Routes.razor 加入授权

完整代码

Home.razor

完整代码

@page "/"
@using System.Security.Claims
@inject NavigationManager Navigation

<PageTitle>Home</PageTitle>

 
<AuthorizeView>
    <Authorized>

        你好, @context.User.Identity?.Name (@context.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Role)?.Value)
       
        <Button Text="注销" OnClick="BeginLogOut" />

        <br /><br /><br />
        <h5>以下是用户的声明</h5><br />

        @foreach (var claim in context.User.Claims)
        {
            <p>@claim.Type: @claim.Value</p>
        }


    </Authorized>
    <NotAuthorized>

        <Button Text="登录" OnClick="BeginLogIn" />

        <p>默认账号 test@test.com 密码 0</p>

    </NotAuthorized>

</AuthorizeView>

@code {
    private string LoginUrl = "login?redirectUri=";
    private void BeginLogIn()
    {
        var returnUrl = Uri.EscapeDataString(Navigation.Uri);
        Navigation.NavigateTo(LoginUrl + returnUrl, forceLoad: true);
    }

    private string LogoutUrl = "logout?redirectUri=";
    private void BeginLogOut()
    {
        var returnUrl = Uri.EscapeDataString(Navigation.Uri);
        Navigation.NavigateTo(LogoutUrl + returnUrl, forceLoad: true);
    }

}

运行

原文 | Máňa,Natalia Kondratyeva

翻译 | 郑子铭

简化的 SocketsHttpHandler 配置

.NET 8 添加了更方便、更流畅的方式来使用 SocketsHttpHandler 作为 HttpClientFactory 中的主处理程序 (
dotnet/runtime#84075
)。

您可以使用
UseSocketsHttpHandler
方法设置和配置 SocketsHttpHandler。您可以使用 IConfiguration 从配置文件设置 SocketsHttpHandler 属性,也可以从代码中配置它,或者可以结合使用这两种方法。

请注意,将 IConfiguration 应用于 SocketsHttpHandler 时,仅解析 bool、int、Enum 或 TimeSpan 类型的 SocketsHttpHandler 属性。 IConfiguration 中所有不匹配的属性都将被忽略。配置仅在注册时解析一次并且不会重新加载,因此处理程序在应用程序重新启动之前不会反映任何配置文件更改。

// sets up properties on the handler directly
services.AddHttpClient("foo")
    .UseSocketsHttpHandler((h, _) => h.UseCookies = false);

// uses a builder to combine approaches
services.AddHttpClient("bar")
    .UseSocketsHttpHandler(b =>
        b.Configure(config.GetSection($"HttpClient:bar")) // loads simple properties from config
         .Configure((h, _) => // sets up SslOptions in code
         {
            h.SslOptions.RemoteCertificateValidationCallback = delegate { return true; };
         });
    );
{
  "HttpClient": {
    "bar": {
      "AllowAutoRedirect": true,
      "UseCookies": false,
      "ConnectTimeout": "00:00:05"
    }
  }
}

QUIC

OpenSSL 3 支持

当前大多数 Linux 发行版在其最新版本中都采用了 OpenSSL 3:

.NET 8 的 QUIC 支持已为此做好准备 (
dotnet/runtime#81801
)。

实现这一目标的第一步是确保 System.Net.Quic 下使用的 QUIC 实现
MsQuic
可以与 OpenSSL 3+ 一起使用。这项工作发生在 MsQuic 存储库
microsoft/msquic#2039
中。下一步是确保 libmsquic 包的构建和发布具有对特定发行版和版本的默认 OpenSSL 版本的相应依赖性。例如 Debian 发行版:

最后一步是确保正在测试正确版本的 MsQuic 和 OpenSSL,并且测试覆盖了所有 .NET 支持的发行版。

例外情况

在 .NET 7 中发布 QUIC API(作为
预览功能
)后,我们收到了几个有关异常的问题:

在 .NET 8 中,System.Net.Quic 异常行为在
dotnet/runtime#82262
中进行了彻底修改,并解决了上述问题。

修订的主要目标之一是确保 System.Net.Quic 中的异常行为在整个命名空间中尽可能一致。总的来说,当前的行为可以总结如下:

  • QuicException:特定于 QUIC 协议或与其处理相关的所有错误。
    • 连接在本地或由对等方关闭。
    • 连接因不活动而闲置。
    • 流在本地或由对等方中止。
    • QuicError
      中描述的其他错误
  • SocketException:用于网络问题,例如网络状况、名称解析或用户错误。
    • 地址已被使用。
    • 无法到达目标主机。
    • 指定的地址无效。
    • 无法解析主机名。
  • AuthenticationException:适用于所有 TLS 相关问题。目标是具有与
    SslStream
    类似的行为。
    • 证书相关错误。
    • ALPN 协商错误。
    • 握手期间用户取消。
  • ArgumentException:当提供的
    QuicConnectionOptions

    QuicListenerOptions
    无效时。
  • OperationCanceledException:每当
    CancellationToken
    被触发取消时。
  • ObjectDisposeException:每当在已释放的对象上调用方法时。

请注意,上述示例并不详尽。

除了改变行为之外,
QuicException
也发生了改变。其中一项更改是调整
QuicError
枚举值。现在 SocketException 涵盖的项目已被删除,并添加了用户回调错误的新值 (
dotnet/runtime#87259
)。新添加的 CallbackError 用于区分
QuicListenerOptions.ConnectionOptionsCallback
引发的异常与 System.Net.Quic 引发的异常 (
dotnet/runtime#88614
)。因此,如果用户代码抛出 ArgumentException,
QuicListener.AcceptConnectionAsync
会将其包装在
QuicException
中,并将
QuicError
设置为 CallbackError,并且内部异常将包含原始用户抛出的异常。它可以这样使用:

await using var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{
    // ...
    ConnectionOptionsCallback = (con, hello, token) =>
    {
        if (blockedServers.Contains(hello.ServerName))
        {
            throw new ArgumentException($"Connection attempt from forbidden server: '{hello.ServerName}'.", nameof(hello));
        }

        return ValueTask.FromResult(new QuicServerConnectionOptions
        {
            // ...
        });
    },
});
// ...
try
{
    await listener.AcceptConnectionAsync();
}
catch (QuicException ex) when (ex.QuicError == QuicError.CallbackError && ex.InnerException is ArgumentException)
{
    Console.WriteLine($"Blocked connection attempt from forbidden server: {ex.InnerException.Message}");
}

异常空间的最后一个更改是将传输错误代码添加到 QuicException 中 (
dotnet/runtime#88550
)。传输错误代码由
RFC 9000 传输错误代码
定义,并且
MsQuic
的 System.Net.Quic 已经可以使用它们,只是没有公开公开。因此,QuicException 中添加了一个新的可为 null 的属性:
TransportErrorCode
。我们要感谢社区贡献者
AlexRach
,他在
dotnet/runtime#88614
中实现了这一更改。

Sockets

套接字空间中最有影响力的更改是显着减少无连接 (UDP) 套接字的分配 (
dotnet/runtime#30797
)。使用 UDP 套接字时,分配的最大贡献者之一是在每次调用
Socket.ReceiveFrom
时分配一个新的 EndPoint 对象(并支持 IPAddress 等分配)。为了缓解这个问题,引入了一组使用
SocketAddress
的新 API (
dotnet/runtime#87397
)。 SocketAddress 在内部将 IP 地址保存为平台相关形式的字节数组,以便可以将其直接传递给操作系统调用。因此,在调用本机套接字函数之前不需要复制 IP 地址数据。

此外,新添加的
ReceiveFrom

ReceiveFromAsync
重载不会实例化每次调用时都会有一个新的 IPEndPoint,而是在适当的位置改变提供的 receiveAddress 参数。所有这些一起可以用来提高 UDP 套接字代码的效率:

// Same initialization code as before, no change here.
Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
byte[] message = Encoding.UTF8.GetBytes("Hello world!");
byte[] buffer = new byte[1024];
IPEndPoint endpoint = new IPEndPoint(IPAddress.Loopback, 12345);
server.Bind(endpoint);

// --------
// Original code that would allocate IPEndPoint for each ReceiveFromAsync:
Task<SocketReceiveFromResult> receiveTaskOrig = server.ReceiveFromAsync(buffer, SocketFlags.None, endpoint);
await client.SendToAsync(message, SocketFlags.None, endpoint);
SocketReceiveFromResult resultOrig = await receiveTaskOrig;

Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, result.ReceivedBytes) + " from " + result.RemoteEndPoint);
// Prints:
// Hello world! from 127.0.0.1:59769

// --------
// New variables that can be re-used for subsequent calls:
SocketAddress receivedAddress = endpoint.Serialize();
SocketAddress targetAddress = endpoint.Serialize();

// New code that will mutate provided SocketAddress for each ReceiveFromAsync:
ValueTask<int> receiveTaskNew = server.ReceiveFromAsync(buffer, SocketFlags.None, receivedAddress);
await client.SendToAsync(message, SocketFlags.None, targetAddress);
var length = await receiveTaskNew;

Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, length) + " from " + receivedAddress);
// Prints:
// Hello world! from InterNetwork:16:{233,121,127,0,0,1,0,0,0,0,0,0,0,0}

最重要的是,在
dotnet/runtime#86872
中改进了 SocketAddress 的使用。 SocketAddress 现在有几个额外的成员,使其本身更有用:

  • getter
    Buffer
    :访问整个底层地址缓冲区。
  • setter
    Size
    :能够调整上述缓冲区大小(只能调整到较小的大小)。
  • static
    GetMaximumAddressSize
    :根据地址类型获取必要的缓冲区大小。
  • 接口
    IEquatable

    :SocketAddress 可用于区分套接字与之通信的对等点,例如作为字典中的键(这不是新功能,它只是使其可通过接口调用)。

最后,删除了一些内部制作的 IP 地址数据副本,以提高性能。

网络原语

MIME 类型

添加缺失的 MIME 类型是网络空间中投票最多的问题之一 (
dotnet/runtime#1489
)。这是一个主要由社区驱动的更改,导致了
dotnet/runtime#85807
API 提案。由于此添加需要经过 API 审核流程,因此有必要确保添加的类型是相关的并遵循规范(
IANA 媒体类型
)。对于这项准备工作,我们要感谢社区贡献者
Bilal-io

mmarinchenko

IP网络

.NET 8 中添加的另一个新 API 是新类型 IPNetwork (
dotnet/runtime#79946
)。该结构允许指定
RFC 4632
中定义的无类 IP 子网。例如:

  • 127.0.0.0/8 用于对应于 A 类子网的无类定义。
  • 42.42.128.0/17 用于 215 个地址的无类别子网。
  • 2a01:110:8012::/100 用于 228 个地址的 IPv6 子网。

新的 API 可以使用构造函数从 IP 地址和前缀长度进行
构造
,也可以通过
TryParse

Parse
从字符串进行解析。最重要的是,它允许使用
Contains
方法检查 IP 地址是否属于子网。示例用法如下:

// IPv4 with manual construction.
IPNetwork ipNet = new IPNetwork(new IPAddress(new byte[] { 127, 0, 0, 0 }), 8);
IPAddress ip1 = new IPAddress(new byte[] { 255, 0, 0, 1 });
IPAddress ip2 = new IPAddress(new byte[] { 127, 0, 0, 10 });
Console.WriteLine($"{ip1} {(ipNet.Contains(ip1) ? "belongs" : "doesn't belong")} to {ipNet}");
Console.WriteLine($"{ip2} {(ipNet.Contains(ip2) ? "belongs" : "doesn't belong")} to {ipNet}");
// Prints:
// 255.0.0.1 doesn't belong to 127.0.0.0/8
// 127.0.0.10 belongs to 127.0.0.0/8

// IPv6 with parsing.
IPNetwork ipNet = IPNetwork.Parse("2a01:110:8012::/96");
IPAddress ip1 = IPAddress.Parse("2a01:110:8012::1742:4244");
IPAddress ip2 = IPAddress.Parse("2a01:110:8012:1010:914e:2451:16ff:ffff");
Console.WriteLine($"{ip1} {(ipNet.Contains(ip1) ? "belongs" : "doesn't belong")} to {ipNet}");
Console.WriteLine($"{ip2} {(ipNet.Contains(ip2) ? "belongs" : "doesn't belong")} to {ipNet}");
// Prints:
// 2a01:110:8012::1742:4244 belongs to 2a01:110:8012::/96
// 2a01:110:8012:1010:914e:2451:16ff:ffff doesn't belong to 2a01:110:8012::/96

请注意,不应将此类型与自 1.0 以来 ASP.NET Core 中存在的
Microsoft.AspNetCore.HttpOverrides.IPNetwork
类混淆。我们预计 ASP.NET API 最终将迁移到新的 System.Net.IPNetwork 类型 (
dotnet/aspnetcore#46157
)。

最后的注释

本博文选择的主题并不是 .NET 8 中所做的所有更改的详尽列表,只是我们认为可能最有趣的主题。如果您对性能改进更感兴趣,您应该查看 Stephen 的大型性能博客文章中的
网络部分
。如果您有任何疑问或发现任何错误,您可以在
dotnet/runtime
存储库中与我们联系。

最后,我要感谢我的合著者:

原文链接

.NET 8 Networking Improvements

知识共享许可协议

本作品采用
知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议
进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接:
http://www.cnblogs.com/MingsonZheng/
),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 (
MingsonZheng@outlook.com
)

热点随笔:

·
5 款轻松上手的开源项目「GitHub 热点速览」
(
削微寒
)
·
一款后台管理框架的年终总结
(
胡尐睿丶
)
·
C#/.NET该如何自学入门?
(
追逐时光者
)
·
.NET Avalonia开源、免费的桌面UI库 - SukiUI
(
追逐时光者
)
·
WebAssembly核心编程[4]: Memory
(
Artech
)
·
基于C#的屏幕鼠标跟随圈圈应用 - 开源研究系列文章
(
lzhdim
)
·
阿里二面:SpringBoot可以同时处理多少个请求?当场懵了。。。。
(
码农Academy
)
·
亿级流量高并发春晚互动前端技术揭秘
(
京东云开发者
)
·
【译】VisualStudio 17.9预览3带来了令人兴奋的代码搜索改变
(
MeteorSeed
)
·
上周热点回顾(1.29-2.4)
(
博客园团队
)
·
完蛋,我被offer包围了|秋招自救指南
(
白泽talk
)
·
一行命令找出 Linux 中所有真实用户
(
咸鱼Linux运维
)

热点新闻:

·
香港最大AI诈骗案!Deepfake换脸「英国CFO」,直接骗走公司2亿港币
·
别嫌手机屏幕大了,真小屏手机来了我怕你把持不住
·
临近春节,91Act遣散全体员工、公司老板抵押房产发遣散费
·
除夕放假,大厂这次想明白了
·
大厂年终奖大比拼
·
暴雪、网易“复合”猜想
·
网易员工爆料:年前大裁员,年都过不好了!网友:公司挺会算的啊
·
华为手机:忍了五年等一个机会,失去的都要拿回来!
·
做这个网站的是天才,他让我在家疯狂做菜
·
合资车全逃离中国?其实他们真把中国当家了
·
AI破译2000年前「上古卷轴」登Nature头版!21岁计算机天才,谷歌华人工程师共获大奖
·
支付巨头宣布大裁员,IT很多部门被连锅端,赔偿N+6