2025年1月

前言

LangEngine内源项目发起于阿里巴巴集团内部组织,LangEngine是类似LLM应用开发框架LangChain的纯Java版本。该框架现已正式对外开源:
https://github.com/AIDC-AI/ali-langengine

作为AI应用搭建平台核心架构师,这段时间一直专注于阿里国际APaaS平台以及AI基础设施建设,LangEngine框架也开始结合国际复杂电商业务做一些更加落地性的场景落地,让LangEngine有了新的活力。

在LangEngine框架提供LLM应用所需的原子化能力的基础上,针对阿里国际复杂电商系统的业务流程和多智能体的大模型自主规划能力,构建出了一套更贴近业务诉求的AgentFramework。

在7月底正式上线第一版面向生产环境的高可用AI应用开发框架,目前在内部钉钉群里有1200+的使用方和开发者,内部社区贡献群有40+核心贡献者。

本篇文章是AIDC AI AppBuilder平台建设之路的上篇,包括如何构建高可用的LLM应用程序、开源的LLM应用开发框架介绍、自研的阿里LLM应用开发介绍等等。而下一篇将介绍它可落地的应用场景。

AICon大会阿里国际Agent应用平台分享

AICon2024.12北京站前线带来的
《AI 在电商出海领域的应用落地实践》
分享,其中介绍了阿里国际AI Agent应用平台产品架构在海外电商业务上的落地,由底层驱动的阿里巴巴LangEngine的Java原生AI应用开发框架:
https://github.com/AIDC-AI/ali-langengine
。开发者们可以通过该框架也可以快速搭建AI业务应用,或者基于该框架搭建属于自己的AI应用开发平台。

如何构建高可用的LLM应用程序

要开发LLM应用程序还是需要有工程方面的构建策略和方法论,这里总结出五大步骤。

1. 专注解决一个问题。

问题的定义需要足够详细,这样你就可以快速迭代并取得结果;足够广泛,这样解决方案才能让用户信服。例如,做一个电商领域的智能客服的LLM应用,需要拆解到它的业务类型,售前方向(例如商品咨询、营销活动等等)、售中方向(例如物流咨询、修改地址等等)、售后方向(例如商品退款、商品退换等等);知识库类型(语雀、PDF、钉钉文档等等),背后可以使用的工具(例如一键换货、智能退款分析、消费者情绪分析等等),是否需要走意图识别(固定话术、知识库检索、工具调用等等)。只有问题定义的足够详细,才能进一步展开LLM应用的构建。

2. 选择合适的LLM大模型。

使用预先训练的模型构建LLM应用程序可以节省成本。如果LLM应用程序需要对外商业化,就需要使用具有获得商业使用许可的开源大模型或者API大模型。大模型的参数越多,其学习新知识、预测能力就越强,而小模型的性能越好,推理越快,价格越便宜。还是以电商客服为例,意图识别选择大模型还是小模型,取决于它的预测能力,从经验上来看,这里更多会选择大参数的LLM作为选择。

3. 定制专属的大模型。

可以针对大模型进行微调训练(SFT)或者强化学习(RLHF)。例如多模态的图片识别能力,具体可以阅读《阿里国际AIDC-AI发布新型多模态大模型架构Ovis》

https://mp.weixin.qq.com/s/D509j5mnePn36jrl1LHcy

4. 设置应用程序的架构。

用户输入,包括用户界面 (UI)、大语言模型和应用托管平台。

输入增强和提示构建工具,涵盖了数据源、嵌入模型、向量数据库、提示构建与优化工具以及数据过滤器。

AI工具,包括大语言模型的缓存、内容分类器或过滤器,以及一个用于评估LLM应用输出的评测服务。

LLM推理、Agent相关能力建设,更高效地完成业务的流程编排。

5. 对应用进行在线评估。

这里包括 主观评测、客观评测、人工评测 等等。因此,构建一套LLM应用的评测平台至关重要。

可以看到整个构建LLM应用程序的过程中,不管从解决问题、LLM大模型选择、LLM应用程序架构设计、在线评估等等方面,构建一套高效且可靠的LLM应用开发框架是非常重要的,合适的框架架构能够使得你构建AI业务应用变得十分快捷,当然也可以结合自身的研发能力,通过选择开源的应用开发框架或者自研的方式来构建自己的AI业务应用。接下来来看下,在开源生态方面都有哪些常用的LLM应用开发框架。

开源的常用LLM应用开发框架

LangChain

开源(90k stars,3000+ 贡献者):

https://github.com/langchain-ai/langchain

✦ 开发语言:Python、NodeJS

✦ 生态建设

✧ 社区与贡献十分活跃,基本每天发布1个版本甚至2个版本,可维护性强

✧配套扩展丰富,包括langsmith、langflow、flowise、dify、gptcache、fastapi、langchain-chatglm等等都是基于langchain开源构建。

✧ 文档十分完善,学习门槛低(普通开发人员)

✦ 主要功能

✧ 数据感知,将语言模型与其他数据源相连接。支持大模型和搜索引擎,本地数据源,企业数据源等连接。

✧ 代理能力,允许语言模型与工程系统化能力互动,可以与个人/企业的API进行连接。

✧ 支持扩展不同的大型语言模型,强调模块级开箱即用。

✧ 各个核心模块扩展性强,利于生态快速建设。

Semantic-Kernel

开源(21k stars,300+ 贡献者):

https://github.com/microsoft/semantic-kernel

✦ 开发语言:C#、Python、Java(Required:OpenJDK 17 or newer)

✦ 生态建设

✧ 社区与贡献较活跃,2~3天发布1个版本

✧文档比较完善,学习门槛较低(应用开发人员)

✦ 主要功能

✧ 轻量级SDK,可帮助开发人员将代码组织到内置于Planner中的技能、记忆和连接器中。

✧ 微软配套产品集成性强,GitHub Copilot, M365 Copilot, D365 Copilot 和Security Copilot。

✧ 支持扩展不同的大型语言模型,强调生成式Prompt(Semantic Function),开发灵活。

✧ 软绝对是接入OpenAI最早的公司,企业工程化经验丰富。

Llamma-Index

开源(34k,1100+贡献者)

https://github.com/run-llama/llama_index

✦ 开发语言:Python

✦ 生态建设

✧ 社区与贡献较活跃,2~3天发布1个版本

✧ 文档比较少,学习门槛比较高

✦ 主要功能

✧ 专注于数据框架,包括丰富的数据源和数据格式的读取和转换,利用LLM来做结构化,非结构化做索引。

Auto-GPT

AutoGPT 使用深度神经网络生成高质量、类似人类的文本(以及指令),而无需人工输入或监督。这意味着它可用于自动执行范围广泛的任务,从编写产品说明和新闻文章到撰写电子邮件和聊天机器人回复,或者编写类似俄罗斯方块的程序。最好的(或最令人担忧的)消息是它设置起来很简单。

✦开源(166k stars,330个贡献者)

https://github.com/Significant-Gravitas/AutoGPT

✦ 开发语言:Python

✦ 生态建设

✧ 社区较活跃,迭代较慢,1~2周更新1个版本

✧ 文档比较完善,学习门槛低(普通开发人员)

✦ 主要功能

✧ 最早的AI智能体雏形之一

✧ 高度依赖CoT思维链模式,由GPT驱动,强调自主实现目标。

✧ 内置读写文件、浏览网页、审查提示结果、互联网访问、长期和短期内存管理,使用GPT-3.5文件存储和生成摘要等。

✧ 功能级开箱即用,但基本仅适用于GPT系列,扩展性较弱。

围绕这些条件进行(目标、约束、命令、资源、评估、返回格式)

阿里巴巴LangEngine框架

框架背景

在阿里从事工程开发的主要编程语言还是以Java为主,并且阿里巴巴集团自身就已经具备成熟的Java中间件优势和相关的运维工具体系,从架构师角度上看,针对于企业自身构建适合自己的框架,最后决定构建以Java为语言基础的LLM开发框架。另外一方面,为了解决框架能够服务于多语言,需要专门去学习了下LangChain Python版本的代码工程和技术架构,做好技术储备,迎接AI未来,通过编写应用框架来拥抱和学习AI技术,并且能够真正在业务场景去落地。

工作原理

LangEngine 是一个基于Java用于由大语言模型支持的AI应用程序的开发框架,它也是当下最流行框架 LangChain 的Java版本。

它的特点:

✦ 阿里体系下基于 LangChain 原理的AI应用框架

✦ 引入java特色的工程模块化思路,可支持日志记录、业务监控、链式编排,实现了类流程持久化

✦ 面向阿里系Java工程开发同学,易学易用

✦ 借鉴 LangChain 生态特点,支持社区生态共建

它使应用程序能够:

✦ 具有上下文感知能力:将语言模型连接到上下文来源(提示说明、Few shot示例、响应的内容等)

✦ 代理以及Reason:依靠语言模型进行推理(关于如何根据提供的上下文进行回答、采取什么操作等)

LangEngine的核心框架分为六大模块:Retrieval、Model I/O、Memory、Chains、Agents、Callbacks。

✦ Retrieval

许多LLM应用程序需要特定于用户的数据,这些数据不属于模型训练集的一部分。实现这一目标的主要方法是通过检索增强生成(RAG)。在此过程中,将检索外部数据,然后在执行生成步骤时将其传递给 LLM。

LangEngine 提供了 RAG 应用程序的所有构建模块 - 从简单到复杂。文档的这一部分涵盖了与检索步骤相关的所有内容 - 例如 数据的获取。虽然这听起来很简单,但实际上可能非常复杂。这包含几个关键模块。

✦ Model I/O

任何语言模型应用程序的核心元素都是模型。LangEngine 为您提供了与任何语言模型交互的构建块。

✦ Memory

大多数LLM应用程序都有对话界面。对话的一个重要组成部分是能够引用对话中先前介绍的信息。至少,对话系统应该能够直接访问过去消息的某些窗口。更复杂的系统需要有一个不断更新的世界模型,这使得它能够执行诸如维护有关实体及其关系的信息之类的事情。

将这种存储过去交互信息的能力称为“记忆”。LangEngine 提供了许多用于向系统添加内存的应用程序。这些应用程序可以单独使用,也可以无缝地合并到链中。

内存系统需要支持两个基本操作:读和写。回想一下,每个链都定义了一些需要某些输入的核心执行逻辑。其中一些输入直接来自用户,但其中一些输入可以来自内存。在给定的运行中,一条链将与其内存系统交互两次。

1. 在收到初始用户输入之后但在执行核心逻辑之前,链将从其内存系统中读取并增加用户输入。

2. 在执行核心逻辑之后但在返回答案之前,链会将当前运行的输入和输出写入内存,以便在将来的运行中引用它们。

✦ Chains

✦ Agents

代理的核心思想是使用语言模型来选择要采取的一系列操作。在链中,一系列操作被硬编码(在代码中)。在代理中,语言模型被用作推理引擎来确定要采取哪些操作以及按什么顺序。

✦ Callbacks

LangEngine 提供了一个回调系统,允许您连接到 LLM 申请的各个阶段。这对于日志记录、监控、流传输和其他任务非常有用。

您可以使用整个 API 中可用的回调参数来订阅这些事件。该参数是处理程序对象的列表,这些对象预计将实现下面更详细描述的一个或多个方法

✦ LangRunnable

可以轻松地从基本组件构建复杂的链条。它通过提供以下功能来实现此目的:

1. 统一的接口:每个 LangRunnable 对象都实现 Runnable 接口,该接口定义一组通用的调用方法(invoke、batch、stream、ainvoke 等)。这使得 LangRunnable 对象链也可以自动支持这些调用。也就是说,每个 LangRunnable 对象链本身就是一个 LangRunnable 对象。

2. 组合原语:LangRunnable 提供了许多原语,可以轻松组合链、并行化组件、添加后备、动态配置链内部等。

为了尽可能轻松地创建自定义链,这里实现了“Runnable”协议。大多数组件都实现了 Runnable 协议。这是一个标准接口,可以轻松定义自定义链并以标准方式调用它们。标准接口包括:

✧ invoke:在输入上调用chain

✧ stream:流回响应块

✧ batch:批量在输入上调用chain

这些也有相应的异步方法:

✧ invokeAsync:在输入上异步调用chain

✧ streamAsync:异步流回响应块

✧ batchAsync:批量在输入上异步调用chain。

服务集成

1.RAG服务

LangEngine Retrieval模块包含了丰富的RAG所需要的元素,让你能够基于LangEngine轻松构建RAG服务,检索增强生成(Retrieval-augmented Generation)。通过文档加载器,加载不同文档的格式类型,常用的包括PDF、Text、Excel、CSV、Markdown、Html等文件,也包括各类常见编程语言加载器,还包括在线网页加载器等等,另外在LangEngine提供了基于阿里钉钉、语雀、ODPS、TDDL等等阿里集团内部常用的加载器,最大限度的支持阿里内部的文档场景。文本分割器,实现了常用的文本分割器,包括递归词句分割的能力。接着就是Embeddings和VectorStore,除了常用的能力之外,也集成了包括通义千问的嵌入服务或者各个阿里云上各种的向量数据库服务。另外也提供常用的Query改写、Rerank服务等支持。通过Retriever、DocumentChain、OnlineSearch构建出RAG的向量检索以及在线检索服务。

2. 意图识别服务

优秀的大模型的意图识别固然重要,如果能够进一步增强意图识别能力也是锦上添花的事情。LangEngine Chain中也提供了各种路由链、顺序链、专家链等等可以进一步提升意图识别效果的增强。

3. 工具代理调用服务

LangEngine Agent模块中内置的各种类型的Agent范式类,可以构建常见的Agent服务方式,通过LangRunnable模块可以更加灵活的自定义自己的Agent范式编排,可以构建出类似于FunctionCall、Planner、ReAct等服务方式,通过这些能力可以进行工具的代理调用。

4. GPT服务

LangEngine的GPT模块中提供了众多GPT服务,例如NL2SQL、NL2Query、NL2API、NL2Prompt。通过SQLDataChain实现了NL2SQL服务,底层也集成了阿里集团的TDDL技术进行数据库查询的支持。NL2Query在关系型数据库以及向量库中来生成Query语句,包括Field、Filter、Order等等常用query语法。通过APIChain来实现利用OpenAPI协议的标准规范,生成指定的API协议,并且最终完成API请求。NL2Prompt来实现Prompt生成的可控性以及优化后Prompt的效果。

5. 多智能体基础服务

LangEngine也汲取业内比较好的多智能体框架,例如MetaGPT、AutoGen、AutoGPT等优秀的开源框架,重新实现了一套Java版本的多智能体框架,利用该框架可以快速构建属于自己业务属性的多智能体应用。

阿里巴巴AgentFramework框架

框架背景

阿里LangEngine提供了LLM应用开发中所需要各种原子化的能力,从官方角度上看,在做AI业务项目或者AI平台建设实践中,大多数时候都是基于这些原子化的能力进行组装和编排,例如既需要做可生产化的RAG服务,也需要做Agent工作流,还需要做智能体相关建设。所以,为了满足普通的AI业务述求,考虑开始构建这套阿里AgentFramework框架。AgentFramework框架的目标是把LangEngine构建好的服务,再进行进一步的组装,通过工作流以及智能体应用的方式组织起来。

工作原理

阿里AgentFramework框架是基于阿里LangEngine框架之上衍生出来真正面向AI业务和平台的Agent构建框架。

✦ Core模块:AgentFramework框架最核心的模块,负责整个流程引擎调度和基础Service SPI的串联,这里你可以选择自己任何的一款工作流框架作为AgentFramework的工作流引擎集成。

✦ Model模块:Agent领域模型,包括基础服务接口。建立以Role/Knowledge/Tool为基础的BuiltInAgentModel(含Bean内置类和 HSF-SPI动态自定义扩展方式)以及FlowAgentModel模型支持。

✦ Engine模块:OpenAPI Pipeline实现,通过该模块可以完成类似于API网关全生命周期的管控,例如访问控制、限流策略、黑白名单、参数转换、参数映射、服务调用、服务打点等常见的API网关功能。

✦ Bundle模块:AgentFramework的业务实现模块。包括大模型调用、知识库检索、工具调用等等服务集成实现。

✦ Storage模块:AgentFramework持久化层。可插拔的持久化模块,可针对不同环境扩展各自持久化能力。

✦ Parse模块:工作流前端UI DSL转换到BpmnXml解析器,这里采用了开源的ReactFlow技术作为工作流UI的基座。这个模块主要解决工作流中的FlowSchema转换为可执行的文件。

服务集成

1. AI Business算法工作台的容器化部署

AgentFramework框架整体可以跟随宿主应用Docker镜像打包进Serverless容器中心镜像拉取,通过容器提供了去中心化的API Gateway对接,从而实现AI应用是容器部署隔离。另外一方面,随着应用、工具、模型整体纳入到容器化服务以后,容器内部任务调度会实时观察模型的吞吐情况以及空闲时段,自动化进行相应的资源(含GPU)的切换。

2. SDK集成与私有化部署

作为AI Business为巴黎奥运会提供的智能解说助手,通过该产品解说员可以了解奥运比赛项目、历届比赛以及运动员等相关信息,在自由问答模块中,可以通过APP Framework SDK集成方式,让应用基于已集成的通用组件、模型服务和FLow编排能力,快速构建自己的AI chat功能。

阿里巴巴LangEngine框架开源地址:
https://github.com/AIDC-AI/ali-langengine

用例是最简单的UML元素,用例图是最简单的UML图,但它也可能是UML中最有用的元素之一。尽管我们用包将工作分解为工作包、团队任务或单项任务,也就是说包是组织UML中的各种图及元素的工具。但是用例图可以帮助我们确定任务,以及应当如何将它们分组并确定工作范围。
每个用例都代表用户希望系统帮助实现的一个目的或目标。例如,对于银行ATM机,客户希望使用它来取款、存款、转账或者修改密码等,而银行则希望使用它可以获得存取款明细等。
要使系统具有实用性,它就必须为用户带来价值。例如银行ATM机,对于客户而言,可以省去或节约人工柜台排队等候的时间,对于银行而言可以降低人力资源成本。在用例的术语中,对于所有与系统产生交互的对象我们统称为“参与者(Actor)”。参与者涵盖了所有与系统产生交互的用户,例如客户、工作人员、管理人员、维护人员等。任何可以使用系统(主动或者被动)的人都可以被视为参与者。
对于图书馆系统,参与者包括读者、图书管理员、编目人员、出入库人员等;对于打车APP,参与者包含乘客、司机、管理人员、客服等;对于银行ATM,参与者包含客户、款箱管理员、运维人员等。
此外,与当前系统相关联的其他系统、设备、传感器等都可以视为参与者。
回到用例本身,用例必须为参与者提供价值。许多程序员对“用例为参与者提供价值”感到困惑,经常与函数或子程序返回一个值混淆。所谓提供价值意味着实现了某种期望的结果,它是比函数或子程序返回值更高层次的结果。例如对于银行ATM,“取款”是一个用例,它的价值在于实现了客户获得现金的目标,而为了实现这个目标,银行ATM识别卡片、客户输入密码及ATM判断密码是否正确等只是“取款”这个用例实现的步骤,它们都有返回值,但是这些并非客户的目标,它没有为客户带来价值或让客户受益,因此它们不是用例。
用例是行为模型的一部分。一个业务用例通常描述一个完整的业务,一个系统用例通常描述一个完整的系统功能。一个用例描述一个场景,它也可以表示一组具有相似目标的相关场景。通常使用结构化文本(用例规约)、故事板、序列图、状态机或活动图来详细描述这些场景。
在UML中使用椭圆表示用例,用例的名称写在椭圆内部,如下图所示。用例的名称表示参与者的目标。通常,用例名称应当使用“动宾”结构,例如存款、取款、修改密码等,而这个动宾结构的主语就是对应的参与者,即客户存款、客户取款、客户修改密码等。

关于用例命名,有一个简单实用的技巧,即在参与者目标前加上“系统,请帮我……”或类似的变体。例如:

  • 系统,请帮我取款。
  • 系统,请帮我修改密码。
  • ……

遵循上述形式获得的用例名称通常是正确的,并且很容易理解。然而有些特殊的用例不适用这种命名的方法。例如,在系统或参与者具备自主能力的场景下,一个机器人系统会自行进入休眠状态,一个时钟会自动计时,此时没有具有传统目标的传统参与者。
用例图简单易懂,几乎不需要经过专业训练就可以阅读和开发,而用例图又是描述需求的手段,故而通常它由需求分析人员与参与者的代表共同开发,或者由需求分析人员开发后,与参与者讨论修改而成。最终形成的用例图还须交给各参与者代表进行审查。参与者代表审查用例图非常有必要且有价值,因为参与者可以明确知道用例图是否包含了他们自己所有的目标,或者是否存在跟他们不相关的目标。也正是基于此,在为用例命名时应当使用参与者的术语,而避免使用IT术语或者实现时的概念,同时力求简单、明确,确保每个人都能理解。
系统开发人员倾向于围绕用例来组织项目,因为这样可以使项目更易于相关各方理解。通常一个项目应当生成需求或设计文档,可以考虑先按参与者再按用例来组织文档结构(如下图所示),同时也可以通过创建相关用例的包来构建包结构。

本文简单讨论了用例的概念及如何发现用例,关于用例与参与者更深入的概念与知识,请参阅博客下UML合集中的其他相关文章。

背景

最近半路接手了一个系统的优化需求,这个系统有个遗留问题还没解决,随着新需求的上线,系统正式开放使用,这个遗留问题也必须解决。

这个系统大概是下面这样的,支持录入各种数据源的信息(ip、端口、数据库种类、账号密码等):

image-20250104210901249

录入完成后,可以查看这些数据源中的表、表的ddl、表中的列(列名、类型及注释等),也可以查看各个表中的数据。

其中一个数据源,是sql server 2008版本,总是连接失败,更别提获取这个db中的表了。

错误堆栈如下:

image-20250104211502284

定位过程

1、前期处理

在我做新需求的时候,我之前的同事A已经处理过这个问题。这个问题只在线上出现,因为开发测试环境压根没有这么老的数据库版本,在开发测试环境申请一台windows服务器来安装一个这样的老版本数据库,也比较麻烦;所以,同事A在处理的时候,基本是网上查到修改的办法后,直接弄到线上去试试能不能解决。

之前改过两次,第一次是这样:

1、参考附件脚本《配置文件java.security》,修改/usr/local/jdk/bin/java/java.security中的配置项jdk.tls.disabledAlgorithms。
修改内容:删除jdk.tls.disabledAlgorithms配置项的“TLSv1, TLSv1.1”,替换成“DHE”

简单解释下这部分的修改,从前文中的错误堆栈来看,这个问题是和ssl有关系的,我之前猜想的就是,这个sql server和mysql一样,支持使用tls加密传输,保护数据安全;但是,可能sql server 2008版本太老了,不支持tls 1.2/1.3这些,只能使用tls1.1/tls1.0等,但是呢,jdk认为使用tls1.1/1.0不够安全,默认是禁用了的,所以,只要把这个禁用tls1.1/tls1.0的配置给改改,允许jdk使用tls1.1/tls1.0,不就可以连接sql server 2008了吗?

但是,遗憾的是,这个改动之前已经上线试过了,没有生效,还是报错。

另外,在我做新需求的时候,同事A又试了一个改动,把驱动版本升级了下,大家知道java都是使用jdbc去连接数据源的,各个厂商会实现jdbc,之前呢,使用的是sqlserver的4.0版本的驱动,这把,直接弄到了8.4.1,准备搞上去再试试:

image-20250104213141696

2、尝试修改配置禁用加密

我了解到这个情况后,因为需求也比较赶,就没想花大力气来搞这个bug,我们都是内网服务器之间调用,这个加密传输,我感觉不是必要,直接弄成不加密不就行了吗?

我找了下代码,里面有拼接jdbc url的代码:

image-20250104213746728

那直接去掉这个
encrypt=true;trustServerCertificate=true
,去掉后,本地测试了下连接sql server数据库(不是2008版本),用wireshark抓包看了下,发现客户端发给数据库(sql server常用1433端口)的报文里,还是说加密是开启的:

image-20250104214148592

行吧,我查了下,原来不指定encrypt属性,默认就是true,那我手动指定成false得了。

image-20250104214411744

又抓包看了下,这次有了变化,客户端发过去的报文是说,不使用加密:

image-20250104214503660

服务端返回报文也说,不加密:

image-20250104214556331

但是,我在后续的报文里,发现还是部分加密了的:

image-20250104214722668

这就有得难以理解了。

3、debug驱动代码

所以这时候的思路就是,看看为什么源码里还会加密传输,那只能debug了,看看是不是还有其他选项在控制这块,后面找到如下代码:

image-20250104215227828

在上图中,先是三次握手,再是prelogin(就是前文抓包看到的那部分,如:Encryption: Encryption is available but off (0)),再下来呢,有个if,如果满足这个if,就会开启SSL,此时,就会导致发出去的报文是ssl的,也就是说,只要走了这个if,我们就绕不开ssl,就规避不了这个bug。

那我们看看,怎么绕开这个if吧。

这个if中,左边是个常量,ENCRYPT_NOT_SUP表示不支持加密,
image-20250104215651186

右边是个变量,初始化的时候是:

private byte negotiatedEncryptionLevel = TDS.ENCRYPT_INVALID;

后续,什么地方会修改这个变量呢,是在prelogin部分,在处理数据库返回的prelogin响应报文时:

image-20250104215857867

这里,2812行,是直接取响应报文中的值,也就是说,以数据库服务端的为准。

还记得,服务端一般是返回如下值:0。

image-20250104220012549

那这样的话,就会导致那个if条件为true:

image-20250104220149407

这块就有点难办了,这个值是服务端返回的,除非数据库返回ENCRYPT_NOT_SUP,表示不支持加密,否则,这个加密是跑不掉了。但我没太想过要让数据库去改这个配置,毕竟这个库,说是客户端还不少,我不可能去动它,影响太大,可能到时候导致别的客户端要改造。

还有个方向,是通过客户端的传参,去影响服务端的返回值,比如客户端传一个不支持加密,看看服务端的返回值。

但,比较遗憾的是,客户端驱动定死了,只能传下面这两个值,要么ENCRYPT_ON,要么ENCRYPT_OFF:

image-20250104221111123

4、覆盖驱动源码,强行绕过enableSSL方法

当时,我的想法是,把这个if条件改一下,改成:

if (TDS.ENCRYPT_ON == negotiatedEncryptionLevel){
    tdsChannel.enableSSL(serverInfo.getServerName(), serverInfo.getPortNumber());
}

其实,按我这会的想法,改下面这个地方也不错,想办法传ENCRYPT_NOT_SUP给服务端:

requestedEncryptionLevel = isBooleanPropertyOn(sPropKey, sPropValue) ? TDS.ENCRYPT_ON : TDS.ENCRYPT_OFF;

如何才能修改驱动包的代码呢,改是改不了,但是可以想办法覆盖,方法就是在项目中建同包名同类名的java文件(内容直接从源码文件拷贝),然后修改其中的部分代码即可。

但这次有点意思的是,遇到个以前没见过的问题,报如下错误:

java.lang.SecurityException: signer information does not match signer information

最终在网上查了下,(
https://blog.csdn.net/weixin_44070655/article/details/129922513),错误的意思是,我们新建的java文件的一些签名信息不太匹配,这块没细看。最终是需要删除jar包中的如下两个文件:

image-20250104223152701

删除的方式,可以直接用压缩软件打开,删除里面的这两个文件即可,另存为即可。然后把改后的jar包发布到私服(可以修改下坐标),或者是使用maven的如下方式:

image-20250104223331747

最终成功绕过enableSSL了,抓包发现,客户端确实没对包进行加密了,但是,服务端不返回任何报文了。我理解的是,服务端当初在进行prelogin协商时,返回的加密选项是:ENCRYPT_OFF,这个按正常流程,后续就是需要加密的,我们现在强行改了客户端源码,导致服务端陷入了迷思:wtf,客户端怎么回事,怎么没加密,这个客户端有问题?行吧,我不返回了。

最终,我还是放弃了这条路。因为,我上网查了下这个encrypt选项。

https://learn.microsoft.com/zh-cn/sql/connect/jdbc/understanding-ssl-support?view=sql-server-ver16

image-20250104224440595

原来,加密分为两个部分,第一个部分是登录部分,建立连接时,会传输用户名密码,我当时发现:在我上面强行改了客户端驱动,收不到服务端响应时,进行了抓包,发现我可以看到明文密码,当时我也有点惊讶,也反应过来了,难怪要弄ssl加密呢;第二个部分是,后续的数据的加密,如传输的sql语句和执行结果。

当encrypt为true时,两个部分都会加密;而当encrypt为false时,登录部分还是会加密,而数据部分不会加密。

所以,不管怎么说,登录部分总是要加密的,所以我还是不要挑战这条路了,毕竟这是协议规定好了的。

5、增加ssl日志

最终,把修改源码部分,全都回退了。最终的jdbc url选项如下,驱动版本也保留着

;encrypt=false;trustServerCertificate=true;
<dependency>
    <groupId>com.microsoft.sqlserver</groupId>
    <artifactId>mssql-jdbc</artifactId>
    <version>8.4.1.jre8</version>
</dependency>

说白了,ssl问题依然会有(毕竟encrypt=false,登录部分还是要走ssl),但是,我们可以想办法把ssl过程中的日志打印出来:

System.setProperty("javax.net.debug","ssl:handshake:verbose");

image-20250104225440075

这个呢,会打印ssl过程中的细节(注意,是打印到标准输出的,日志文件里没有,要看看启动java进程时,把标准输出重定向到哪里去了,不能是 > /dev/null这种),类似下面这种,到时候我们上线了再看看日志情况吧:

image-20250104225659003

6、上线后检查ssl日志

这个问题,现在说白了,就是客户端发了ssl握手消息给服务端,正常来说,服务端是要响应的,像下面这样,返回server hello这个报文,其中包含选定的ssl加密套件、服务端证书链等信息:

image-20250105080602411

然后我预期的是,上线后,这个ssl日志能把服务端报错的原因打印出来,结果并没有。

直接就是说,服务端关闭了连接,终止握手:

image-20250105080917662

从后来我找运维抓的包也能看出来,服务端发了tcp关闭的报文:

image-20250105081152690

7、尝试更换客户端驱动版本

此时,有点陷入僵局了。客户端没日志,网络报文也看不出来,那意思是只能看看服务端有没有日志了吧。

然后去找了dba,我现场演示了下,他看了数据库端的日志文件:啥都没有。

他给了我两个方案,一个是这个库太老,后续会复制一个新库出来,这个要等;再一个是,这个库也有其他的项目在用,也是java客户端的,他说帮我问下相关同事。

然后后续我单独加了那个同事,了解了下,他们用的驱动版本是7.4.1,我们目前线上是8.4.1:

<dependency>
    <groupId>com.microsoft.sqlserver</groupId>
    <artifactId>mssql-jdbc</artifactId>
    <version>7.4.1.jre8</version>
</dependency>

然后,jdbc的url是这样:

image-20250105082000028

最终我们的方向是,换不同版本的驱动看一下,先试上面这个7.4.1.jre8版本。因为之前我也查到过资料,就是xx版本的数据库,需要xx版本的驱动。

https://learn.microsoft.com/zh-cn/sql/connect/jdbc/microsoft-jdbc-driver-for-sql-server-support-matrix?view=sql-server-ver16

image-20250105082313608

从图里能看出来,sql server 2008,需要7.2版本的驱动。我们之前的8.4.1,肯定是高了;其实看上图,7.4也高了,但不知道人家项目为啥能行,就也试试呗。

8、开发测试驱动版本工具类

写了个类来测试:

image-20250105082729476

执行方式就是把jar和class放到同一目录下执行:

[root@news-center-app ~]# ll DbConnectTest.class mssql-jdbc-7.4.1.jre8.jar 
-rw-rw-rw- 1 root root    1631 Dec 30 15:30 DbConnectTest.class
-rw-rw-rw- 1 root root 1209660 Dec 30 15:16 mssql-jdbc-7.4.1.jre8.jar
[root@news-center-app ~]#  java -classpath .:./mssql-jdbc-7.4.1.jre8.jar DbConnectTest "jdbc:sqlserver://1.1.1.1:1433;databaseName=xxx;encrypt=false;trustServerCertificate=true" zhangsan xxx

这样呢,方便我们替换驱动的jar包。

结果呢,运维说必须走流程才能这么玩,理由就是不能在生产上做测试,battle了半天,后面还是提流程了(正好有个小需求又要上,就把这个工具一起弄上去了)。

上线的时候,我们顺便就把之前的驱动版本从8.4.1.jre8改成了7.4.1.jre8,也包括这个小工具。

上线后,以为这次肯定能行,结果,还是报一样的错误,此时正值周五快下班的时候,我无语了:就不能早点解决了这个bug,好好过个周末,不然还牵挂着它。

结果我回家路上,运维在群里说,bug可以了,他上网找了下文章:

https://blog.csdn.net/zhujun300/article/details/141434867

还是修改jdk的java.security文件,这次又把另一个被禁用的给去掉了:3DES_EDE_CBC. (我在开头说,同事A之前就改过一次,但是去的是tls1.1/tls1.0,没去这个3DES_EDE_CBC)。

然后,就好了。

行吧,还是能好好过周末的。

9、为什么去掉3DES_EDE_CBC能好

网上翻了很多资料,没找到讲这块原理的。我自己本地试验了下,在去掉这个3DES_EDE_CBC前,记录了打印的ssl日志;在去掉后,又记录了下。

对比如下,可以看到,去掉后,握手消息中多了很多加密套件(其中都包含了3DES_EDE_CBC这个加密算法):

image-20250105084822878

那这样的话,我们可以认为,线上那个库,应该是不支持客户端发送出去的所有加密套件,才把ssl握手终止了。

而加上3DES_EDE_CBC后,多了一些加密套件,而这些套件,正好服务端就支持,所以就可以了。

当然,具体选择了哪个加密套件,可能得下周上班了再找运维看看日志或者抓个包瞧瞧才知道。

这次呢,我也学会了一个新技能,由于ssl握手消息是封装在其他协议(TDS)里面的,在wireshark中都没法看。

image-20250105085534815

上面蓝色部分就是握手消息,但看不了,要知道握手的具体细节,非得看ssl日志才行,这个让人有点不爽。

我上网找了下,还真找到个网站:

https://williamlieurance.com/tls-handshake-parser/

只要把十六进制流复制进去,就能解析ssl。

image-20250105085747745

image-20250105085817985

对我来说,算是不小的一个收获。

10、怎么查看sql server 2008支持的加密套件

一开始,对这块不理解,以为ssl加密相关能力是sql server 2008这个软件提供的(对windows服务器太不了解了),但后来查了些资料发现,ssl加密相关能力是操作系统提供的;像是linux呢,一般就是安装了openssl,其他软件都是复用openssl的能力。

而sql server 2008,当时查了下版本:

Microsoft SQL Server 2008 (SP1) - 10.0.2531.0 (X64)   Mar 29 2009 10:11:52   Copyright (c) 1988-2008 Microsoft Corporation  Enterprise Edition (64-bit) on Windows NT 5.2 <X64> (Build 3790: Service Pack 2) (VM) 

没细问windows服务器的版本,但我们从上述也能看出来:

windows服务器版本其实就是:Windows NT 5.2
(Build 3790: Service Pack 2)

这个服务器,搜索了下,其实是:win server 2003版本。

image-20250105100603157

难怪了,这个操作系统版本太低了,估计就是很多ssl套件都不支持。

那么,我们如何查看一个windows电脑,支持哪些加密套件呢?

有以下几种方式:

10.1 通过组策略管理器查看

  1. 按下 “Win + R” 键,输入 “gpedit.msc” 并回车,打开组策略编辑器2。
  2. 依次展开 “计算机配置”→“策略”→“管理模板”→“网络”→“SSL 配置设置”2。
  3. 双击右侧的 “SSL 密码套件顺序”,选择 “已启用”,在左下侧可以看到支持的 SSL 加密套件

10.2 通过命令查看

使用 PowerShell:以管理员身份运行 PowerShell,输入Get-TlsCipherSuite命令,可列出当前系统上配置的 TLS/SSL 加密套件以及它们的启用状态等信息。

image-20250105095527943

10.3 IISCrypto

这边下载了一个软件:
https://www.nartac.com/Products/IISCrypto/Download

可以大概看到ssl中涉及的几个部分:传输协议、加密算法、hash算法、秘钥交换算法。

image-20250105095223507

通过上述几个部分,组成了各种各样的ssl套件:

image-20250105095317399

官方参考链接

https://learn.microsoft.com/en-us/windows/win32/secauthn/schannel-cipher-suites-in-windows-vista

其他尝试过的定位手段

1、本地安装sql server 2008

我猜测这个问题应该是比较好复现的,只是苦于没有环境,然后就想着在本机装一个,没想到,就这也踩了好久的坑。

安装sql server 2008,依赖.net framework 3.5这个运行环境,我是win10系统。网上的方法分两类,在线安装和离线安装,整个.net framework 3.5包有100多m,在线安装,我这边反正不行,不只是网络的问题,好像在线安装是需要一个service正常运行才可以:windows update。

相信很多人,当初为了禁用windows的升级,可能把这个service都删掉了。

可能也正是因为这样,我甚至离线安装也是失败的。

我也附上几个链接吧,万一大家可以呢:

https://mp.weixin.qq.com/s/y0sZz8wtLtcPQM0sI8DHmQ

https://mp.weixin.qq.com/s/_unsXuBLH1JjtsprKYjSWw

https://mp.weixin.qq.com/s/4FtoTMF3L_hDAXGu_GgOdA

试这些的时候,也可以先看下官方帮助文档:

https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/enable-or-disable-windows-features-using-dism?view=windows-11

C:\WINDOWS\system32>dism /online /?
dism /online /Enable-Feature /?
dism /online /Add-Package /?

最终,我用上述这些方法也没成功,后来是按照如下文章来解决的:

https://blog.csdn.net/Roeluo/article/details/144692042

当然,这个.net framework 3.5装上了,并不影响我的sql server 2008安装失败,当然,现在bug都解决了,有空再弄吧。

参考链接

https://blog.csdn.net/wpf416533938/article/details/128573683

https://blog.csdn.net/tanhongwei1994/article/details/84957254

https://learn.microsoft.com/zh-cn/archive/blogs/jdbcteam/the-driver-could-not-establish-a-secure-connection-to-sql-server-by-using-secure-sockets-layer-ssl-encryption

https://stackoverflow.com/questions/32766114/sql-server-jdbc-error-on-java-8-the-driver-could-not-establish-a-secure-connect

https://stackoverflow.com/questions/79113822/java-1-8-sql-server-2008-r2-unable-to-run-query-when-encryption-is-activated

https://www.reddit.com/r/sysadmin/comments/u6grqv/very_legacy_ssl_problem_on_server_2003yep_it/

1. Python中字符串的相加和相乘

在Python中,字符串可以通过加号(
+
)进行相加(连接),也可以通过乘号(
*
)进行相乘(重复)。以下是这两种操作的详细说明和示例:

字符串的相加(连接)

字符串的相加是通过使用加号(
+
)运算符来实现的。它将两个或多个字符串连接成一个单一的字符串。

str1 = "Hello"
str2 = "World"
result = str1 + " " + str2
print(result)  # 输出 "Hello World"

在连接多个字符串时,可以连续使用加号:

str1 = "Hello"
str2 = "World"
str3 = "Python"
result = str1 + " " + str2 + " " + str3
print(result)  # 输出 "Hello World Python"

字符串的相乘(重复)

字符串的相乘是通过使用乘号(
*
)运算符来实现的。它将一个字符串重复指定的次数。

str1 = "Hello"
result = str1 * 3
print(result)  # 输出 "HelloHelloHello"

如果乘以0,结果将是一个空字符串:

str1 = "Hello"
result = str1 * 0
print(result)  # 输出 ""

组合使用

你也可以将字符串的相加和相乘组合使用:

str1 = "Hello"
str2 = "World"
result = (str1 + " ") * 2 + str2
print(result)  # 输出 "Hello Hello World"

注意事项

  • 字符串的相加和相乘操作是不可逆的,即结果仍然是一个字符串,而不是一个列表或其他数据结构。
  • 在连接大量字符串时,使用
    join()
    方法通常更高效,因为它避免了创建多个中间字符串对象。
  • 字符串的相乘操作要求乘数是一个整数,否则会引发
    TypeError

通过这些操作,你可以灵活地处理字符串数据,满足不同的编程需求。


2. Python中元祖的相加和相乘

在Python中,元组(tuple)也可以通过加号(
+
)进行相加(连接),以及通过乘号(
*
)进行相乘(重复)。以下是对这两种操作的详细说明和示例:

元组的相加(连接)

元组的相加是通过使用加号(
+
)运算符来实现的。它将两个或多个元组连接成一个单一的元组。

tuple1 = (1, 2, 3)
tuple2 = (4, 5, 6)
result = tuple1 + tuple2
print(result)  # 输出 (1, 2, 3, 4, 5, 6)

在连接多个元组时,可以连续使用加号:

tuple1 = (1, 2)
tuple2 = (3, 4)
tuple3 = (5, 6)
result = tuple1 + tuple2 + tuple3
print(result)  # 输出 (1, 2, 3, 4, 5, 6)

元组的相乘(重复)

元组的相乘是通过使用乘号(
*
)运算符来实现的。它将一个元组重复指定的次数。

tuple1 = (1, 2, 3)
result = tuple1 * 3
print(result)  # 输出 (1, 2, 3, 1, 2, 3, 1, 2, 3)

如果乘以0,结果将是一个空元组:

tuple1 = (1, 2, 3)
result = tuple1 * 0
print(result)  # 输出 ()

组合使用

你也可以将元组的相加和相乘组合使用:

tuple1 = (1, 2)
tuple2 = (3, 4)
result = (tuple1 + tuple2) * 2
print(result)  # 输出 (1, 2, 3, 4, 1, 2, 3, 4)

注意事项

  • 元组的相加和相乘操作是不可逆的,即结果仍然是一个元组,而不是其他数据结构。
  • 元组的相乘操作要求乘数是一个整数,否则会引发
    TypeError
  • 元组是不可变的,因此相加和相乘操作不会改变原始元组,而是创建一个新的元组。

通过这些操作,你可以灵活地处理元组数据,满足不同的编程需求。


3. Python中列表的相加和相乘

是的,Python中的列表(list)也可以通过加号(
+
)进行相加(连接),以及通过乘号(
*
)进行相乘(重复)。以下是对这两种操作的详细说明和示例:

列表的相加(连接)

列表的相加是通过使用加号(
+
)运算符来实现的。它将两个或多个列表连接成一个单一的列表。

list1 = [1, 2, 3]
list2 = [4, 5, 6]
result = list1 + list2
print(result)  # 输出 [1, 2, 3, 4, 5, 6]

在连接多个列表时,可以连续使用加号:

list1 = [1, 2]
list2 = [3, 4]
list3 = [5, 6]
result = list1 + list2 + list3
print(result)  # 输出 [1, 2, 3, 4, 5, 6]

列表的相乘(重复)

列表的相乘是通过使用乘号(
*
)运算符来实现的。它将一个列表重复指定的次数。

list1 = [1, 2, 3]
result = list1 * 3
print(result)  # 输出 [1, 2, 3, 1, 2, 3, 1, 2, 3]

如果乘以0,结果将是一个空列表:

list1 = [1, 2, 3]
result = list1 * 0
print(result)  # 输出 []

组合使用

你也可以将列表的相加和相乘组合使用:

list1 = [1, 2]
list2 = [3, 4]
result = (list1 + list2) * 2
print(result)  # 输出 [1, 2, 3, 4, 1, 2, 3, 4]

注意事项

  • 列表的相加和相乘操作是不可逆的,即结果仍然是一个列表,而不是其他数据结构。
  • 列表的相乘操作要求乘数是一个整数,否则会引发
    TypeError
  • 列表是可变的,因此相加和相乘操作不会改变原始列表,而是创建一个新的列表。

通过这些操作,你可以灵活地处理列表数据,满足不同的编程需求。


4. Python中的与或非

在Python中,与(AND)、或(OR)和非(NOT)是逻辑运算符,用于组合或修改布尔值(
True

False
)。以下是对这些逻辑运算符的详细说明和示例:

与(AND)

  • 功能

    and
    运算符用于逻辑与运算。当两个操作数都为
    True
    时,结果为
    True
    ,否则为
    False
  • 示例

    result = True and True  # True
    result = True and False  # False
    result = False and True  # False
    result = False and False  # False
    

或(OR)

  • 功能

    or
    运算符用于逻辑或运算。当两个操作数中至少有一个为
    True
    时,结果为
    True
    ,否则为
    False
  • 示例

    result = True or True  # True
    result = True or False  # True
    result = False or True  # True
    result = False or False  # False
    

非(NOT)

  • 功能

    not
    运算符用于逻辑非运算。它将布尔值取反,即
    True
    变为
    False

    False
    变为
    True
  • 示例

    result = not True  # False
    result = not False  # True
    

短路求值

Python的逻辑运算符具有短路求值的特性:

  • AND短路
    :如果第一个操作数为
    False
    ,则整个表达式的结果必定为
    False
    ,因此不会评估第二个操作数。

    result = False and some_function()  # 不会调用some_function()
    
  • OR短路
    :如果第一个操作数为
    True
    ,则整个表达式的结果必定为
    True
    ,因此不会评估第二个操作数。

    result = True or some_function()  # 不会调用some_function()
    

实际应用

这些逻辑运算符可以用于复杂的条件判断,例如:

age = 25
is_student = True

# 判断是否为成年学生
if age >= 18 and is_student:
    print("You are an adult student.")

# 判断是否为未成年或非学生
if age < 18 or not is_student:
    print("You are either a minor or not a student.")

通过这些逻辑运算符,你可以构建复杂的逻辑条件来控制程序的流程。


5. Python的赋值机制

赋值机制
这张图片描述了Python中基本类型(如整数)的赋值机制。在Python中,基本类型是不可变的,这意味着一旦创建,它们的值就不能被改变。图片中的三行代码展示了变量赋值和内存地址的变化过程:

Step 1

  • a = 100
    :创建了一个变量
    a
    ,并将其与值
    100
    关联。在内存中,
    100
    被存储在一个位置,我们称之为
    pos1(100)
  • b = a
    :创建了另一个变量
    b
    ,并将
    a
    的值赋给它。在Python中,这意味着
    b
    也指向内存中的同一个位置
    pos1(100)

Step 2

  • a = 200
    :将新的值
    200
    赋给变量
    a
    。由于基本类型是不可变的,所以不会修改内存中
    pos1(100)
    的位置。相反,
    a
    现在指向内存中的另一个位置
    pos2(200)
    。而
    b
    仍然指向原来的
    pos1(100)
    ,因为
    b
    的值没有被重新赋值。

Step 3

  • 这个步骤强调了可以使用
    id()
    函数来验证变量的内存地址。
    id()
    函数返回对象的内存地址,可以用来检查两个变量是否指向同一个对象。

这个过程说明了Python中变量赋值的机制,即变量实际上是对对象的引用。当一个变量被赋值给另一个变量时,它们都指向内存中的同一个对象。如果原始变量被赋予一个新的值,它将指向一个新的对象,而其他变量仍然指向原来的对象。

以下是这个过程的Python代码示例:

a = 100  # Step 1: a -> pos1(100)
print(id(a))  # 输出a的内存地址

b = a    # b也指向a的内存地址
print(id(b))  # 输出b的内存地址,应该与a相同

a = 200  # Step 2: a -> pos2(200), b仍然指向pos1(100)
print(id(a))  # 输出a的新内存地址
print(id(b))  # 输出b的内存地址,应该与最初的a相同

这段代码将展示
a

b
在内存中的地址变化,从而验证了上述的赋值机制。

进行联想,这样的赋值机制和在C、C++、Verilog中的有什么区别?

Python的赋值机制与C、C++和Verilog等语言有显著的不同,主要是因为这些语言在处理变量和内存管理方面的方式不同。以下是一些主要的区别:

1. 基本类型和对象引用

  • Python
    :Python中的变量总是指向对象的引用。对于基本类型(如整数、浮点数、字符串),Python使用一种称为“对象模型”的机制,其中每个值都是一个对象,变量存储的是对象的引用。这意味着即使两个变量指向同一个对象,修改其中一个变量的值不会影响另一个变量。
  • C/C++
    :在C和C++中,基本类型(如
    int

    float

    char
    )直接存储值。变量赋值实际上是值的复制。如果一个变量被赋值给另一个变量,它们各自独立存储自己的值副本。
  • Verilog
    :Verilog是一种硬件描述语言,用于设计和模拟数字系统。在Verilog中,变量的赋值可以是阻塞赋值(
    =
    )或非阻塞赋值(
    <=
    ),这影响赋值的执行顺序和时序。Verilog中的赋值更接近于硬件电路的行为。

2. 可变性

  • Python
    :Python中的不可变类型(如整数、浮点数、字符串)一旦创建,其值就不能改变。这意味着任何赋值操作实际上都是创建一个新的对象,并让变量指向这个新对象。
  • C/C++
    :C和C++中的变量可以直接修改其值,因为它们存储的是值本身。对于基本类型,赋值操作会直接改变变量的值。
  • Verilog
    :Verilog中的变量赋值涉及到信号的更新,这些更新可能在模拟的下一个时间步发生,具体取决于赋值类型(阻塞或非阻塞)。

3. 内存管理

  • Python
    :Python自动管理内存,包括垃圾回收。对象的生命周期由引用计数和垃圾回收器共同管理。
  • C/C++
    :C和C++要求程序员手动管理内存,包括分配和释放。这提供了更高的灵活性,但也增加了出错的风险。
  • Verilog
    :Verilog不涉及传统的内存管理概念,因为它用于描述硬件行为,而不是软件中的内存操作。

4. 赋值语句的执行

  • Python
    :赋值语句创建或修改对象引用。如果一个变量被赋值给另一个变量,它们最初指向同一个对象。
  • C/C++
    :赋值语句复制值到新的变量。如果一个变量被赋值给另一个变量,它们各自独立存储自己的值副本。
  • Verilog
    :赋值语句定义了信号之间的逻辑关系,这些关系在硬件实现中表现为电路连接。

总的来说,Python的赋值机制更侧重于对象的引用和不可变性,而C、C++和Verilog则更侧重于值的直接操作和硬件行为的描述。这些差异反映了不同语言的设计哲学和应用领域。

如果更加形象化地解释,如下:

让我们通过一个比喻来形象化地解释Python、C/C++和Verilog中的赋值机制:

Python:图书馆的图书卡片

想象一下,Python中的变量就像是图书馆里的图书卡片。每张卡片上写着一本书的标题和它在书架上的位置(内存地址)。当你创建一个变量并赋值时,就像你从书架上取出一本书,然后写一张卡片来记录这本书的信息。

  • 基本类型赋值
    :假设你取出了一本名为“Python编程”的书,你写了一张卡片“a”,上面写着“Python编程”和它的位置。当你把变量
    a
    赋值给变量
    b
    时,你只是又写了一张相同的卡片“b”,两张卡片都指向同一本书。如果你在卡片“a”上写下了另一本书的名字“高级Python”,这并不会影响到卡片“b”,因为它仍然指向原来的那本书“Python编程”。在Python中,这意味着
    b
    的值不会随着
    a
    的赋值而改变。

C/C++:图书馆的复印机

在C或C++中,变量就像是图书馆里的复印机。当你创建一个变量并赋值时,就像你复印了一本书的内容,并把复印件给了变量。

  • 基本类型赋值
    :如果你复印了“Python编程”这本书,并把复印件给了变量
    a
    ,然后你又复印了
    a
    的内容给了变量
    b
    ,那么
    a

    b
    都有了“Python编程”的副本。如果你改变了
    a
    的副本,比如在上面写下了“高级Python”,这并不会影响到
    b
    的副本,因为它们是独立的。在C/C++中,这意味着
    b
    的值不会随着
    a
    的赋值而改变。

Verilog:电路板上的连线

Verilog中的赋值更像是电路板上的连线。当你在Verilog中赋值时,你定义了电路板上不同组件之间的连接。

  • 赋值
    :如果你定义了一个信号
    a
    连接到一个LED灯,然后你定义了另一个信号
    b
    也连接到同一个LED灯,那么
    a

    b
    都会影响到LED灯的状态。如果你改变了
    a
    的值,这将直接影响到
    b
    ,因为它们是同一个电路的一部分。在Verilog中,这意味着
    b
    的值会随着
    a
    的赋值而改变,这取决于你使用的是阻塞赋值还是非阻塞赋值。

通过这个比喻,我们可以看到Python中的赋值更像是创建指向同一对象的引用,而C/C++中的赋值是创建值的副本,Verilog中的赋值则是定义电路组件之间的逻辑关系。这些不同的赋值机制反映了各自语言的特性和用途。

进一步联想,这和面向对象、面相过程的编程的区分有关系吗?

是有关系的,Python、C/C++和Verilog中的赋值机制与面向对象(Object-Oriented Programming, OOP)和面向过程(Procedure-Oriented Programming, POP)编程范式有着紧密的联系。这些编程范式影响了语言的设计和赋值机制的实现。让我们看看它们之间的联系:

面向对象编程(OOP)

  • Python
    :Python是一种多范式语言,支持OOP。在OOP中,数据和函数被封装在对象中,对象通过方法和属性相互交互。Python的赋值机制允许对象(包括基本类型)的引用被复制,而不是对象本身。这与OOP中对象的封装和隐藏原则相吻合,即对象的内部状态不应该被外部直接修改。

面向过程编程(POP)

  • C/C++
    :C是一种面向过程的语言,而C++支持POP和OOP。在POP中,程序是由一系列操作步骤(过程或函数)组成,数据通过参数在函数间传递。C/C++的赋值机制直接复制值,这与POP中函数间数据传递的方式相似,即函数调用时参数值被复制到函数内部。

硬件描述语言(HDL)

  • Verilog
    :Verilog是一种硬件描述语言,用于设计和模拟数字电路。它不直接属于OOP或POP范式,但它的赋值机制与硬件电路的行为紧密相关。Verilog中的赋值定义了电路组件之间的连接和信号流动,这与硬件设计中电路的物理连接相似。

赋值机制与编程范式的关系

  • 封装
    :在OOP中,封装是一个核心概念,它意味着对象的内部状态应该被保护起来,不被外部直接访问。Python的赋值机制支持这一点,因为变量赋值操作不会影响到其他指向同一对象的变量,除非使用特定的方法(如修改可变对象的属性)。

  • 数据流
    :在POP中,数据通过函数调用在程序中流动。C/C++的赋值机制反映了这种数据流,因为变量的值在函数间传递时会被复制。

  • 电路行为
    :Verilog的赋值机制模拟了硬件电路的行为,其中信号的赋值定义了电路组件之间的连接和数据流动。

总的来说,编程范式影响了语言的赋值机制和数据管理方式。OOP强调对象的封装和交互,POP侧重于函数和过程的调用,而硬件描述语言则模拟电路的物理行为。这些范式在不同语言中的体现,决定了它们如何处理变量赋值和数据管理。

更加形象地解释如下:

让我们通过比喻来形象地解释面向对象(OOP)和面向过程(POP)编程的区分,以及这两种编程范式在Python、C、C++中的应用:

面向对象编程(OOP):建筑团队

想象一下,你是一个建筑项目的负责人。在OOP中,每个对象就像是项目中的一个团队成员,他们各自有特定的职责和技能(属性和方法)。

  • Python
    :在Python中,OOP非常自然和直观。你可以将每个团队成员(对象)视为一个独立的实体,他们可以互相交流(方法调用)和协作(继承和多态)。例如,一个
    House
    类可以有
    build()

    paint()
    等方法,而
    Apartment
    类可以继承这些方法并添加特定的功能。

面向过程编程(POP):食谱和烹饪步骤

面向过程编程就像是按照食谱烹饪。食谱中的每个步骤(函数)都是独立的,你需要按照一定的顺序执行这些步骤来完成菜肴。

  • C
    :C语言主要是面向过程的。你可以将每个烹饪步骤(函数)视为一个独立的指令,它们按照特定的顺序执行。例如,一个程序可能首先调用
    prepare_ingredients()
    函数,然后是
    cook_meal()
    ,最后是
    serve_dinner()

  • C++
    :C++支持面向过程和面向对象编程。你可以将C++看作是食谱和建筑团队的结合。你仍然可以按照食谱(POP)的步骤来烹饪,但你也可以创建不同的食材(类)和烹饪工具(对象),它们可以有自己的特性和行为。

Python、C、C++中的OOP和POP区别

  • Python
    :在Python中,OOP是核心特性之一。你可以轻松地定义类和对象,以及它们之间的关系。Python的动态类型系统使得OOP更加灵活和强大。

  • C
    :C语言不支持OOP的概念。你不能定义类或对象,只能使用结构体(structs)来模拟一些OOP的特性,如封装。C语言更侧重于过程和函数的调用。

  • C++
    :C++是支持OOP的,它引入了类(class)的概念,允许你定义对象和它们的行为。C++也支持继承、多态和封装,这些都是OOP的关键特性。同时,C++也保持了C语言的面向过程特性,允许你使用函数和过程来组织代码。

形象比喻

  • OOP
    :就像是一个由多个专业团队组成的项目,每个团队(类)都有自己的专长和任务,他们可以独立工作,也可以与其他团队合作完成更大的项目。

  • POP
    :就像是一个详细的食谱,每一步(函数)都是独立的,你需要按照食谱的顺序来准备和烹饪食物。

通过这些比喻,我们可以更直观地理解OOP和POP编程范式的区别,以及它们在不同编程语言中的实现和应用。

附上另外一个案例

案例

这是小卷对分布式系统架构学习的第7篇文章,前面已经讲了很多理论知识,今天结合具体的中间件来讲分布式配置中心

1.面试官提问

面试官:假设你是公司的基础架构部门,现在需要设计内部的配置中心中间件,你要怎么设计?

我:设计客户端和服务端,客户端集成到业务项目中,项目启动时从服务端pull配置加载到本地,并且定时check服务端和本地配置是否一致,服务端如有更新,再pull到本地

面试官:那如果有几万台服务器,都是这样定时去check,服务端压力岂不是很大,要怎么解决呢?

我:那改成用服务端push的方式???

面试官:......

面试官:那今天就到这里吧,你回去等通知吧......

2.为什么需要分布式配置中心

不了解底层原理的小卷只好回家后苦心专研分布式配置中心的原理,一定要弄清楚底层逻辑,下次要吊打面试官。

先来简单理解为什么需要配置中心?

我们开发的服务都是单体架构时,配置文件就和代码放在一起,如springboot的application.yml文件,对配置的修改只需要修改这一个文件就行。到分布式服务中,一个服务会有多台机器,不可能每个机器都单独修改配置文件,然后重新部署的。

这就要用到配置中心了,以nacos为例,下图是配置修改时和服务器间的操作:

3.开源框架

这里列举4种分布式配置中心的中间件,我们直接从一个中间件的原理来学习配置中心。

工作这么多年,应该得了解一些开源组件,大大小小的都行:

1、Apollo

2016年5月,携程开源的配置管理中心,具备规范的权限、流程治理等特性。

GitHub地址:
https://github.com/apolloconfig/apollo

2、spring cloud config

2014年9月开源,Spring Cloud 生态组件,可以和Spring Cloud体系无缝整合。

3、Nacos

2018年6月,阿里开源的配置中心,也可以做DNS和RPC的服务发现。

4、Diamond

Diamond 出自淘宝,开源地址 【
https://github.com/takeseem/diamond】
,阿里集团内部的配置中心仍然用的diamond,只是开源版本不再维护

面试时可能会问到为什么选择Apollo作为配置中心?不用其他的配置中心呢?

很多人用的时候就是看别人也这么用,或者大家都这么用,就选择了这个中间件。这里如果遇到了的话,就可以提到开源社区的活跃性,因为Apollo 的社区生态活跃,且使用的公司特别多,常见的坑基本都被踩完了,所以选用Apollo。

4. Apollo工作原理

4.1基础模型

Apollo文档:
Apollo配置中心设计
,工作原理比较简单:

  1. 用户在配置中心对配置进行修改并发布
  2. 配置中心通知Apollo客户端有配置更新
  3. Apollo客户端从配置中心拉取最新配置,更新本地配置并通知到应用

4.2架构模块

解释下各个模块的功能:

  • Config Service提供配置的读取、推送等功能,服务对象是Apollo客户端
  • Admin Service提供配置的修改、发布功能,服务对象是Apollo Portal(管理界面)
  • Config Service和Admin Service都需要注册到Eureka并保持心跳;
  • Meta Server是对Eureka做了一层封装,封装的是服务发现接口;
  • Client通过域名访问Meta Server获取Config Service服务列表,即获取IP+端口,然后通过IP+端口访问服务,同时Client端自己做负载均衡,错误重试;
  • Portal访问Meta Server获取Admin Service服务列表,也是获取IP+端口,然后访问服务,Portal侧也做负载均衡;

5. 使用Apollo

官方有提供快速部署使用文档:
Quick Start

具体操作步骤可以自行查看官方文档,这里我们主要通过简单使用Apollo来理解配置中心。部署完成后,登陆Apollo的管理界面,然后创建个应用,发布后再创建个配置,接着再次发布,如下图:

这里我是在本地启动的,访问http://localhost:8080/可以查看已注册的实例

然后创建一个Springboot应用连接到Apollo配置中心,这里不写那么具体了,可以自行参考官方的
Java客户端使用指南

Mac电脑需先在本地的
/opt/settings/server.properties
文件中配置环境
env=DEV
,然后在
application.properties
文件中配置Apollo相关的内容如下:

# 接入Apollo配置
app.id=multi_function
apollo.meta=http://localhost:8080
# Apollo本地缓存路径
apollo.cache-dir=/Users/longbig/log
# 指定Apollo配置文件的环境
env=DEV
# 配置访问秘钥
apollo.accesskey.secret=4c61a00512ad4cc09ef8a0e1ee672d89
apollo.bootstrap.enabled=true

为了测试客户端接收到配置中心配置变更的事件,我们参考官方文档的代码写个监听器的代码如下:

@Configuration
@Slf4j
public class ApolloConfig {
    @Bean
    public void init() {
        Config config = ConfigService.getAppConfig();
        config.addChangeListener(new ConfigChangeListener() {
            @Override
            public void onChange(ConfigChangeEvent changeEvent) {
                log.info("Changes for namespace " + changeEvent.getNamespace());
                for (String key : changeEvent.changedKeys()) {
                    ConfigChange change = changeEvent.getChange(key);
                    log.info(String.format("Found change - key: %s, oldValue: %s, newValue: %s, changeType: %s", change.getPropertyName(), change.getOldValue(), change.getNewValue(), change.getChangeType()));
                }
            }
        });
    }
}

最后测试验证,在管理界面增加一个配置,然后对配置修改发布,可以看到客户端已经接收到配置变更的事件了,并且打印出日志信息了


6. 配置发布后实时生效设计

从上面简单使用中可以看到,配置发布后实时推送到客户端。下面我们简要看一下这块是怎么设计实现的

配置发布的大致过程:

  1. 用户在Portal操作配置发布
  2. Portal调用Admin Service的接口操作发布
  3. Admin Service发布配置后,发送ReleaseMessage给各个Config Service
  4. Config Service收到ReleaseMessage后,通知对应的客户端

6.1发送ReleaseMessage的实现方式

从上图看,应该是用MQ的方式比较合适,但是Apollo没有用外部消息中间件,而是通过数据库来实现这个简单的消息队列的。具体如下:

  1. Admin Service在配置发布后会往ReleaseMessage表插入一条消息记录,消息内容就是配置发布的AppId+Cluster+Namespace
  2. Config Service有一个线程会每秒扫描一次ReleaseMessage表,看看是否有新的消息记录
  3. Config Service如果发现有新的消息记录,那么就会通知到所有的消息监听器(
    ReleaseMessageListener
  4. 消息监听器得到配置发布的AppId+Cluster+Namespace后,会通知对应的客户端

我们查看数据库的
ReleaseMessage

ReleaseHistory
表,可以查看到当前消息和历史消息


6.2 Config Service通知客户端的实现方式

这里能解释说明开头的面试题,客户端更新配置是Pull还是Push的方式?

具体实现方式如下:

  1. 客户端会发起一个Http请求到Config Service的
    notifications/v2
    接口,也就是
    NotificationControllerV2
  2. NotificationControllerV2不会立即返回结果,而是通过
    Spring DeferredResult
    把请求挂起
  3. 如果在60秒内没有该客户端关心的配置发布,那么会返回Http状态码304给客户端
  4. 如果有该客户端关心的配置发布,NotificationControllerV2会调用DeferredResult的
    setResult
    方法,传入有配置变化的namespace信息,同时该请求会立即返回。客户端从返回的结果中获取到配置变化的namespace后,会立即请求Config Service获取该namespace的最新配置。

7. 客户端的工作原理

接着讲讲Apollo客户端的工作原理:

  1. 客户端和服务端保持了一个长连接,从而能第一时间获得配置更新的推送。(通过Http Long Polling实现)
  2. 客户端还会定时从Apollo配置中心服务端拉取应用的最新配置。
    • 这是一个fallback机制,为了防止推送机制失效导致配置不更新
    • 客户端定时拉取会上报本地版本,所以一般情况下,对于定时拉取的操作,服务端都会返回304 - Not Modified
    • 定时频率默认为每5分钟拉取一次,客户端也可以通过在运行时指定System Property:
      apollo.refreshInterval
      来覆盖,单位为分钟。
  3. 客户端从Apollo配置中心服务端获取到应用的最新配置后,会保存在内存中
  4. 客户端会把从服务端获取到的配置在本地文件系统缓存一份
    • 在遇到服务不可用,或网络不通的时候,依然能从本地恢复配置
  5. 应用程序可以从Apollo客户端获取最新的配置、订阅配置更新通知

8.题外话

之前在第一家公司工作过程中,遇到个问题是:对应用某个配置的变更如何通知到生产环境的所有机器?

当时的场景是前端发起HTTP请求,调用后端接口修改配置,因为负载均衡的缘故,请求只会打到1台机器上,只有1台机器的内存配置被更新,其他机器的内存配置还是旧的,当时小组一起讨论解决办法,可能认知有限,只想到MQ等等方式,没想到配置中心的原理

后来去了阿里之后,参与过写配置中心配置变更监听器,实现了全量机器的内存配置更新功能

现在回想起来,当时没解决的原因还是认知不够,现在学了配置中心的原理又想到了这件事,分享给大家学习参考~

相信通过学习Apollo配置中心的原理,你在面试过程中如果遇到开头的题目,应该也能说上一二了。