架构

此处记录架构相关的知识

最宏观的架构盘点

  • 分层架构(LAYERED): 通过层次分离确保系统的可维护性

    优点:低层可复用

    缺点:在高性能应用程序中的性能很差,因为通过多个层来满足业务请求的效率不高

    适用场景: 通常用于构建一般的桌面应用程序以及相对简单的web应用程序

  • 客户端服务器模式

    缺点: 服务器可能成为性能瓶颈和单点故障

    适用场景: 在线应用程序,如电子邮件,共享文档系统和银行业务

  • 模型视图控制器模式

  • 事件驱动架构: 通过组件间的松耦合实现高灵活性

  • 事件总线模式: 现代企业软件通常构建为分布式系统,可以为异步到达的与大量事件相关的消息提供服务.在事件总线上,事件源将消息发布到特定的通道,监听器订阅特定的通道,监听器收到所订阅的通道中的消息通知

    缺点: 因为所有消息都通过相同的总线传输,这种模式的可伸缩性是一个问题

    适用场景: Android开发,电子商务应用程序和通知服务中

  • 管道过滤模式(PIPE-FILTER): 单个事件触发一系列处理步骤,每个步骤执行特定的功能.将复杂消息流转换为唯一的简单纯文本顺序消息流,而不需要额外的数据字段.管道和过滤器体系结构将较大的处理任务划分为一系列较小的独立处理步骤或过滤器,这些处理步骤和过滤器通过管道互相连接

    适用场景: 常用于连续过滤器执行词法分析,解析语义分析和代码编译器中

  • 微内核架构: 将核心功能与插件分离,提高系统的可拓展性

  • 微服务架构: 通过服务的独立开发和部署提升系统的敏捷性

    现代程序复杂性越来越高,单体应用可能会变得过于庞大和复杂,无法有效地支持和部署.解决方案就是微服务架构,每个服务都可以独立的部署和拓展,并且有自己的API边界.不同的服务器可以由不同的编程语言编写,管理自己的数据库,并由不同的团队开发

  • 单体架构: 将所有组件打包成一个单元,简化开发和部署过程

  • 代理模式: 这种模式用解耦的组件来构造分布式系统,这些组件可以通过远程服务调用互相交互.代理组件负责协调组件之间的通信,服务器将其功能发布给代理,客户端从代理请求服务,然后代理将客户端重定向到其注册中心的服务.代理模式允许对象的动态更改,添加删除和重定位,使请求分发对开发人员透明

    适用场景: 常用于消息代理软件,如Kafka,rabbit mq

  • 点对点模式(PEER-TO-PEER): 这种模式中,每个组件被称为节点.节点既可以作为向其他节点请求服务的客户端,也可以作为向其他节点提供服务的服务器.p2p模式支持分布式计算

    优点:具有很强的健壮性和可拓展性

    缺点: 由于节点之间是自愿合作,因此服务质量无法得到保证,此外安全性无法保证,系统性能往往依赖于节点的数量

    适用场景: 常用于文件共享网络,以及基于加密货币的产品如比特币

  • 黑板模式(BLACKBOARD):对于那些没有确定解决方案策略的问题,这种模式非常有用.由3个部分组成:

    1. 黑板数据结构: 按照与应用程序相关的层次来组织并解决问题的数据,知识源通过不断地改变黑板数据来解决问题,知识源通过不断改变黑板数据来解决问题
    2. 知识源: 包含独立的,与应用程序相关的知识,知识源之间不直接进行通讯,他们之间的交互只通过黑板来完成
    3. 控制组件: 完全由黑板的状态驱动,黑板状态的改变决定了需要使用的特定知识

    优点: 很容易拓展数据空间的结构

    缺点: 然而修改数据空间的结构是困难的,因为所有应用程序都会受到影响

    适用场景: 常用于语音识别,蛋白质结构识别

  • 主从模式(MASTER-SLAVE): 由主和从组成.主组件将工作分配给从组件,并根据从组件返回的结果计算出最终结果

    优点:准确性,服务的执行委托给具有不同实现的不同从服务器执行

    缺点: 只能应用于可分解的问题

    适用场景: 常用于将主数据库视为权威的源数据库,并将数据库同步到从库

领域驱动设计

这类理论都是由软件设计领域的大牛(如Martin Fowler)提出来的

领域模型分为4大类

“血”指的是Domain Object的Domain层内容

在这四种模型当中,失血模型和胀血模型应该是不被提倡的。而贫血模型和充血模型从技术上来说,都是可行的。但是我个人仍然主张使用贫血模型。其理由:

  • 虽然贫血模型的Domain Object确实不够rich,但Domain Object只包含属于它本身的领域逻辑,不包含持久化逻辑,将有效的隔离和屏蔽了持久化技术,进而可以对持久化技术进行灵活的替换。
  • 贫血模型中提出的按照是否依赖持久化进行划分,这种标准是非常确定的,不会引起开发团队设计上的争议。

失血模型

Domain Object(领域对象)模型仅仅包含对象属性的定义和操作对象属性的getter/setter方法所有的业务逻辑完全由Business Logic层(业务逻辑层)中的服务类来完成。这种类在java中叫POJO,在.NET中叫POCO

  • 优点

    领域对象结构简单

  • 缺点

    肿胀的业务服务代码逻辑,难于理解和维护

    无法良好的应对复杂业务逻辑和场景

贫血模型

Domain Object(领域对象)模型包含对象属性的定义和操作对象属性的getter/setter方法包含了对象的行为(例如:就像一个完整的人,具有一些属性如姓名、性别、年龄等,还具有一些能力,如走路、吃饭、恋爱等,这样才是一个完整的对象),不包含依赖Dao层(持久层)的业务逻辑。这部分依赖于Dao层的业务逻辑将会放到Business Logic层(业务逻辑层)中的服务类来实现组合逻辑也由服务类负责。可以看出,贫血模型中的领域对象是不依赖于持久层的代码架构层次结构是Client-> Business Facade Service -> Business Logic Service(Business Logic Service是依赖Domain Object的行为) -> Data Access Service

  • 优点

    层次结构清楚,各层之间单向依赖

    对于只有少量业务逻辑的应用来说,使用起来非常自然

    开发迅速,易于理解

  • 缺点

    无法良好的应对非常复杂逻辑和场景

充血模型

Domain Object(领域对象)模型包含对象属性的定义和操作对象属性的getter/setter方法包含了大多数相关的业务逻辑,也包含了依赖于持久层的业务逻辑, Business Logic层是很薄的一层,仅仅简单封装少量业务逻辑以及控制事务、权限逻辑等,不和DAO层打交道。所以,使用充血模型的领域对象是依赖于持久层的。代码架构层次结构是Client-> Business Facade Service -> Business Logic Service -> Domain Object -> Data Access Service

  • 优点

    更符合OO的原则

    Business Logic层很薄,符合单一职责,不像在贫血模型里面那样包含所有的业务逻辑太过沉重,只充当Facade的角色,不和DAO打交道

  • 缺点

    什么样的逻辑应该放在Domain Object中,什么样的业务逻辑应该放在Business Logic中,这是很含糊的。即使划分好了业务逻辑,由于分散在Business Logic和Domain Object层中,不能更好的分模块开发。熟悉业务逻辑的开发人员需要渗透到Domain Logic中去,而在Domian Logic又包含了持久化,对于开发者来说这十分混乱。

    其次,因为Business Logic要控制事务并且为上层提供一个统一的服务调用入口点,它就必须把在Domain Logic里实现的业务逻辑全部重新包装一遍,完全属于重复劳动。

胀血模型

Domain Object(领域对象)模型包含对象属性的定义和操作对象属性的getter/setter方法包含了所有相关的的业务逻辑,也包含了不想关的其它应用逻辑(如授权、事务等)。胀血模型取消了Business Logic层(业务逻辑层)只剩下Domain Object和DAO两层,在Domain Object的Domain Logic上面封装事务,授权逻辑等

  • 优点

    简化了代码分层结构

    也算符合面向对象设计

  • 缺点

    取消了Business Logic层(业务逻辑层),在Domain Object的Domain Logic上面封装事务,授权等很多本不应该属于领域对象的逻辑,使业务逻辑再次进行到混论的状态,引起了Domain Object模型的不稳定

    代码理解和维护性差

DDD分层架构模型

它包括用户接口层、应用层、领域层和基础层,分层架构各层的职责边界非常清晰,又能有条不紊地分层协作。

  • 用户接口层:面向前端提供服务适配,面向资源层提供资源适配。这一层聚集了接口适配相关的功能。
  • 应用层职责:实现服务组合和编排,适应业务流程快速变化的需求。这一层聚集了应用服务和事件相关的功能。
  • 领域层:实现领域的核心业务逻辑。这一层聚集了领域模型的聚合、聚合根、实体、值对象、领域服务和事件等领域对象,以及它们组合所形成的业务能力。
  • 基础层:贯穿所有层,为各层提供基础资源服务。这一层聚集了各种底层资源相关的服务和能力。
image-20250116093436059

分层架构的一个重要原则是每层只能与位于其下方的层发生耦合。

分层架构可以简单分为两种,即严格分层架构和松散分层架构。在严格分层架构中,某层只能与位于其直接下方的层发生耦合,而在松散分层架构中,则允许某层与它的任意下方层发生耦合

分层架构的优点,Martin Fowler在《Patterns of Enterprise Application Architecture》一书中给出了答案:

  1. 开发人员可以只关注整个结构中的某一层
  2. 可以很容易的用新的实现来替换原有层次的实现
  3. 可以降低层与层之间的依赖
  4. 有利于标准化
  5. 有利于各层逻辑的复用

适当的分层体系结构将开发层面进行隔离,这些层不受其他层的更改的影响,从而使重构更加容易。划分任务并定义单独的层是架构师面临的挑战。当需求很好地适应了模式时,这些层将易于解耦或分层开发。

使用场景:

  • 需要快速构建的新应用程序
  • 需要严格的可维护性和可测试性标准的应用

解决90%问题的异步架构

降低代码的复杂度

对功能组件进行解耦

The forward Logic is only about 10% of your code,everything else is 90%. – Michael Stonebraker

开发的过程中遇到的问题盘点:

  • 业务逻辑冲突的处理规则
  • 数据完整性的保护机制
  • 超时规则
  • 重试规则

诸如此类的问题起码占到了代码开发的90%

异步解决一切问题

async和await这类代码能删除”等待”这个行为,统一管理和分配每一个步骤的执行

  • 管理池 master: 只负责管理
  • 任务池 slaves: 负责分配这些资源来处理任务
  • 重试池 retry: 灵活分配资源,当遇到接口故障的时候,可以直接暂停分配资源给这些重试支付的任务

好处在于可以灵活的分配资源去独立处理每一个任务的每一个步骤

异步架构的技术选型

打造这种异步的任务流水线,最需要解决的就是负责登记任务,跟踪进度,分配资源和进行调度的这一部分,最好的解决方案就是消息队列框架

比如说RabbitMQ,Kafka

事件驱动架构

事件驱动的核心是触发机制(trigger)和推送机制(pub/sub)

消息队列

三种消息传递模式详解

image-20240908111620780

图中的“生产者状态”和“消费者状态”指的是各自保存消息传递的状态信息,以便在失败或重试时恢复到正确的状态。

  • 最多一次:消息可能丢失,但不会重复,适用于对可靠性要求低的场景。
  • 至少一次:消息不会丢失,但可能会重复,适用于对消息丢失不可接受的场景。
  • 精确一次:消息既不会丢失也不会重复,适用于对可靠性和一致性要求高的场景。

盘点生产者与消费者之间的消息传递行为,以及这些模式的可靠性和特点

  1. 最多一次传递(At-most-once delivery)

    描述:生产者发送消息后,不会尝试再次发送,即便消息丢失。这意味着消息可能会被消费者成功接收到,也可能会在传输中丢失。

    风险:消息可能会丢失,生产者并不重试发送。

    应用场景:适用于一些对数据完整性要求不高的场景,比如不需要重试的通知。

  2. 至少一次传递(At-least-once delivery)

    描述:生产者在发送消息后,保存状态并尝试重发消息,直到收到消费者的确认。这会确保消费者至少会收到一次消息。

    风险:消费者可能会接收到重复消息,因为生产者在未确认的情况下会多次重发。

    应用场景:常用于对消息丢失不能容忍,但能处理重复消息的场景,比如日志系统或审计系统。

  3. 精确一次传递(Exactly-once delivery)

    描述:这是最强的消息传递语义,保证生产者和消费者都对消息进行状态保存,以确保消息恰好传递一次。消息不会丢失,也不会重复。

    风险:无重复也无丢失,但需要较复杂的实现(通常需要使用事务性操作)。

    应用场景:适用于对消息传递的正确性有严格要求的场景,如金融交易系统、账务系统等。

“精确一次传递”(Exactly-once delivery)是指在数据传输或消息传递过程中,确保每条消息只被处理一次且仅处理一次的特性。这一特性在分布式系统、消息队列和数据库等场景中非常重要,因为它能有效避免重复处理和数据丢失的问题。

理解“精确一次传递”的原理,可以从以下几个方面进行分析:

  1. 消息标识:每条消息通常会有一个唯一的标识符(ID),接收方在处理消息时会记录已处理的消息ID,以避免重复处理。

  2. 幂等性:在接收方的处理逻辑中,应该设计成幂等的,即多次处理同一条消息的结果是相同的。这样,即使消息被重复处理,也不会产生不一致的状态。

  3. 事务机制:在发送和接收消息的过程中,通常会使用事务来确保操作的原子性。例如,在数据库操作中,可以通过事务来确保数据的一致性和完整性。

  4. 确认机制:发送方在发送消息后,会等待接收方的确认(acknowledgment),只有在收到确认后,才会认为消息成功传递。这可以减少消息丢失的风险。

  5. 重试机制:如果发送方没有收到确认,它会重新发送消息。通过合理的重试策略,可以确保消息最终被成功传递。

  6. 状态管理:系统需要维护一定的状态信息,以跟踪消息的发送和处理情况,这样可以避免在系统故障或网络问题时造成的数据丢失或重复处理。

总之,精确一次传递的实现需要结合多个技术手段和设计原则,以确保在各种情况下都能保证消息的唯一性和一致性。

重试机制

image-20240908111924396

展示了几种回退(Backoff)策略重试时间线(Retry timeline)上的表现,横轴为回退时间(Backoff time),纵轴表示重试次数(Retry timeline)。回退策略常用于网络请求或分布式系统中的重试机制,以避免频繁请求或冲突。图片列出了几种常见的回退策略,并展示了它们在不同时间点的表现:

  1. No Backoff(无回退)

    这条线是平坦的,表示在失败时,系统不会等待,而是立刻重试。这种方法容易造成网络或系统的过载。

  2. Constant Backoff(常量回退)

    这条线也是平坦的,表明每次重试之间的等待时间是固定的,例如每次都等待相同的时间间隔。虽然这种策略避免了无间隔的重试,但对于某些情况来说,它的效率可能不够高。

  3. Linear Backoff(线性回退)

    这条线呈现出斜率逐渐增大的趋势,表示每次重试的间隔时间逐渐增加,但增加幅度是线性的。线性回退意味着每次重试等待的时间都会按固定的增量递增。

  4. Fibonacci Backoff(斐波那契回退)

    这条线表示每次重试的间隔时间按照斐波那契数列增长。相比线性回退,它的增长更加渐进,且在初期增长较为平缓。

  5. Quadratic Backoff(二次回退)

    这条线表示每次重试的间隔时间按二次方的规律增长。随着时间推移,间隔时间会迅速增加,这种方法可以有效避免高频重试对系统的冲击。

  6. Exponential Backoff(指数回退)

    这条线表示每次重试的间隔时间呈指数增长。最初增长较慢,但后期增长非常快。指数回退常用于网络重试中,特别是应对高并发时,指数增加等待时间可以减轻系统压力。

  7. Polynomial Backoff(多项式回退)

    这条线的增长速度最快。多项式回退的增长速度比指数回退更快,间隔时间的增加幅度更加剧烈。一般用于系统对重试频率的容忍度极低的场景。

总结:

  • No BackoffConstant Backoff 适合简单场景,且系统负载较低时使用。
  • LinearFibonacciQuadratic Backoff 是中间方案,随着重试次数的增加,系统等待时间也逐渐增加,适用于大多数实际应用。
  • ExponentialPolynomial Backoff 适合在高负载或高并发的情况下使用,能够显著减少系统压力,避免频繁重试。

接口架构

统一接口架构

前端的变动往往是频繁的,琐碎的,由此会波及到后端的部分,就很容易形成任务进度上的瓶颈

统一接口架构一次性解决了这个问题,每个页面的http信息都可以在一个http请求里传达清楚

结构的定义就是统一前后端交流的语言

image-20240908212030970

后端要实现数据结构模型和翻译器

很多自动化框架从数据库的schema生成接口的所有的数据结构和翻译器

  • APOLLO
  • Prisma
  • HASURA
  • PostGraphile

可以用swagger生成API文档

可能的缺陷:

  • 抛弃了rest API就是抛弃了几十年互联网基础设施所提供的强大的缓存能力
  • 合并所有的接口会让权限管理和限速限量等功能开发难度大大提升

架构模式

系统架构模式(Architectural Patterns)是软件系统设计中的高级模式,它提供了结构化和解决复杂问题的框架和策略,旨在提高系统的可扩展性、可维护性、灵活性以及模块化。常见的系统架构模式有以下几种:

  1. 层次化架构模式(Layered Architecture Pattern)

    • 描述:将系统分为多个层,每一层都只与其相邻的层进行交互。典型的分层结构包括表示层(UI)、业务逻辑层(Business Logic)、数据访问层(Data Access)、数据库层等。
    • 用途:适用于标准化的企业应用程序和Web开发。
    • 示例:MVC(Model-View-Controller)模式,三层架构(表示层、业务逻辑层、数据层)。
  2. 微服务架构模式(Microservices Architecture Pattern)

    • 描述:将系统拆分为多个独立的、可部署的微服务,每个微服务专注于特定的业务功能,并通过轻量级的通信(如HTTP或消息队列)进行交互。
    • 用途:适用于需要高度扩展性和可部署独立组件的大型分布式系统。
    • 示例:亚马逊、Netflix等使用的微服务架构。
  3. 事件驱动架构模式(Event-Driven Architecture Pattern)

    • 描述:基于事件机制进行异步通信,系统组件对特定的事件进行监听和响应。事件生产者(Publisher)和事件消费者(Subscriber)通过消息传递中间件解耦。
    • 用途:适用于需要快速响应变化、解耦复杂依赖的系统,常见于金融系统、监控系统。
    • 示例:发布/订阅模式(Publish/Subscribe)、消息队列(如Kafka、RabbitMQ)。
  4. 微内核架构模式(Microkernel Architecture Pattern)

    • 描述:系统的核心功能保持固定,扩展功能通过插件或附加模块来提供。微内核负责管理和通信,而插件负责具体的业务功能。
    • 用途:适用于需要灵活扩展、插件化功能的系统,如文本编辑器、IDE等。
    • 示例:Eclipse IDE、浏览器的插件机制。
  5. 面向服务架构模式(Service-Oriented Architecture, SOA)

    • 描述:系统由多个松耦合的服务组成,每个服务通过标准协议(如SOAP或REST)提供业务功能。服务可以被复用,并通过服务总线(ESB)进行集成。
    • 用途:适用于跨企业或跨应用集成的系统。
    • 示例:企业服务总线(ESB)、Web服务架构。
  6. 管道-过滤器架构模式(Pipes and Filters Pattern)

    • 描述:将系统的处理任务划分为多个独立的步骤,每个步骤被称为“过滤器”,它们通过“管道”串联。每个过滤器接收输入、处理数据并传递输出给下一个过滤器。
    • 用途:适用于数据处理流水线、流媒体处理等场景。
    • 示例:Unix管道(grep、sort、awk等命令的组合)、图像处理流水线。
  7. 共享数据库架构模式(Shared Database Architecture Pattern)

    • 描述:多个服务或组件共享同一个数据库,彼此通过访问同一数据库中的表来进行通信。这种方式通过数据库实现数据一致性和持久化。
    • 用途:适用于需要多个系统访问同一数据源的场景。
    • 示例:企业级应用程序的多模块数据共享。
  8. 空间分区架构模式(Space-Based Architecture Pattern)

    • 描述:通过将应用程序的处理和存储分区到不同的物理空间,以便系统能够水平扩展。所有组件共享数据和计算负载,避免单点瓶颈。
    • 用途:适用于需要大规模扩展的实时、高性能系统,如电商平台、社交网络。
    • 示例:分布式缓存系统、Amazon DynamoDB等。
  9. 主从架构模式(Master-Slave Architecture Pattern)

    • 描述:主服务器(Master)负责接收请求并将任务分配给从服务器(Slave),每个从服务器执行任务并将结果返回给主服务器。主从结构中,从服务器通常是副本或负载均衡节点。
    • 用途:适用于需要高可用性和负载均衡的分布式系统。
    • 示例:MySQL主从复制、Redis主从结构。
  10. 代理架构模式(Proxy Architecture Pattern)

    • 描述:通过代理(Proxy)来间接访问目标对象,代理可以控制、过滤、或增强对目标对象的访问。
    • 用途:适用于需要在访问目标对象之前进行控制或优化的场景。
    • 示例:虚拟代理(延迟加载)、远程代理(分布式对象访问)、保护代理(访问控制)。
  11. 分层缓存架构模式(Cache-Aside Architecture Pattern)

    • 描述:将缓存层作为独立于应用和数据库的层,用于存储经常访问的数据。应用程序先访问缓存,如果数据不在缓存中,再从数据库中获取。
    • 用途:适用于高并发访问、高性能数据读取的场景。
    • 示例:Memcached、Redis缓存策略。
  12. 分区架构模式(Partitioned Architecture Pattern)

    • 描述:通过水平或垂直分割数据和任务来分配负载。每个分区负责处理特定的数据或任务,并且可以独立扩展。
    • 用途:适用于需要水平扩展、处理海量数据的场景。
    • 示例:Hadoop、分布式数据库中的分区机制。
  13. 中介者架构模式(Mediator Architecture Pattern)

    • 描述:通过一个中介对象来协调多个子系统或模块之间的通信和交互,避免模块之间的直接依赖。
    • 用途:适用于模块复杂、需要集中控制通信的系统。
    • 示例:消息中间件(如RabbitMQ)、机场塔台控制系统。
  14. 代理-客户机架构模式(Client-Server Architecture Pattern)

    • 描述:客户端发起请求,服务器响应请求。客户端和服务器通过网络进行通信。服务器通常处理资源密集型任务,而客户端负责用户交互。
    • 用途:适用于典型的网络应用场景。
    • 示例:Web服务器和浏览器、FTP服务器和客户端。
  15. REST架构模式(Representational State Transfer, REST)

    • 描述:基于HTTP协议的一种风格,系统以无状态的方式进行交互,使用统一的接口,资源通过URI表示,操作通过HTTP方法(GET, POST, PUT, DELETE)实现。
    • 用途:适用于Web应用程序和分布式系统。
    • 示例:大多数现代Web API都基于REST架构。
  16. 无服务器架构模式(Serverless Architecture Pattern)

    • 描述:不需要显式管理服务器,开发人员将函数部署到云端,云提供商负责自动扩展、维护和运行环境。常用于事件驱动和按需执行的场景。
    • 用途:适用于事件驱动的应用程序,如实时处理、API调用、批处理任务。
    • 示例:AWS Lambda、Google Cloud Functions。
  17. CQRS(Command Query Responsibility Segregation)架构模式

    • 描述:将读取操作(查询)与写入操作(命令)分离,针对这两种操作使用不同的模型,避免读写模型的相互影响。
    • 用途:适用于高并发、高可用系统,尤其是读多写少的场景。
    • 示例:在电商系统中,订单系统使用CQRS来优化读写性能。

总结:

系统架构模式为设计软件系统提供了多种标准化的解决方案,每种模式针对不同的系统特性和需求提供相应的结构和设计思路。选择合适的架构模式需要考虑系统的规模、性能要求、扩展性、可维护性以及业务需求。

软件开发设计哲学

  1. KISS原则(Keep It Simple, Stupid)

    该原则强调系统设计应尽可能简单,避免不必要的复杂性。简单的设计更容易理解、维护和扩展。

  2. YAGNI原则(You Aren’t Gonna Need It)

    YAGNI原则主张在编写代码时,不要为未来可能需要的功能提前做准备。只实现当前需求,避免不必要的复杂性和代码膨胀。

  3. SOLID原则

    SOLID是五个面向对象设计原则的缩写,分别是:

    • Single Responsibility Principle(单一职责原则):一个类应该只有一个原因引起变化。
    • Open/Closed Principle(开闭原则):软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。
    • Liskov Substitution Principle(里氏替换原则):子类对象应该能够替换父类对象,而不影响程序的正确性。
    • Interface Segregation Principle(接口隔离原则):不应强迫任何客户端依赖于它们不使用的接口。
    • Dependency Inversion Principle(依赖倒置原则):高层模块不应依赖于低层模块,二者应依赖于抽象。
  4. DRY(Don’t Repeat Yourself)

    • 不要重复自己,强调在代码中避免重复。

    DRY原则强调在系统中同一种信息或逻辑应该只存在一个地方

    其所指的重复,包括但不限于:

    • 代码重复:相同的代码片段在多个地方出现。
    • 逻辑重复:相同的业务逻辑在不同的模块中实现。
    • 数据重复:相同的数据在多个地方存储。
  5. Separation of Concerns(关注点分离)

    • 该原则主张将不同的关注点或功能分开,以提高系统的模块化程度和可维护性。
  6. Law of Demeter(迪米特法则)

    • 该原则建议一个对象应尽量少地了解其他对象,强调对象之间的低耦合性。
  7. Composition over Inheritance(组合优于继承)

    • 该原则提倡使用对象组合来实现功能,而不是通过类继承。这有助于提高代码的灵活性和可重用性。

这些原则共同构成了现代软件开发的设计哲学,旨在提高代码的可维护性、可读性和灵活性。

函数内部设计建议

  1. 确保参数有效性

    函数入口处的参数检查

    • 对指针类型参数或容器进行空检查
    • 对整数浮点数等类型参数进行范围检查
  2. 减少重复代码(DRY原则)

    提取重复部分为辅助函数

  3. 处理异常

  4. 局部变量初始化

  5. 合理利用早退出(Guard Clauses)

  6. 优化循环结构

    循环中,尽量减少不必要的操作,如将不变的表达式移到循环外

  7. 明确函数职责(单一职责原则)

    若函数内部逻辑过于复杂,考虑将其拆分成多个小函数

  8. 减少嵌套

    层级越多,可读性越差

函数式编程

函数式编程( Functional Programming,简称 FP)

近些年,函数式以其优雅,简单的特点开始重新风靡整个编程界,主流语言在设计的时候无一例外都会更多的参考函数式特性( Lambda 表达式,原生支持 map ,reduce ……)

  • 命令式编程 一步一步执行原始操作,逐步实现目标,更加底层和繁琐

  • 声明式编程 侧重于使用工具简化操作,通过高层次的描述目标来实现功能

  • 函数式编程 输入声明式编程的一种,重点在于如何制作工具

    编程世界中,我们需要处理的其实也只有“数据”和“关系”,而关系就是函数,或者说是一种映射,而这种映射关系是可以组合的,函数式编程其实就是强调在编程过程中把更多的关注点放在如何去构建关系

函数式编程就像第三次工业革命,前两次分别为命令式编程(Imperative programming)和面向对象编程(Object Oriented Programming)

从λ演算深入了解函数式编程

js的函数式编程参考

参考书籍

[[C++11与14#C++中的函数式编程|参考C++中的函数式编程]]

实现前提

函数是“一等公民” (First-Class Functions)

这是函数式编程得以实现的前提,因为我们基本的操作都是在操作函数。

这个特性意味着函数与其他数据类型一样,处于平等地位

  • 可以赋值给其他变量
  • 可以作为参数,传入另一个函数
  • 可以作为别的函数的返回值

纯函数

纯函数是函数式编程的基石
$$
纯函数是指在给定相同输入的情况下,将始终返回相同输出且没有任何可观察的副作用的函数。
$$

img

定义

  • 输出完全取决于输入
  • 没有副作用

副作用:

在我们函数中最主要的功能当然是根据输入返回结果,而在函数中我们最常见的副作用就是随意操纵外部变量

  • 使全局状态变化

  • 改变其输入参数

    如果输入参数是个引用类型,会导致副作用

  • 执行任何IO操作

注意 纯函数并非完全不能读写外部的数据,如果外部数据可控,那么也可以读写

相关理解参考数据不可变章节

作为对比,不纯函数有如下特征:

  • 输入参数以外的因素会影响输出
  • 可能导致副作用

纯函数的优点

其优点也正是为什么引入纯函数的原因

  • 并行化

    纯函数可以并行化,即它们可以同时执行,充分利用多个 CPU 核心或分布式计算环境。这是因为纯函数没有副作用,使得它们可以安全地并发执行,而不影响总体结果。

  • 惰性求值

    所谓惰性执行指的是函数只在需要的时候执行,即不产生无意义的中间变量。函数式编程跟命令式编程最大的区别就在于几乎没有中间变量,它从头到尾都在写函数,只有在真正需要时才会被计算

  • 记忆化

    纯函数可以被记忆化,即函数的输出被缓存并在同样的输入再次出现时被重复使用。这项技术消除了一次性计算的需要,减少执行时间和提高了总体性能。

    记忆化特别适合于处理昂贵的计算或 I/O 操作。通过存储纯函数的结果,可以避免冗余的计算并专心于更重要的任务。

  • 易于测试

    纯函数易于测试,因为它们的输出是确定性的,即它们总是返回相同的结果,给定相同的输入。这使得编写单元测试变得简单,因为输出是可预测的,不依赖于外部状态。

无状态和数据不可变

无状态

无状态: 主要是强调对于一个函数,不管你何时运行,它都应该像第一次运行一样,给定相同的输入,给出相同的输出,完全不依赖外部状态的变化。

数据不可变

比如说,外界变量是个const变量的话,那么该函数依旧是个纯函数

如果在递归中要共享一个外部变量,可是使用这种方式

1
2
3
4
5
6
function test(cache)
{
if(cache)
cache={};
//其他代码,递归调用
}

该JS代码实现了一种机制,即如果cache不存在,则在函数体内初始化空间

高阶函数

高阶函数(High-Order Function)

在函数式编程中,高阶函数是指可以满足以下任一条件的函数:

  1. 接收一个或多个函数作为参数:在这种情况下,高阶函数可以利用其他函数来执行某些操作。这使得函数的行为可以变得动态和可配置。
  2. 返回一个函数作为结果:高阶函数可以生成新的函数,有时候也称为工厂函数。这允许创建可复用和灵活的代码结构。

高阶函数是函数式编程的核心概念之一

  • 高阶函数可以帮助提高代码的可重用性和模块化,因为它们允许程序员将行为抽象出来并动态地组合不同的功能。这种灵活性使得程序更容易维护和扩展。
  • 通过高阶函数,我们可以实现诸如事件处理、回调机制、装饰器、策略模式等设计模式,从而使代码更加简洁和易于理解

常用高阶函数

mapfilterreduce等常见的高阶函数

功能 Python C# C++ (STL) JavaScript 说明
Map map() Select() std::transform() Array.map() 将函数应用于集合中的每个元素,生成一个新集合
Filter filter() Where() std::copy_if() Array.filter() 过滤集合中的元素,只保留满足条件的元素
Reduce reduce() from functools Aggregate() std::reduce() Array.reduce() 使用累积函数将集合归约为单个值
For Each for loop or list comprehension ForEach() std::for_each() Array.forEach() 对集合中的每个元素执行指定操作
Any any() Any() std::any_of() Array.some() 检查集合中是否存在至少一个满足条件的元素
All all() All() std::all_of() Array.every() 检查集合中是否所有元素都满足条件
Find next() with generator expression FirstOrDefault() std::find_if() Array.find() 返回集合中第一个满足条件的元素

组合函数和管道函数

常规的链式调用也可以达到函数组合的效果,但是链式调用有个很苛刻的条件,那就是返回值必须为this,也就是说链式调用实际上是一种面向对象的思想

  • 管道函数(pipe)

    正向数据流方向

    更符合人类的阅读习惯

  • 组合函数(compose)

    反向数据流方向

    更符合函数式编程理论中的组合原则,这是因为数学函数组合通常以这种从右到左的方式定义

js中使用reduce实现组合:

1
2
3
4
5
6
const compose = (...fns) => (x) =>
fns.reduceRight((acc, fn) => fn(acc), x);

// 使用组合函数
const combinedFunction = compose(subtract, multiply, add);
console.log(combinedFunction(5)); // ((5 + 1) * 2) - 3 = 9

偏函数与柯里化

partial function

通过把已知函数的一个或多个参数设定为特定值的方法创建新函数的概念称为偏函数应用

偏的意思是,在计算函数结果时,只需传递部分参数,而不需要传递所有参数

柯里化

柯里化是偏函数中的一个特例

柯里化每次只固定一个参数

柯里化的必要性: 为了使用高阶函数,统一参数的个数等情况,因此柯里化是必须的

案例

下面展示偏函数和柯里化的案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function add(num1, num2) {
return num1 + num2;
}

const curry = (fun, arg) => {
if (!arg) {
arg = [];
}
//可以逐个接收参数并在参数个数达到原函数所需的数量时调用原函数
return (input) => {
arg.push(input);
if (arg.length >= fun.length) {
return fun(...arg);
} else {
return curry(fun, arg);
}
};
};
//柯里化
const curriedAdd = curry(add);
var result = curriedAdd(1)(2);
console.log(result);//3
//偏函数
const partialAdd = curry(add, [2, 3]);
var result2 = partialAdd();
console.log(result2);//5

偏函数和柯里化各自的适用场景

  • 当有一个要绑定其参数的特定函数时, 偏函数比较有用
  • 当函数可以有任意多个参数时, 柯里化很实用

下面内容还没很明白

单子/例子/函子

单子(Monad)

例子(Maybe Monad)

Monad

  • 简单理解:Monad 是一台可以一步一步进行工作的机器。它会在你完成一步工作之后,根据结果自动决定下一步去做什么。
  • 例子:假设你在盒子里找东西,如果找到继续处理下一个步骤,如果没找到,就停止。这就是 Monad 的工作过程,它帮你顺序处理一系列操作,可以不停地“打包打开盒子”,非常适合处理可能失败或者有风险的步骤。

Functor和Applicative

Functor

  • 简单理解:Functor 就像一个有弹性的手套,你可以把手(函数)放进去操作其中的东西,而不直接接触它。想象一下数据是放在盒子中的,Functor 允许你在不打开这个盒子的情况下对数据进行修改。
  • 例子:想象你有一个 “盒子”(比如一个列表或是一个选项 Maybe),然后你有一个功能去增大里面的所有东西。使用 Functor,你可以不用打开盒子,而是将这个“增大功能”传给盒子,它自己去处理。

Applicative

  • 简单理解:Applicative 就像多条生产线上的机器抓手,可以同时加工多个零件。它不仅能对盒子中的每个元素作同样的操作,还能把多个盒子里的东西配对起来进行操作。
  • 例子:继续上面的比喻,如果你有两个盒子,一个装着数字,另一个装着 +1 的功能,你可以用 Applicative 把数字盒子和功能盒子结合起来,得到结果盒子。

惰性求值

架构相关库盘点

消息队列框架

RabbitMQ

Kafka

依赖注入容器

依赖注入(Dependency Injection,简称DI)容器是一种用于管理对象之间依赖关系的设计模式和工具

它的基本原理是在程序运行时,通过容器自动为对象注入所需要的依赖,而不需要由对象自己创建或管理这些依赖。

在更简单的术语中,依赖注入容器帮助你将对象的创建与对象的使用分开,让你不需要手动处理对象间的依赖关系,容器会自动完成这一过程

容器的作用

依赖注入容器(如 Spring、Guice、Dagger 等)会管理这些依赖关系,并确保类的实例在需要时能够自动获取所依赖的对象。它将这些依赖关系保存在容器内部,并提供一种机制来“注入”这些依赖。

注入的方式

  • 构造函数注入:依赖通过类的构造函数传入。
  • 方法注入:依赖通过类中的某个方法传入。
  • 字段注入:依赖通过类的字段(通常是类的成员变量)传入。

容器的作用

  • 注册组件:开发者将对象或服务(通常称为“bean”或“服务”)注册到容器中,告诉容器这个类需要哪些依赖。
  • 创建对象:当需要创建对象时,容器会根据已注册的信息自动为对象注入依赖项。
  • 管理生命周期:容器不仅负责对象的创建,还可能负责对象的销毁,依赖的管理等。

依赖注入容器的好处

  1. 解耦:对象不再直接负责创建它依赖的对象,而是通过容器来管理,减少了类之间的耦合。
  2. 便于测试:可以通过模拟对象来进行单元测试,不需要担心依赖项的具体实现。
  3. 灵活性:可以动态地调整和替换依赖,方便管理和扩展系统。
  4. 生命周期管理:容器可以管理对象的生命周期,确保每个对象在合适的时机被创建和销毁。

实现一个简易容器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
//自建简易容器
public class Container
{
/// <summary>
/// 将对象设置到依赖注入容器中
/// </summary>
/// <param name="type"></param>
/// <param name="obj"></param>
public void Set(Type type, object obj)
{
// InnerDictionary[type] = obj;
InnerDictionary.TryAdd(type, obj);//避免重复添加,并且线程安全
}

/// <summary>
/// 泛型版本,这样可以在初始化的时候只传一个参数
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="obj"></param>
public void Set<T>(T obj)
{
Set(typeof(T), obj);
}

/// <summary>
/// 从依赖注入容器中获取对象
/// </summary>
/// <returns></returns>
public object Get(Type type)
{
return InnerDictionary[type];
}

/// <summary>
/// Get的泛型版本,这样可以在获取时免传参数
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public T Get<T>()
{
return (T)Get(typeof(T));
}

//用于存储对象和类型,字典实际上不是安全的,下面改成使用线程安全的字典
// private Dictionary<Type, object> InnerDictionary { get; } = new();
ConcurrentDictionary<Type, object> InnerDictionary { get; } = new();
}


public class Foo
{
public string Name { set; get; } = "zeroko";
}

public class Program
{
private static Container _container = new();

private static void Init()
{
Foo foo0 = new Foo();
Foo foo1 = new Foo();
Foo foo2 = new Foo();
// _container.Set(typeof(Foo), foo);//引入泛型Set接口后改成如下使用方式
// _container.Set(foo);

int n = 100;
var taskList = new List<Task>();
for (int i = 0; i < n; i++)
{
var foo = i < n / 2 ? foo1 : foo2;
var task = Task.Run(() =>
{
_container.Set(foo);
});
taskList.Add(task);

}
Task.WaitAll(taskList.ToArray());
var fx = _container.Get<Foo>();
if (object.ReferenceEquals(fx, foo1))//用于判断两个对象实际上是否是同一个
{
Console.WriteLine("foo1");
}
else
{
Console.WriteLine("foo2");
}
}

private static void Use()
{
// Foo foo = _container.Get(typeof(Foo)) as Foo;//引入泛型Get接口后改成如下使用方式
var foo = _container.Get<Foo>();
Console.WriteLine(foo.Name);
}

public static void Main()
{
Console.WriteLine("开始测试");
Init();
Use();
return;
}
}

依赖注入的关键就是,我问你要类型,你给我实例

更详细的依赖注入容器可以参考开源的simpleIOC的代码

依赖注入的实现

上面代码只体现了一个容器,但没有实现类似构造函数注入的特性

实现的核心原理在于: 依赖注入容器通过反射分析类的构造函数和参数类型,然后自动匹配容器中注册的依赖对象

依赖注入容器通过 反射递归依赖解析 来实现。容器会在获取对象时,首先分析该类型的构造函数,查找其依赖的参数类型,并递归地从容器中获取这些依赖项,然后通过构造函数注入这些依赖,从而实现自动注入。这是依赖注入容器的核心特性之一,也是它与手动创建对象的区别之一。

[[CSharp入门#CSharp 反射]]