Tuesday, July 19, 2022

SegmentFault 最新的文章

SegmentFault 最新的文章


Shopee在React Native 架构方面的探索

Posted: 17 Jul 2022 07:11 AM PDT

1. 背景

React Native(下文简称 RN)是混合应用领域流行的跨端开发框架。RN 非常适合灵活多变的电商领域业务,由于 RN 是基于客户端渲染的技术,所以相较于 H5 页面,它在用户体验方面有一定优势。

伴随着 Shopee 业务的飞速发展,我们 App 中的 RN 代码量增长得非常快,出现了构建产物体积过大、部署时间太长、不同团队依赖冲突等问题。为了应对这些痛点,我们探索了去中心化的 RN 架构,并结合该模型自研了系统(Code Push Platform,简称 CPP)和客户端 SDK,覆盖了多团队的开发、构建、发布、运行等一系列 RN 研发周期。经过近三年的迭代,现已接入多款公司级核心 App。

Shopee 商家服务前端团队打造了多款商家端应用,大部分用户是商家服务人员,他们对业务系统高可用和问题及时反馈有着很高的要求,从而也推动我们对 React Native 的架构有了更高的要求。

本文会从发展历史、架构模型、系统设计、迁移方案四个方向逐一介绍我们如何一步步地满足多团队在复杂业务中的开发需求。

2. 发展历程

随着业务高速发展,我们的 RN bundle 个数飞速增加,App 个数也达到近十个。整个 RN 项目在开发模型、部署模型和架构模型三个维度都发生了变化,从单团队发展成多团队,从一个 bundle 发展成多个 bundle,从中心化架构发展成为去中心化,最终发展成为每个团队的业务代码可以独立地开发、部署、运行。

整个发展历史分为 4 个阶段,分别是单 bundle 集中开发模式、单 bundle 多业务组开发模式、多 bundle 中心化发布模式、多 bundle 去中心化发布模式。

image.png

2.1 第一阶段:单 bundle 集中开发模式

最初的 RN 整体技术架构相对简单。由于当时业务形态不算复杂,为了满足独立团队在同一个代码仓库当中的开发流程,整个发布流程是基于 CDN 的更新发布,并且使用配置文件记录 RN bundle 文件的版本以及下载地址,以此进行资源管理。整个发布的产物有两个,一个是 RN 资源包,一个是用于资源版本管理的 JSON 配置文件。

每次 RN 资源在完成构建后,这两种构建产物会被放置在静态资源目录下。App 在特定的时间节点(例如 App 重启等)会自动拉取配置文件检查资源更新状态,然后再从 CDN 拉取 RN 静态资源。在下一次打开页面的时候,App 会加载最新的页面内容。

image.png
随着业务发展,越来越多业务团队期望使用 RN 技术栈开发业务,这种情况让已有架构发生改变,我们自然地产生了"多个业务组多个代码仓库"的想法。

2.2 第二阶段:单 bundle 多业务组开发模式

针对上述问题,多业务组的研发解决方案是 host-plugin 这种模式。

host 用于管理公共依赖和通用逻辑,它将 React、React Native、Shopee RN SDK 等通过一个独立的仓库管理起来,保证了特殊 RN 依赖的"singleton"(单例模式)条件,避免了部分客户端组件的重叠依赖,而这种重叠依赖是 RN 官方不允许的。

一个 host 对应着多个 plugin 仓库,业务代码仓库则是被看作为一个插件(plugin),以插件的形式接入主应用当中。业务团队可以按自己的编码规范来管理这个仓库。每个插件仓库会被视为 host 项目的 npm 依赖,它的构建是一个集中发布的流程。所有代码都会集成在 host 项目当中执行构建脚本。这种模式满足超级 App 的要求。

image.png
与此同时,host-plugin 的模式也带来了一个"难题",业务发展使得 RN 产物体积逐渐变大,过大的产物会影响客户端的解压效率和 RN 容器加载 JS 时长。

2.3 第三阶段:多 bundle 中心化架构模式

针对 RN 产物体积过大的问题,我们利用构建工具将打包产物细分成多个 bundle,这一优化是非常有必要的,我们称它为"分包"。host 项目对应的是公共包,plugin 项目对应的是业务包。

整个构建发生在 host 项目,项目的模式还是"集中构建"和"集中发布"。多 bundle 产物将会发布到系统当中,客户端将拉取热更新的内容。客户端会按需加载对应的 bundle,RN 容器单次加载消耗的资源大大减少,解决了效率问题。

image.png
但是它的缺点也很明显。随着业务团队的变大和业务内容的扩张,多 bundle 中心化发布模式同样也具备四个弊端:

  • 针对 RN 的运行时,即使分包技术使得产物分离,但是它们还是运行在同一个 JSContext 当中,这种情况可能会导致依赖冲突和环境变量污染;
  • 在开发调试的过程中,项目重依赖于 host 项目,每次存在着代码变更,需要重新加载很多内容,让开发调试不太友好;
  • 在项目构建的过程中,打包速度受到 plugin 个数的影响,特大型应用甚至需要 50 分钟执行一次构建,过长的构建耗时严重影响了发布效率;
  • 在部署发布的过程中,host 项目维护者负责整个 App,每个业务组不能独立发布,发布时间会绑定在一起。当出现 live issue 的情况,开发者需要花费大量的沟通成本,且只能整体回滚。

2.4 第四阶段:多 bundle 去中心化架构模式

去中心化 React Native 架构模式与网页的"微前端"或者客户端的"微应用"的概念类似,满足了多业务团队独立开发部署,能够在同一个 App 各模块独立运行。它涵盖了开发、构建、发布、运行等多个方面。该模型解决了上面所说的四个弊端,并针对整个研发体系有了全面的升级,优点有:RN 运行时的互不干扰,开发调试的高效,构建发布的独立性。

下文会重点介绍项目的去中心化 RN 架构和系统设计,以及我们是怎样做到灵活性和稳定性的平衡的。

3. 去中心的 RN 架构模型

简单来说,去中心化的 RN 发布模型涉及到四个部分:独立的 JS 运行时;独立的开发流程;独立的构建流程;独立的发布流程。在这四个关键环节的帮助下,每个团队按自己的节奏掌控 RN 的研发流程。

3.1 独立 JS 运行时

独立运行时(多 JSContext,执行上下文环境)的出现是去中心化架构的最大特色。独立运行时是对独立发布的完美保证,将 RN 运行代码按照 plugin 维度进行隔离,它可以有效避免不同业务之间的变量冲突以及依赖冲突问题,即"plugin A"的发布绝对不会影响到"plugin B"。

它的设计主要包含以下三点:

  • 提前创建 JSContext 且预加载公共包;
  • 进入 plugin 的页面,SDK 会查看对应的 JSContext 是否已被实例化。如果已经被实例化,就直接使用,否则从 JSContext Pool 选取一个独立的上下文,加载执行业务包,各个 plugin 之间运行时是隔离的;
  • 退出业务页面时,该 JSContext 不会立即销毁,而是放入一个缓存池,使得重复进入该业务可以获得极致体验。

image.png
装置 JSContext 的容器可以是线程或者进程。为了避免它频繁创建和回收,我们要维护缓存池且尽可能地复用现有的 JSContext。

这里我们采用 Least Frequently Recently Used(简称 LFRU)的策略。当刚退出的应用被重新打开,该 JSContext 会被重新启用。这样,我们能够节省 85% 的首屏渲染时长。缓存个数管理是可配置的,业务方可以根据应用的规模作为合理的预估。当该 RN 页面还在使用中,即使超出预估数,该上下文也不会立即被回收,该设计有效地保证页面的可用性。

image.png

3.2 开发流程

上文提及 RN 项目的调试效率问题,它会随着业务代码的体量增多,代码调试效能也会随之下降。每个开发者的效率问题直接影响到大家的"幸福感"。相比之下,RN 去中心化发布则是针对开发流程做了特定的优化。

随着独立运行时环境的出现,RN 进入调试的时候,客户端可以做到只加载一个 plugin 到对应的 JSContext 中,其他 plugin 则采用内置 cache。

这样做有两个好处:一是保证了服务启动范围的最小化,保证了代码热加载的效率;二是确保开发和构建两种流程的一致性,这样会让一些问题在开发阶段提前暴露出来,比如 babel 插件缺失导致的编译问题。这样的"去中心化"的开发流程提高了 RN 调试效率。

image.png

3.3 构建流程

随着业务发展,某 App 的 RN plugin 数有 4 个,旧构建流程受到 plugin 个数的影响,集中构建时长超过 20 分钟。而采用去中心化 RN 架构,构建时长不再随 plugin 个数增长,只和该 plugin 代码量有关,稳定在 5 分钟左右。

新架构也是同样基于 host-plugin 模型,独立仓库的隔离让每个团队有自由的发展空间。考虑到在应用内的基础 Native 依赖是统一的,host 项目仅用来管理统一的公共依赖。项目需要优先将 common bundle 构建完成,系统会记录公共包中的依赖信息。当每个 plugin 项目进行构建的时候,构建工具会剔除掉公共包依赖信息,并完成业务包的构建。每个业务包的构建产物都是独立地存放于系统当中。系统具备独立回滚、独立发布、独立灰度的能力。

这样的好处在于构建任务的最小粒度化,每个 plugin 的构建不会引起整个项目的重新构建,做到真正意义的"按需打包"。

image.png

3.4 发布流程

RN 的构建和发布是两个独立的流程。这也意味着 bundle 的构建环节和发布环节完全解耦,发布时间点也可以由每个业务团队发布负责人灵活安排。每个业务组对自己的代码质量负责,灵活地把控自己的发版本节奏,不会影响其他团队线上业务。发布流程里面包含了全量发布、联合发布、灰度发布、回滚等操作,后续章节会详细介绍如何保证发布的稳定性。

4. 系统设计

对于复杂的大型项目来说,简单的热更新流程已无法满足多业务组协同合作,我们需要一个功能完善、性能优越、操作友好的热更新系统来满足复杂业务的发展。Code Push Platform 由 Node.js 编写,搭配系统附属的命令行工具和客户端 SDK。
image.png
为了满足该系统在多业务团队的运作,整个系统从功能角度可以划分为三大部分,分别是:

  • 多团队权限管控;
  • bundle 生命周期管理;
  • 系统效能提升。

其中,系统效能提升功能又细分为:

  • 增量差分;
  • 多场景入口体积优化;
  • 一站式多环境整合。

4.1 多团队权限管控

系统除了记录每次构建操作,更重要的是工作流程的去中心化,每个 plugin 的权限是隔离的。每个负责人只能在系统内部操作,plugin 1 的负责人只能触发相关的构建和发布,没法看到 plugin 2 的操作情况。系统通过严格的权限管控来规范所有发布流程,保证了项目的可控性。

image.png
React Native 去中心化发布的设计目标是节省不同团队之间的沟通成本。系统会限制他们的构建和发布的动作,各自的发布不会互相干扰。

权限的管理呈树状结构,一个 App 对应着一个项目,项目负责人默认是 App 团队的项目负责人。创建一个全新的插件等系统操作需要项目负责人审批。一个 App 包含有多个 plugin,每个 plugin 负责人默认是相应的业务团队负责人,他有权限分配发布和构建的权限。

image.png

4.2 bundle 生命周期管理

4.2.1 客户端版本控制

RN 有别于网页应用,它对客户端有着紧密的依赖关系。在客户端底层依赖没有变化的情况下,一般情况下开发者可以通过热更新进行 RN 代码的更新。但是遇到重大的更新,例如 React Native 的版本从 59 升级到 63,不仅仅需要 JavaScript 侧改动,客户端也要升级版本且没法继续向下兼容。从技术层面看,它是难以避免的。这种客户端无法向下兼容的情况,被称为"断层"。

系统会提供客户端版本控制的能力。当重大变更出现时,App 负责人应该在系统上新建一个"断层信息",版本号的范围是从最低 App 兼容版本到最高 App 版本。在这个区间客户端才能拉取到该断层的最新 RN 资源。

如下表所示,大于等于 2.5.0 版本的 App 拉取的是 105 版本 RN 包;在 2.0.0 至 2.5.0 版本拉取到 103 版本 RN 包;在 1.0.0 至 2.0.0 版本拉取到 100 版本 RN 包。
image.png
这种措施能够有效避免潜在风险。而最新的需求只会在最新断层上线,旧的断层只做线上问题修复。毕竟是两套代码,代码的维护有成本,随着用户更新至最新版本,应当逐渐淘汰掉旧断层。

4.2.2 灰度和回滚

发布流程里面包含了全量发布、灰度发布、回滚等操作。对于大型需求,全量上线会带来潜在风险。一般来说,优先针对部分用户投放新版本,发布负责人可以根据指定用户和特定范围进行灰度发布,逐步扩大灰度发布范围,直至转到全量。当发现重大 bug 的时候,发布者可以采用"零构建"的方式进行"秒级"回滚。

去中心化 RN 架构支持每个 plugin 独立发布、独立灰度、独立回滚,以最小颗粒度的操作来保证质量规避风险。plugin 维度级别的灰度和回滚能够为不同的业务团队带了灵活性,每个业务团队可以自行发布版本,控制灰度节奏,处理线上问题。

4.3 系统效能提升

4.3.1 差分增量

App 频繁更新 RN 资源包会造成对用户流量的消耗,最有效的方式是利用增量更新来节省流量。RN 资源包涵盖了编译后的 JavaScript 产物、图片、翻译文件等静态资源。它们的前后版本差异即是该版本变更的代码或者其他资源文件。为了让差分粒度深入到资源包内部,系统专门提供独立的"差分服务",采用二进制差分的方式对构建产物进行差分。

RN 资源包的 diff(差分)操作在服务端完成 ,patch(整合)操作在 App 端完成。在去中心化 RN 架构中,每个 plugin 的差分都是独立的。plugin 的发布会自动触发差分的执行,系统会以 plugin 为维度拉取最近五个版本,Diff Server 则会依次将它们和当前版本进行差分计算。如果计算成功,会将差分结果上传到 CDN 并反馈给系统,否则继续重试。整个差分操作是一个异步的过程,即使出现"差分服务"下线等极端情况,系统会自动降级为全量包,保证系统的可用性。

image.png

4.3.2 多场景入口体积优化

由于 React Native 的构建官方依赖于 metro.js,而它并没有具备无用代码剔除(tree-shaking)的能力。随着业务代码的膨胀,包体积的优化是一个很重要的问题。

例如,ShopeePay 为公司多款核心 App 提供支付业务。ShopeePay plugin 在不同地区、不同 App 之间存在一些页面级别差异。同一个仓库含了所有代码和资源,但是构建脚本会将它们都会打包成为一个产物。很明显,这导致 ShopeePay 的发布产物包含大量冗余资源,并非最优,浪费下载流量,同时也影响代码的执行效率。

我们采用自研的多场景插件(babel-plugin-scene),该插件通过注入的环境变量设置一个场景值,babel 可以根据场景值的差异化加载不同的文件,并且以默认文件作为降级兜底。不同场景对应不同的入口文件,利用这种形式可以有效控制包体积。

image.png

4.3.3 一站式多环境融合

一个正常的研发流程是从 test 环境,到 uat 环境,再到 live环境。Code Push Platform 对接了 App 的 test/uat/live环境,所以 RN 开发者只需要在该系统就可以进行"一站式"的操作,方可满足一个需求的整个研发周期。

不同环境的包资源流转,是多环境融合的一大亮点。如果某 RN bundle 在 uat 环境构建,它也不需要重新构建,将 bundle 无缝转换到 线上 环境进行发布。它带来的优势在于"零构建时长"以及资源包的稳定性,因为 bundle 没有重新进行构建,所以它的内容已经在 uat 得到了充分的验证,发布风险更小。

image.png

5. 旧业务的迁移方案

如何迁移现有业务的 App 是一个非常严肃的问题,特别是历史背景较重的业务,它们可能存在"逻辑耦合"或者"组件耦合"的场景。与此同时,很多相关业务都在需求迭代当中,系统的迁移是不能阻碍需求迭代,所以旧业务"渐进式迁移"方案是非常必要的。

5.1 逻辑耦合

如果两个以上 plugin 存在逻辑依赖关系,用户必须同时加载到最新的 plugin。考虑到热更新失败的可能性,逻辑耦合就是多个 plugin 隐藏着一种约束关系。例如,订单业务和购买业务存在一定的逻辑耦合关系,发布负责人针对流量极大的超级 App,不可能逐个发布 plugin。在极端的状态下,用户可能会先加载到 plugin A,新版本的 plugin A 和旧版本的 plugin B 是不兼容的,这样会带来严重后果。遇到这种情况,有两种解决方案:

  • 方案一:plugin 间逻辑解耦,保证每个 plugin 的独立性。
  • 方案二:系统提供了联合发布,在 Native 侧保证多个 plugin 能够同时加载到最新。

方案一是最理想化的状态,但是在业务场景细分的情况下,项目结构很难做到绝对独立。

针对老业务可以考虑方案二,系统提供了 module 的概念,一个 module 对应着两个以上的 plugin。它们存在着一个绑定的关系。在同一个下载任务里面,客户端 SDK 以"事务"形式,保证多个 plugin 能够同时下载完成并投入使用。联合发布这个能力在系统层面,有效规避这种错误的可能性。

image.png

5.2 组件耦合

如果说联合发布是针对在 plugin 维度的"逻辑耦合"兼容方案,"组件耦合"则是更细粒度的组件级别的耦合关系。也就是说,一个页面中存在多个组件来自不同的团队,例如商品详情页等页面有评价功能组件。这种"一个页面存在着 JSContext 相互嵌套"的情景存在于电商业务当中。

针对这种"组件耦合"情况,有两种解决方案:

  • 方案一:嵌套组件抽离成为一个独立仓库,供第三方 plugin 使用。
  • 方案二:使用"同屏渲染"的能力实现"多 Context 嵌套"。

方案一是最理想的解决方案。但是考虑到迁移成本,我们也提供了方案二(一种"同屏渲染"嵌套组件)来支持这种场景,它类似一种 Native 组件。在多个 JSContext 的情况下,通过 plugin 名和页面名将所需要的内容嵌套到另一个页面当中。

如下图所示,plugin A 会嵌套 plugin B 的内容,A 和 B 也可以实现在同一个屏幕进行渲染。从 Web 的方向理解,这种情况有点像 "iframe" 的场景,支持多个页面的嵌套。它非常易于 RN 开发者的理解,客户端 SDK 能够动态加载目标 bundle 并将它渲染在合适的位置。

image.png

5.3 渐进式迁移

对于现有的 App,因为业务没法暂停迭代,我们难以一次性完成整体迁移。因此,我们提供了"渐进式迁移"方案。考虑到历史背景,该方案不会一次性把所有 plugin 都迁移,而是逐步拆分,再迁移接入到新发布系统。

迁移的步骤如下图所示:

  • 优先将独立的业务迁移到 Code Push Platform,它们享用一个独立的 JSContext;
  • 所有"待拆分代码"共用一个独立的JSContext;
  • 将"待拆分代码"继续拆分成几个独立 plugin,独立使用 JSContext,其他内容则保持步骤二的状态。

随着版本迭代,重复第二和第三步骤,直至历史业务全部拆分完毕。这样我们可以达到一个最优的目标,即是真正意义的"独立构建"和"独立发布"。

image.png

6. 总结

该系统的目标在于满足所有 App 的多团队研发协作效率问题,去中心化 RN 发布模型考虑到"独立运行时"、"独立开发"、"独立构建"、"独立发布"四大方面,保障了每个 plugin 运行的独立性。最终目标在于支撑 Shopee 的多个 RN 团队在不同 App 平台根据自己节奏自由发布且高效运作。

系统设计涉及到"多团队权限管控"、"客户端版本控制"、"灰度和回滚"、"增量差分"、"多入口包体积优化"、 "一站式多环境融合",加速了整个研发流程,真正做到了"灵活性"和"稳定性"的兼得。

一个 Java 猿眼中 Vue3 和 Vue2 的差异

Posted: 17 Jul 2022 11:31 PM PDT

随着 TienChin 项目视频的录制,松哥终于也要静下心来,认真捋一捋 Vue3 中的各种新特性了,然后再和小伙伴们进行分享,其实 Vue3 中还是带来了很多新鲜的玩意,今天我们就不卷 Java 了,来卷卷前端。

以下内容是一个 Java 猿对 Vue3 的理解,主要是应用层面上,如果有专业的前端小伙伴,请轻拍。

1. script 写法

进入到 Vue3 时代,最明显的感受就是在一个 .vue 文件中,script 标签的写法大变样了。以前在 Vue2 中,我们都是这样写的:

<script>     export default {         name: "SysHr",         data() {             return {                 //             }         },         mounted() {             //         },         methods: {             deleteHr(hr) {                 //             },             doSearch() {                 //             }         }     } </script>

不过到了 Vue3 里边,这个写法变了,变成下面这样了:

<template>     <div>         <div>{{a}}</div>         <div>{{result}}</div>         <button @click="btnClick">clickMe</button>     </div> </template> <script>      import {ref} from 'vue';     import {onMounted,computed} from 'vue'      export default {         name: "MyVue01",         setup() {             const a = ref(1);             const btnClick=()=>{                 a.value++;             }             onMounted(() => {                 a.value++;             });             const result = computed(()=>{                 return Date.now();             });             return {a,btnClick,result}         }     } </script>

先从大的方面来看,细节实现咱们后面再细聊。

大的方面,就是在这个 export default 中,以后就只有两个元素了,name 和 setup,我们以前的各种方法定义、生命周期函数、计算属性等等,都写在 setup 中,并且需要在 setup 中返回,setup 中返回了什么,上面的 template 中就能用什么。

这种写法稍微有点费事,所以还有一种简化的写法,像下面这样:

<template>     <div>         <div>{{a}}</div>         <div>{{result}}</div>         <button @click="btnClick">clickMe</button>     </div> </template>  <script setup>      import {ref} from 'vue';     import {onMounted, computed} from 'vue'      const a = ref(1);     const btnClick = () => {         a.value++;     }     onMounted(() => {         a.value++;     });     const result = computed(() => {         return Date.now();     }); </script>

这种写法就是直接在 script 标签中加入 setup,然后在 script 标签中该怎么定义就怎么定义,也不用 return 了。这个场景,又有点 jQuery 的感觉了。

上面这个实现里有几个细节,我们再来详细说说。

2. 生命周期

首先就是生命周期函数的写法。

以前 Vue2 里的写法有一个专业名词叫做 options API,现在在 Vue3 里也有一个专业名词叫做 composition API。在 Vue3 中,这些对应的生命周期函数都要先从 vue 中导出,然后调用并传入一个回调函数,像我们上一小节那样写。

下图这张表格展示了 options API 和 composition API 的一一对应关系:

options APIcomposition API
beforeCreateNot Needed
createdNot Needed
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeUnmountonBeforeUnmount
unmountedonUnmounted
errorCapturedonErrorCaptured
renderTrackedonRenderTracked
renderTriggeredonRenderTriggered
activatedonActivated
deactivatedonDeactivated

想用哪个生命周期函数,就从 vue 中导出这个函数,然后传入回一个回调就可以使用了。例如第一小节中松哥给大家举的 onMounted 的用法。

3. 计算属性

除了生命周期函数,计算属性、watch 监听等等,用法也和生命周期函数类似,需要先从 vue 中导出,导出之后,也是传入一个回调函数就可以使用了。上文有例子,我就不再啰嗦了。

像 watch 的监控,写法如下:

<script>      import {ref} from 'vue';     import {onMounted,computed,watch} from 'vue'      export default {         name: "MyVue01",         setup() {             const a = ref(1);             const btnClick=()=>{                 a.value++;             }             onMounted(() => {                 a.value++;             });             const result = computed(()=>{                 return Date.now();             });             watch(a,(value,oldValue)=>{                 console.log("value", value);                 console.log("oldValue", oldValue);             })             return {a,btnClick,result}         }     } </script>

导入 watch 之后,然后直接使用即可。

4. ref 于 reactive

上面的例子中还有一个 ref,这个玩意也需要跟大家介绍下。

在 Vue2 里边,如果我们想要定义响应式数据,一般都是写在 data 函数中的,类似下面这样:

<script>     export default {         name: "SysHr",         data() {             return {                 keywords: '',                 hrs: [],                 selectedRoles: [],                 allroles: []             }         }     } </script>

但是在 Vue3 里边,你已经看不到 data 函数了,那怎么定义响应式数据呢?就是通过 ref 或者 reactive 来定义了。

在第一小节中,我们就是通过 ref 定义了一个名为 a 的响应式变量。

这个 a 在 script 中写的时候,有一个 value 属性,不过在 HTML 中引用的时候,是没有 value 的,可千万别写成了 {{a.value}},我们再来回顾下上文的案例:

<template>     <div>         <div>{{a}}</div>         <button @click="btnClick">clickMe</button>     </div> </template>  <script>      import {ref} from 'vue';      export default {         name: "MyVue04",         setup() {             const a = ref(1);             const btnClick=()=>{                 a.value++;             }             return {a,btnClick}         }     } </script>

现在就是通过这样的方式来定义响应式对象,修改值的时候,需要用 a.value,但是真正的上面的 template 节点中访问的时候是不需要 value 的(注意,函数也得返回后才能在页面中使用)。

和 Vue2 相比,这种写法有一个很大的好处就是在方法中引用的时候不用再写 this 了。

ref 一般用来定义原始数据类型,像 String、Number、BigInt、Boolean、Symbol、Null、Undefined 这些。

如果你想定义对象,那么可以使用 reactive 来定义,如下:

<template>     <div>         <div>{{a}}</div>         <button @click="btnClick">clickMe</button>         <div>{{book.name}}</div>         <div>{{book.author}}</div>     </div> </template>  <script>      import {ref, reactive} from 'vue';      export default {         name: "MyVue04",         setup() {             const a = ref(1);             const book = reactive({                 name: "三国演义",                 author: "罗贯中"             });             const btnClick = () => {                 a.value++;             }             return {a, btnClick,book}         }     } </script>

这里定义了 book 对象,book 对象中包含了 name 和 author 两个属性。

有的时候,你可能批量把数据定义好了,但是在访问的时候却希望直接访问,那么我们可以使用数据展开,像下面这样:

<template>     <div>         <div>{{a}}</div>         <button @click="btnClick">clickMe</button>         <div>{{name}}</div>         <div>{{author}}</div>     </div> </template>  <script>      import {ref, reactive} from 'vue';      export default {         name: "MyVue04",         setup() {             const a = ref(1);             const book = reactive({                 name: "三国演义",                 author: "罗贯中"             });             const btnClick = () => {                 a.value++;             }             return {a, btnClick,...book}         }     } </script>

这样,在上面访问的时候,就可以直接访问 name 和 author 两个属性了,就不用添加 book 前缀了。

不过!!!

这种写法其实有一个小坑。

比如我再添加一个按钮,如下:

<template>     <div>         <div>{{a}}</div>         <button @click="btnClick">clickMe</button>         <div>{{name}}</div>         <div>{{author}}</div>         <button @click="updateBook">更新图书信息</button>     </div> </template>  <script>      import {ref, reactive} from 'vue';      export default {         name: "MyVue04",         setup() {             const a = ref(1);             const book = reactive({                 name: "三国演义",                 author: "罗贯中"             });             const btnClick = () => {                 a.value++;             }             const updateBook=()=>{                 book.name = '123';             }             return {a, btnClick,...book,updateBook}         }     } </script>

这个时候点击更新按钮,你会发现没反应!因为用了数据展开之后,响应式就失效了。所以,对于这种展开的数据,应该再用 toRefs 来处理下,如下:

<template>     <div>         <div>{{a}}</div>         <button @click="btnClick">clickMe</button>         <div>{{name}}</div>         <div>{{author}}</div>         <button @click="updateBook">更新图书信息</button>     </div> </template>  <script>      import {ref, reactive, toRefs} from 'vue';      export default {         name: "MyVue04",         setup() {             const a = ref(1);             const book = reactive({                 name: "三国演义",                 author: "罗贯中"             });             const btnClick = () => {                 a.value++;             }             const updateBook = () => {                 book.name = '123';             }             return {a, btnClick, ...toRefs(book),updateBook}         }     } </script>

当然,如果你将 setup 直接写在了 script 标签中,那么可以直接按照如下方式来展开数据:

<template>     <div>         <div>{{a}}</div>         <button @click="btnClick">clickMe</button>         <div>{{name}}</div>         <div>{{author}}</div>         <button @click="updateBook">更新图书信息</button>     </div> </template>  <script setup>      import {ref, reactive, toRefs} from 'vue';      const a = ref(1);     const book = reactive({         name: "三国演义",         author: "罗贯中"     });     const btnClick = () => {         a.value++;     }     const updateBook = () => {         book.name = '123';     }     const {name, author} = toRefs(book); </script>

5. 小结

好啦,今天就和小伙伴们分享了 Vue3 中几个新鲜的玩法~作为我们 TienChin 项目的基础(Vue 基本用法在 vhr 中都已经讲过了,所以这里就不再赘述了),当然,Vue3 和 Vue2 还有其他一些差异,这些我们都将在 TienChin 项目视频中和小伙伴们再仔细分享。

可能改变前端工程化未来的特性:ESM Loader Hooks

Posted: 17 Jul 2022 08:09 PM PDT

大家好,我卡颂。

在最近发布的Node v18.6.0中,带来了一个试验特性ESM Loader Hooks API

如果他最终落地,很可能会成为改变前端工程化未来的特性。本文我们来聊聊他。

欢迎加入人类高质量前端框架群,带飞

本文参考:

Custom ESM loaders: Who, what, when, where, why, how

特性简介

用过webpack的朋友一定知道webpack中有个loader的概念,用于加载并处理不同类型文件,比如css-loaderurl-loader

loader的执行顺序取决于webpack内部对文件树解析、遍历的顺序。

今天要介绍的ESM Loader Hookswebpack loader类似,只不过对文件树的解析、遍历是由Node.js原生支持的ESM规范(而不是打包工具)确定的。

通过定义不同loader,就能在不使用工程化工具的前提下,对项目中各个ESM模块进行处理。

举个例子,在命令行通过--experimental-loader指令开启特性后,执行如下语句:

$> node --loader redirect.mjs app.mjs

其中,app.mjs待处理的源文件.mjs后缀指代该文件是个ESM模块(相对应的,.cjs后缀指CJS模块)。

--loader用于指定自定义的ESM Loader,这里指定的是redirect.mjsapp.mjs会交由redirect.mjs处理。

redirect.mjs代码如下:

// redirect.mjs export function resolve(specifier, context, nextResolve) {   let redirect = 'app.prod.mjs';    switch(process.env.NODE_ENV) {     case 'development':       redirect = 'app.dev.mjs';       break;     case 'test':       redirect = 'app.test.mjs';       break;   }    return nextResolve(redirect); }

redirect.mjs会根据Node当前所属环境改写文件的引入路径。

比如在开发环境(process.env.NODE_ENV === 'development'),app.mjs经由redirect.mjs处理,会重定向到app.dev.mjs

ESM Loader Hooks API中之所以带Hooks字眼,是因为每个自定义ESM Loader,都可以像钩子(Hooks)一样连接其他自定义ESM Loader(或者Node.js提供的默认ESM Loader)。

比如在如下语句中:

$> node --loader c.mjs --loader b.mjs --loader a.mjs app.mjs

app.mjs会依次经过a b c三个自定义ESM Loader处理。

整个过程就像一个promise.then链条(事实上,每个ESM loader确实会返回一个promise)。

实际例子

来看一个更接近日常开发的例子,考虑如下ESM模块:

// app.tsx import ReactDOM from 'react-dom/client'; import {   BrowserRouter,   useRoutes, } from 'react-router-dom';  import App from './AppHeader.tsx';  import routes from 'https://example.com/routes.json' assert { type: 'json' };  import './global.css' assert { type: 'css' };  const root = ReactDOM.createRoot(document.getElementById('root'));  root.render(   <BrowserRouter>     <App />     <main>{useRoutes(routes)}</main>   </BrowserRouter> );

其中包括很多Node.js不能处理的部分,比如:

  • TS语法(需要编译成JS,并处理文件描述符为Node.js可识别的形式)
  • JSX转换(需要编译成React.createElementjsxRuntime.jsx
  • 需要处理引入的CSS文件
  • 需要处理远程引入的模块(代码中引入routes的语句)

处理CSS文件

以处理CSS文件举例,假设CSS文件内容如下:

.Container {   border: 1px solid black; }  .SomeInnerPiece {   background-color: blue; }

如果为了测试目的,仅需要生成类名对应快照即可,所以可以实现一个简易的CSS loader,处理输入的CSS文件,将结果输出为Node.js可执行的JSON格式:

{    "Container": "Container",    "SomeInnerPiece": "SomeInnerPiece" }

参考:CSS loader的简易实现

处理远程引入的模块

再以处理处理远程引入的模块举例。

当识别到https://开头的文件描述符(即import声明或import()表达式中字符串的部分)时, 可以利用https模块发起请求,返回请求对应promise

import { get } from 'https';  export function load(url, context, nextLoad) {   if (url.startsWith('https://')) {     return new Promise((resolve, reject) => {       get(url, (res) => {         let data = '';         res.on('data', chunk => data += chunk);         res.on('end', () => resolve({           format: 'module',           shortCircuit: true,           source: data,         }));       }).on('error', err => reject(err));     });   }    return nextLoad(url, context); }

参考:Https loader的简易实现

总结

ESM Loader Hooks特性趋于稳定,配套的loader生态足够丰富后,很多原来需要打包工具才能实现的工程化需求都能用Node.js原生解决。

比如,要处理上述提到的app.tsx文件,只需执行如下命令:

$> node --loader typescript-loader --loader css-loader --loader network-loader app.tsx

你觉得这个特性对未来前端工程化会有多大影响呢?

CSS color-scheme 和夜间模式

Posted: 17 Jul 2022 08:00 PM PDT

欢迎关注微信公众号:前端侦探

介绍一个和深色模式相关的CSS属性:color-scheme

一、什么是 color-scheme?

color-scheme顾名思义,即为"配色方案",在系统中指的是"白天模式"和"夜间模式"。使用这个属性可以轻松的更改浏览器的默认配色方案,语法如下

color-scheme: normal; color-scheme: light; color-scheme: dark; color-scheme: light dark;

几个关键词如下

normal:表示元素未指定任何配色方案,因此应使用浏览器的默认配色方案呈现。
light:表示可以使用操作系统亮色配色方案渲染元素。
dark:表示可以使用操作系统深色配色方案渲染元素。

下面来看一个简单的例子

<h2>前端侦探</h2> <button>关注我</button>

在无任何CSS的情况下,效果如下

image-20220702164619190

如果将系统配色设置为深色模式,由于什么都没有做,当然也不会有什么变化,如下

image-20220702164803001

现在在根元素上添加color-scheme属性

:root{   color-scheme: light dark }

这里设置两个值,表示可选的配色方案,由系统来决定。

官方文档上虽然说是优先选择,实测两种顺序没有区别,有知道的小伙伴可以留言指点

效果如下

Kapture 2022-07-02 at 17.19.10

是不是非常神奇?平平无奇的页面马上就支持深色模式了。

还可以指定单个值,这样就和系统配色无关了,比如

:root{   color-scheme: dark }

这样在浅色模式下也能以深色主题来渲染,效果如下

image-20220702172224904

二、color-scheme 的作用范围

通过上面的例子看着好像很强大,是不是可以一键生成"深色"模式了?其实不然,color-scheme 的作用范围很有限,包括表单控件滚动条CSS 系统颜色的使用值

1. 表单元素

先看表单元素,就拿 checkbox来说吧

<input type="checkbox">

image-20220702174426988

假设现在我们需要做一个深色模式的主题,手动将页面背景设置为黑色

body[dark]{   background: #000 }

image-20220702174537277

是不是感觉到未勾选状态有点太过刺眼了呢?

这时,就可以用到color-scheme了,可以将表单元素以深色模式渲染

body[dark]{   background: #000;   color-scheme: dark }

image-20220702174854928

这样是不是柔和多了?这里选中的主题色貌似也发生了变换,这个是浏览器为了方便夜间浏览默认设置的,如果你不希望这个选中颜色,可以用accent-color来覆盖

body{   accent-color: #0175ff }

效果如下

image-20220702183201037

accent-color可以更改表单元素的默认颜色,有兴趣的可以参考张鑫旭的这篇文章 CSS accent-color属性简介

下面是其他表单元素的深色模式

image-20220704163752288

2. 滚动条

color-scheme也能改变滚动条的配色模式。这个在 windows 下比较明显,默认浅色模式下,滚动条是这种灰白色的

企业微信截图_16567581171385

如果在没有自定义滚动条的情况下(很多设计觉得默认滚动条挺好看的),设计了一套深色主题,可能就变成了这样

企业微信截图_16567561585032

滚动条是不是显得非常突兀?是不是只能通过伪类自定义滚动条样式了呢?现在有了color-scheme就不需要这么麻烦了,直接设置如下

body[dark]{   color-scheme: dark }

这样是不是和谐多了?

image-20220702184405473

其实 MDN 官网已经这么做了,上面只是临时屏蔽了这一属性

企业微信截图_16567586027558

3. CSS 系统颜色

系统颜色指的是浏览器内置的一些颜色。比如前面的button,为什么能自动渲染成深色模式呢?除了因为它是表单元素外,最根本的一点是这些表单元素的默认样式上使用了系统颜色。注意看下面这个截图

image-20220703112802452

可以看到,button的默认样式里,使用到了一些系统颜色

button{   /**/   color: buttontext;   background-color: buttonface;   border-color: buttonborder; }

这里的buttontextbuttonfacebuttonborder就是系统颜色了,它会根据color-scheme自动适应深色模式

image-20220703113337440

这些系统颜色不仅仅可以用在表单元素上,也能用在普通元素上,比如这里取按钮文本颜色buttontext

div{   background-color: buttontext; }

这个颜色会在深色模式下自动变成白色,如下

image-20220703114939552

如果手动的指定了一些正常颜色,那么也就失效了

button{   color: #333 }

image-20220703122050588

所以,综合上面所述,只有系统相关的样式和颜色才可以受到color-scheme的影响。

完整的 CSS 系统颜色可以参考官方 MDN 文档 ,比较有限,而且颜色都是那种黑白等高饱和度颜色,酌情使用

三、color-scheme 和 prefers-color-scheme

相比color-scheme,大家可能更熟悉prefers-color-scheme,它用于检测用户是否有将系统的主题色设置为亮色或者暗色。

.day   { background: #eee; color: black; } .night { background: #333; color: white; }  @media (prefers-color-scheme: dark) {   .day.dark-scheme   { background:  #333; color: white; }   .night.dark-scheme { background: black; color:  #ddd; } }

那么,它和color-scheme有什么联系呢?举个例子

:root{   color-scheme:  dark; } @media (prefers-color-scheme: dark) {   body {     color: red   } }
请问,在浅色模式下,body 的颜色是什么?

🤔

🤔

🤔

🤔

🤔

🤔

下面来看实际结果

image-20220703120956764

只有当系统真正切换到深色模式,才会变成红色

image-20220703121100050

所以结论是,color-schemeprefers-color-scheme没有必然联系,并不会干涉prefers-color-scheme的判断,但是是相辅相成的,color-scheme 可以更好的处理系统默认的一些样式,而 prefers-color-scheme可以更方便的自定义其他普通样式。

四、总结一下

综上所述,为了更好的支持深色模式,可以在常规的深色模式下添加color-scheme作为兜底方案,可以很好的适配浏览器原生样式

:root{   color-scheme: light dark }

这样一个小知识点,学到了吗?下面是本文要点总结

  1. color-scheme 是原生支持的配色方案,支持"白天模式"和"夜间模式",可以轻松的更改浏览器的默认配色方案
  2. color-scheme 支持的对象有表单控件滚动条CSS 系统颜色
  3. CSS 系统颜色指的是浏览器内置的颜色,这些颜色是动态的
  4. 但是系统颜色比较有限,而且颜色都是那种黑白等高饱和度颜色,酌情使用
  5. 媒体查询 prefers-color-scheme 不会受到 color-scheme 的影响,只和系统设置有关
  6. 平时使用中 color-scheme 和 prefers-color-scheme 需要相互配合, color-scheme 适配原生,prefers-color-scheme 适配自定义

然后提一下兼容性,其实对版本要求还挺高的

image-20220706152054928

但是,这并不影响我在项目中使用。原因很简单,这算得上是渐进增强的属性,即使浏览器不支持,也不会对页面造成什么影响,如果支持,体验会更好。所以,赶紧使用起来吧,就一行代码的事,无形之中提示了视觉体验。

最后,如果觉得还不错,对你有帮助的话,欢迎点赞、收藏、转发❤❤❤

欢迎关注微信公众号:前端侦探

SpringBoot接口 - API接口有哪些不安全的因素?如何对接口进行签名?

Posted: 17 Jul 2022 04:26 PM PDT

在以SpringBoot开发后台API接口时,会存在哪些接口不安全的因素呢?通常如何去解决的呢?本文主要介绍API接口有不安全的因素以及常见的保证接口安全的方式,重点实践如何对接口进行签名。@pdai

准备知识点

建议从接口整体的安全体系角度来理解,比如存在哪些不安全的因素,加密解密等知识点。

API接口有哪些不安全的因素?

这里从体系角度,简单列举一些不安全的因素:
  • 开发者访问开放接口

    • 是不是一个合法的开发者?
  • 多客户端访问接口

    • 是不是一个合法的客户端?
  • 用户访问接口

    • 是不是一个合法的用户?
    • 有没有权限访问接口?
  • 接口传输

    • http明文传输数据?
  • 其它方面

    • 接口重放,上文介绍的接口幂等
    • 接口超时,加timestamp控制?
    • ...

常见的保证接口安全的方式?

针对上述接口存在的不安全因素,这里向你展示一些典型的保障接口安全的方式。

AccessKey&SecretKey

这种设计一般用在开发接口的安全,以确保是一个合法的开发者
  • AccessKey: 开发者唯一标识
  • SecretKey: 开发者密钥

以阿里云相关产品为例

认证和授权

从两个视角去看

  • 第一: 认证和授权,认证是访问者的合法性,授权是访问者的权限分级;
  • 第二: 其中认证包括对客户端的认证以及对用户的认证
  • 对于客户端的认证

典型的是AppKey&AppSecret,或者ClientId&ClientSecret等

比如oauth2协议的client cridential模式

https://api.xxxx.com/token?grant_type=client_credentials&client_id=CLIENT_ID&client_secret=CLIENT_SECRET

grant_type参数等于client_credentials表示client credentials方式,client_id是客户端id,client_secret是客户端密钥。

返回token后,通过token访问其它接口。

  • 对于用户的认证和授权

比如oauth2协议的授权码模式(authorization code)和密码模式(resource owner password credentials)

https://api.xxxx.com/token?grant_type=password&username=USERNAME&password=PASSWORD&client_id=CLIENT_ID&scope=read

grant_type参数等于password表示密码方式,client_id是客户端id,username是用户名,password是密码。

(PS:password模式只有在授权码模式(authorization code)不可用时才会采用,这里只是举个例子而已)

可选参数scope表示申请的权限范围。(相关开发框架可以参考spring security, Apache Shiro,SA-Token等)

https

从接口传输安全的角度,防止接口数据明文传输, 具体可以看这里

HTTP 有以下安全性问题:

  • 使用明文进行通信,内容可能会被窃听;
  • 不验证通信方的身份,通信方的身份有可能遭遇伪装;
  • 无法证明报文的完整性,报文有可能遭篡改。

HTTPs 并不是新协议,而是让 HTTP 先和 SSL(Secure Sockets Layer)通信,再由 SSL 和 TCP 通信,也就是说 HTTPs 使用了隧道进行通信。

通过使用 SSL,HTTPs 具有了加密(防窃听)、认证(防伪装)和完整性保护(防篡改)。

接口签名(加密)

接口签名(加密),主要防止请求参数被篡改。特别是安全要求比较高的接口,比如支付领域的接口。
  • 签名的主要流程

首先我们需要分配给客户端一个私钥用于URL签名加密,一般的签名算法如下:

1、首先对请求参数按key进行字母排序放入有序集合中(其它参数请参看后续补充部分);

2、对排序完的数组键值对用&进行连接,形成用于加密的参数字符串;

3、在加密的参数字符串前面或者后面加上私钥,然后用加密算法进行加密,得到sign,然后随着请求接口一起传给服务器。

例如:
https://api.xxxx.com/token?ke...

服务器端接收到请求后,用同样的算法获得服务器的sign,对比客户端的sign是否一致,如果一致请求有效;如果不一致返回指定的错误信息。

  • 补充:对什么签名?
  1. 主要包括请求参数,这是最主要的部分,签名的目的要防止参数被篡改,就要对可能被篡改的参数签名
  2. 同时考虑到请求参数的来源可能是请求路径path中,请求header中,请求body中。
  3. 如果对客户端分配了AppKey&AppSecret,也可加入签名计算;
  4. 考虑到其它幂等,token失效等,也会将涉及的参数一并加入签名,比如timestamp,流水号nonce等(这些参数可能来源于header)
  • 补充: 签名算法?

一般涉及这块,主要包含三点:密钥,签名算法,签名规则

  1. 密钥secret: 前后端约定的secret,这里要注意前端可能无法妥善保存好secret,比如SPA单页应用;
  2. 签名算法:也不一定要是对称加密算法,对称是反过来解析sign,这里是用同样的算法和规则计算出sign,并对比前端传过来的sign是否一致。
  3. 签名规则:比如多次加盐加密等;
PS:有读者会问,我们是可能从有些客户端获取密钥,算法和规则的(比如前端SPA单页应用生成的js中获取密钥,算法和规则),那么签名的意义在哪里?我认为签名是手段而不是目的,签名是加大攻击者攻击难度的一种手段,至少是可以抵挡大部分简单的攻击的,再加上其它防范方式(流水号,时间戳,token等)进一步提升攻击的难度而已。
  • 补充:签名和加密是不是一回事?

严格来说不是一回事:

  1. 签名是通过对参数按照指定的算法、规则计算出sign,最后前后端通过同样的算法计算出sign是否一致来防止参数篡改的,所以你可以看到参数是明文的,只是多加了一个计算出的sign。
  2. 加密是对请求的参数加密,后端进行解密;同时有些情况下,也会对返回的response进行加密,前端进行解密;这里存在加密和解密的过程,所以思路上必然是对称加密的形式+时间戳接口时效性等。
  • 补充:签名放在哪里?

签名可以放在请求参数中(path中,body中等),更为优雅的可以放在HEADER中,比如X-Sign(通常第三方的header参数以X-开头)

  • 补充:大厂开放平台是怎么做的呢?哪些可以借鉴?

以腾讯开放平台为例,请参考腾讯开放平台第三方应用签名参数sig的说明

实现案例

本例子采用AOP拦截自定义注解方式实现,主要看实现的思路而已(签名的目的要防止参数被篡改,就要对可能被篡改的参数签名)。@pdai

定义注解

package tech.pdai.springboot.api.sign.config.sign;  import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target;  /**  * @author pdai  */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Signature { }

AOP拦截

这里可以看到需要对所有用户可能修改的参数点进行按规则签名

package tech.pdai.springboot.api.sign.config.sign;  import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Objects;  import javax.servlet.http.HttpServletRequest;  import cn.hutool.core.text.CharSequenceUtil; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.util.ContentCachingRequestWrapper; import tech.pdai.springboot.api.sign.config.exception.BusinessException; import tech.pdai.springboot.api.sign.util.SignUtil;  /**  * @author pdai  */ @Aspect @Component public class SignAspect {      /**      * SIGN_HEADER.      */     private static final String SIGN_HEADER = "X-SIGN";      /**      * pointcut.      */     @Pointcut("execution(@tech.pdai.springboot.api.sign.config.sign.Signature * *(..))")     private void verifySignPointCut() {         // nothing     }      /**      * verify sign.      */     @Before("verifySignPointCut()")     public void verify() {         HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();         String sign = request.getHeader(SIGN_HEADER);          // must have sign in header         if (CharSequenceUtil.isBlank(sign)) {             throw new BusinessException("no signature in header: " + SIGN_HEADER);         }          // check signature         try {             String generatedSign = generatedSignature(request);             if (!sign.equals(generatedSign)) {                 throw new BusinessException("invalid signature");             }         } catch (Throwable throwable) {             throw new BusinessException("invalid signature");         }     }      private String generatedSignature(HttpServletRequest request) throws IOException {         // @RequestBody         String bodyParam = null;         if (request instanceof ContentCachingRequestWrapper) {             bodyParam = new String(((ContentCachingRequestWrapper) request).getContentAsByteArray(), StandardCharsets.UTF_8);         }          // @RequestParam         Map<String, String[]> requestParameterMap = request.getParameterMap();          // @PathVariable         String[] paths = null;         ServletWebRequest webRequest = new ServletWebRequest(request, null);         Map<String, String> uriTemplateVars = (Map<String, String>) webRequest.getAttribute(                 HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);         if (!CollectionUtils.isEmpty(uriTemplateVars)) {             paths = uriTemplateVars.values().toArray(new String[0]);         }          return SignUtil.sign(bodyParam, requestParameterMap, paths);     }  }

Request封装

package tech.pdai.springboot.api.sign.config;  import java.io.IOException;  import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;  import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper;  @Slf4j public class RequestCachingFilter extends OncePerRequestFilter {      /**      * This {@code doFilter} implementation stores a request attribute for      * "already filtered", proceeding without filtering again if the      * attribute is already there.      *      * @param request     request      * @param response    response      * @param filterChain filterChain      * @throws ServletException ServletException      * @throws IOException      IOException      * @see #getAlreadyFilteredAttributeName      * @see #shouldNotFilter      * @see #doFilterInternal      */     @Override     protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {         boolean isFirstRequest = !isAsyncDispatch(request);         HttpServletRequest requestWrapper = request;         if (isFirstRequest && !(request instanceof ContentCachingRequestWrapper)) {             requestWrapper = new ContentCachingRequestWrapper(request);         }         try {             filterChain.doFilter(requestWrapper, response);         } catch (Exception e) {             e.printStackTrace();         }     } }

注册

package tech.pdai.springboot.api.sign.config;  import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration;  @Configuration public class FilterConfig {     @Bean     public RequestCachingFilter requestCachingFilter() {         return new RequestCachingFilter();     }      @Bean     public FilterRegistrationBean requestCachingFilterRegistration(             RequestCachingFilter requestCachingFilter) {         FilterRegistrationBean bean = new FilterRegistrationBean(requestCachingFilter);         bean.setOrder(1);         return bean;     } }

实现接口

package tech.pdai.springboot.api.sign.controller;  import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import tech.pdai.springboot.api.sign.config.response.ResponseResult; import tech.pdai.springboot.api.sign.config.sign.Signature; import tech.pdai.springboot.api.sign.entity.User;  /**  * @author pdai  */ @RestController @RequestMapping("user") public class SignTestController {      @Signature     @PostMapping("test/{id}")     public ResponseResult<String> myController(@PathVariable String id             , @RequestParam String client             , @RequestBody User user) {         return ResponseResult.success(String.join(",", id, client, user.toString()));     }  }

接口测试

body参数

如果不带X-SIGN

如果X-SIGN错误

如果X-SIGN正确

示例源码

https://github.com/realpdai/t...

更多内容

告别碎片化学习,无套路一站式体系化学习后端开发: Java 全栈知识体系(https://pdai.tech)

Go语言ORM框架:构造查询条件

Posted: 17 Jul 2022 06:00 PM PDT

构造查询条件

worm是一款方便易用的Go语言ORM库。worm支Model方式(持结构体字段映射)、原生SQL以及SQLBuilder三种模式来操作数据库,并且Model方式、原生SQL以及SQLBuilder可混合使用。
Model方式、SQL builder支持链式API,可使用Where, And, Or, ID, In, Limit, GroupBy, OrderBy, Having等函数构造查询条件。也可以可通过Join、LeftJoin、RightJoin来进行数据库表之间的关联查询。
本文通过一些例子来说明如何使用worm来构造查询条件。

main函数

package main import (     "database/sql"     _ "github.com/go-sql-driver/mysql"     log "github.com/haming123/wego/dlog"     "github.com/haming123/wego/worm" ) func mysql_open(cnnstr string) (*sql.DB, error) {     db, err := sql.Open("mysql", cnnstr)     if err != nil {         return nil, err     }     err = db.Ping()     if err != nil {         return nil, err     }     return db, nil } func main() {     //创建数据连接池     cnnstr := "user:passwd@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True"     db_cnn, err := mysql_open(cnnstr)     if err != nil {         log.Error(err)         return     }     //初始化ORM     worm.InitMysql(db_cnn)     //显示SQL语句log     worm.ShowSqlLog(true) }

说明:

  • worm代码的下载
    go get github.com/haming123/wego
  • worm.ShowSqlLog
    worm.ShowSqlLog用于控制sql日志的显示,建议测试环境下打开sql日志的显示的开关,这样可以看到每个数据库操作的sql语句以及执行时间,方便快速定位问题。
  • 数据库的支持
    目前worm支持的数据库有:mysql、postgres、sqlite、sqlserver, 本文的例子中采用了mysql数据库。

数据库表与数据模型

//建表语句 CREATE TABLE `user` (   `id` bigint(20) NOT NULL AUTO_INCREMENT,   `name` varchar(30) DEFAULT NULL,   `age` int(11) DEFAULT NULL,   `passwd` varchar(32) DEFAULT NULL,   `created` datetime DEFAULT NULL,   PRIMARY KEY (`id`) );

数据库表user对应的实体类的定义如下:

type User struct {     Id          int64       `db:"id;autoincr"`     Name        string      `db:"name"`     Age         int64       `db:"age"`     Passwd      string      `db:"passwd"`     Created     time.Time    `db:"created;n_update"` } func (ent *User) TableName() string {     return "user" }

说明:

  • worm使用名称为"db"的Tag映射数据库字段,"db"后面是字段的名称,autoincr用于说明该字段是自增ID,n_update用于说明该字段不可用于update语句中。

通过ID来查询数据

若数据库表存在id字段,则可以通过ID函数来查询一条数据据记录:

func DemoGetById() {     var user model.User     _, err := worm.Model(&user).ID(1).Get()     if err != nil {         log.Error(err)         return     }     log.Debug(user) } //select id,name,age,passwd,created from user where id=? limit 1

执行该函数后的sql日志为:

[S] select id,name,age,passwd,created from user where id=1 limit 1 [S] DB: time=18.816ms

通过Where函数来查询数据

Where函数的使用类似Sprintf函数,函数的第一个参数是sql语句(where语句)模板,后面的参数是模板变量的值。

func DemoWhere() {     var users []model.User     err := worm.Model(&model.User{}).Where("id>? and age>?", 1, 10).Find(&users)     if err != nil {         log.Error(err)         return     } } //对应的sql语句为: //select id,name,age,passwd,created from user where id>? and age>?

说明:

  • worm占位符统一使用?,worm会根据数据库类型,自动替换占位符,例如postgresql数据库把?替换成$1,$2...
  • 可以在Where函数使用多个变量进行查询,这种方式比较直观,与数据库查询中的sql语句的写法比较类似。但是当查询条件比较多时,建议使用And、OR函数进行适当的分割,防止将查询变量与变量的值对应错误。例如:
func DemoWhere2() {     var users []model.User     err := worm.Model(&model.User{}).Where("id>?", 1).And("age>?", 10).Find(&users)     if err != nil {         log.Error(err)         return     } } //对应的sql语句为: //select id,name,age,passwd,created from user where id>? and age>?
  • like查询的写法
    例如查询用户的姓名中包含:demo的数据库记录:

    func DemoWhereLike() {   var users []model.User   err := worm.Model(&model.User{}).Where("name like ?", "%demo%").Find(&users)   if err != nil {       log.Error(err)       return   } } //对应的sql语句为: //select id,name,age,passwd,created from user where name like '%demo%'

XXXIf查询

有些情况加我们会根据变量的值来判断使用使用一个变量来作为查询条件来查询书库,例如,若用户的姓名不为空时通过用户姓名来查询数据库。常规的写法如下:

func DemoWhereIf(name string) {     var users []model.User     var err error     if name == "" {         err = worm.Model(&model.User{}).Find(&users)     } else {         err = worm.Model(&model.User{}).Where("name=?", name).Find(&users)     }     if err != nil {         log.Error(err)         return     } }

worm提供了更为简单的方法(提供了WhereIf、AndIf、OrIf函数)来支持这种查询需求:

func DemoWhereIf(name string) {     var users []model.User     err := worm.Model(&model.User{}).WhereIf(name != "", "name=?", name).Find(&users)     if err != nil {         log.Error(err)         return     } }

说明:

  • WhereIf函数的第一个参数时一个bool变量,若该变量为true,则会添加查询条件,否则忽略该查询条件。

in、not in查询

worm提供了AndIn、AndNotIn、OrIn、OrNotIn函数来支持sql语句中的in、not in查询。例如:

func DemoWhereIn() {     var users []model.User     err := worm.Model(&model.User{}).Where("").AndIn("id", 11, 12, 13, 14).Find(&users)     if err != nil {         log.Error(err)         return     } } //对应的sql语句为: select id,name,age,passwd,created from user where id in (?,?,?,?)

XXXIn、XXXNotIn的第二个参数时一个变长参数,您可以将需要查询的值作为变长参数传入,也可以将查询的值放到一个数组中进行查询:

func DemoWhereIn() {     var users []model.User     arr_id := []int64{11, 12, 13, 14}     err := worm.Model(&model.User{}).Where("").AndIn("id", arr_id).Find(&users)     if err != nil {         log.Error(err)         return     } }

说明:

  • 若使用数组方式,则可变长参数智能时一个参数,并且该参数为数组类型。

嵌套查询语句

worm支持嵌套查询语句,例如查询为:age>10 and (name='demo1' or name='demo2'), 则使用worm的方式如下:

func DemoWhereExp() {     var users []model.User     sqlw := worm.SQLW("name=?", "demo1").Or("name=?", "demo2")     err := worm.Model(&model.User{}).Where("age>?", 10).AndExp(sqlw).Find(&users)     if err != nil {         log.Error(err)         return     } } //对应的sql语句为: //select id,name,age,passwd,created from user where age>? and (name=? or name=?)

Limit与Offset

在MySQL语句中可以使用Limit与Offset来查询数据库,这种查询通常用于WEB的分页查询中。worm也支持mysql的Limit与Offset语句:

func DemoQueryPage(plen int64, pcur int64) {     var users []model.User     err := worm.Model(&model.User{}).Where("age>?", 10).Limit(plen).Offset(plen * pcur).Find(&users)     if err != nil {         log.Error(err)         return     } } //对应的sql语句为: //select id,name,age,passwd,created from user where age>? limit ?, ? 

orderby查询

OrderBy函数对应sql语句中的order by语句:

func DemoQueryOrderBy(orderby string) {     var users []model.User     err := worm.Model(&model.User{}).Where("age>?", 10).OrderBy(orderby).Find(&users)     if err != nil {         log.Error(err)         return     } } //对应的sql语句为: //select id,name,age,passwd,created from user where age>? order by created desc 

【分享】从Mybatis源码中,学习到的10种设计模式

Posted: 17 Jul 2022 05:33 PM PDT

作者:小傅哥
<br/>博客:https://bugstack.cn

沉淀、分享、成长,让自己和他人都能有所收获!😄

一、前言:小镇卷码家

总有不少研发伙伴问小傅哥:"为什么学设计模式、看框架源码、补技术知识,就一个普通的业务项目,会造飞机不也是天天写CRUD吗?"

你说的没错,但你天天写CRUD,你觉得 烦不? 慌不? 是不是既担心自己没有得到技术成长,也害怕将来没法用这些都是CRUD的项目去参加;述职、晋升、答辩,甚至可能要被迫面试时,自己手里一点干货也没有的情况。

所以你/我作为一个小镇卷码家,当然要扩充自己的知识储备,否则架构,架构思维不懂设计,设计模式不会源码、源码学习不深,最后就用一堆CRUD写简历吗?

二、源码:学设计模式

在 Mybatis 两万多行的框架源码实现中,使用了大量的设计模式来解耦工程架构中面对复杂场景的设计,这些是设计模式的巧妙使用才是整个框架的精华,这也是小傅哥喜欢卷源码的重要原因。经过小傅哥的整理有如下10种设计模式的使用,如图所示

Mybatis 框架源码10种设计模式

讲道理,如果只是把这10种设计模式背下来,等着下次面试的时候拿出来说一说,虽然能有点帮助,不过这种学习方式就真的算是把路走窄了。就像你每说一个设计模式,能联想到这个设计模式在Mybatis的框架中,体现到哪个流程中的源码实现上了吗?这个源码实现的思路能不能用到你的业务流程开发里?别总说你的流程简单,用不上设计模式!难到因为有钱、富二代,就不考试吗?🤔

好啦,不扯淡了,接下来小傅哥就以《手写Mybatis:渐进式源码实践》的学习,给大家列举出这10种设计模式,在Mybatis框架中都体现在哪里了!

三、类型:创建型模式

1. 工厂模式

源码详见cn.bugstack.mybatis.session.SqlSessionFactory

public interface SqlSessionFactory {     SqlSession openSession();  }

源码详见cn.bugstack.mybatis.session.defaults.DefaultSqlSessionFactory

public class DefaultSqlSessionFactory implements SqlSessionFactory {      private final Configuration configuration;      public DefaultSqlSessionFactory(Configuration configuration) {         this.configuration = configuration;     }      @Override     public SqlSession openSession() {         Transaction tx = null;         try {             final Environment environment = configuration.getEnvironment();             TransactionFactory transactionFactory = environment.getTransactionFactory();             tx = transactionFactory.newTransaction(configuration.getEnvironment().getDataSource(), TransactionIsolationLevel.READ_COMMITTED, false);             // 创建执行器             final Executor executor = configuration.newExecutor(tx);             // 创建DefaultSqlSession             return new DefaultSqlSession(configuration, executor);         } catch (Exception e) {             try {                 assert tx != null;                 tx.close();             } catch (SQLException ignore) {             }             throw new RuntimeException("Error opening session.  Cause: " + e);         }     }  }

Mybatis 工厂模式

  • 工厂模式:简单工厂,是一种创建型设计模式,其在父类中提供一个创建对象的方法,允许子类决定实例对象的类型。
  • 场景介绍SqlSessionFactory 是获取会话的工厂,每次我们使用 Mybatis 操作数据库的时候,都会开启一个新的会话。在会话工厂的实现中负责获取数据源环境配置信息、构建事务工厂、创建操作SQL的执行器,并最终返回会话实现类。
  • 同类设计SqlSessionFactoryObjectFactoryMapperProxyFactoryDataSourceFactory

2. 单例模式

源码详见cn.bugstack.mybatis.session.Configuration

public class Configuration {      // 缓存机制,默认不配置的情况是 SESSION     protected LocalCacheScope localCacheScope = LocalCacheScope.SESSION;      // 映射注册机     protected MapperRegistry mapperRegistry = new MapperRegistry(this);      // 映射的语句,存在Map里     protected final Map<String, MappedStatement> mappedStatements = new HashMap<>();     // 缓存,存在Map里     protected final Map<String, Cache> caches = new HashMap<>();     // 结果映射,存在Map里     protected final Map<String, ResultMap> resultMaps = new HashMap<>();     protected final Map<String, KeyGenerator> keyGenerators = new HashMap<>();      // 插件拦截器链     protected final InterceptorChain interceptorChain = new InterceptorChain();      // 类型别名注册机     protected final TypeAliasRegistry typeAliasRegistry = new TypeAliasRegistry();     protected final LanguageDriverRegistry languageRegistry = new LanguageDriverRegistry();      // 类型处理器注册机     protected final TypeHandlerRegistry typeHandlerRegistry = new TypeHandlerRegistry();      // 对象工厂和对象包装器工厂     protected ObjectFactory objectFactory = new DefaultObjectFactory();     protected ObjectWrapperFactory objectWrapperFactory = new DefaultObjectWrapperFactory();      protected final Set<String> loadedResources = new HashSet<>();       //... }

Mybatis 单例模式

  • 单例模式:是一种创建型模式,让你能够保证一个类只有一个实例,并提供一个访问该实例的全局节点。
  • 场景介绍:Configuration 就像狗皮膏药一样大单例,贯穿整个会话的生命周期,所以的配置对象;映射、缓存、入参、出参、拦截器、注册机、对象工厂等,都在 Configuration 配置项中初始化。并随着 SqlSessionFactoryBuilder 构建阶段完成实例化操作。
  • 同类场景ErrorContextLogFactoryConfiguration

3. 建造者模式

源码详见cn.bugstack.mybatis.mapping.ResultMap#Builder

public class ResultMap {      private String id;     private Class<?> type;     private List<ResultMapping> resultMappings;     private Set<String> mappedColumns;      private ResultMap() {     }      public static class Builder {         private ResultMap resultMap = new ResultMap();          public Builder(Configuration configuration, String id, Class<?> type, List<ResultMapping> resultMappings) {             resultMap.id = id;             resultMap.type = type;             resultMap.resultMappings = resultMappings;         }          public ResultMap build() {             resultMap.mappedColumns = new HashSet<>();             // step-13 新增加,添加 mappedColumns 字段             for (ResultMapping resultMapping : resultMap.resultMappings) {                 final String column = resultMapping.getColumn();                 if (column != null) {                     resultMap.mappedColumns.add(column.toUpperCase(Locale.ENGLISH));                 }             }             return resultMap;         }      }          // ... get }

Mybatis 建造者模式

  • 建造者模式:使用多个简单的对象一步一步构建成一个复杂的对象,这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
  • 场景介绍:关于建造者模式在 Mybatis 框架里的使用,那真是纱窗擦屁股,给你漏了一手。到处都是 XxxxBuilder,所有关于 XML 文件的解析到各类对象的封装,都使用建造者以及建造者助手来完成对象的封装。它的核心目的就是不希望把过多的关于对象的属性设置,写到其他业务流程中,而是用建造者的方式提供最佳的边界隔离。
  • 同类场景SqlSessionFactoryBuilderXMLConfigBuilderXMLMapperBuilderXMLStatementBuilderCacheBuilder

四、类型:结构型模式

1. 适配器模式

源码详见cn.bugstack.mybatis.logging.Log

public interface Log {    boolean isDebugEnabled();    boolean isTraceEnabled();    void error(String s, Throwable e);    void error(String s);    void debug(String s);    void trace(String s);    void warn(String s);  }

源码详见cn.bugstack.mybatis.logging.slf4j.Slf4jImpl

public class Slf4jImpl implements Log {    private Log log;    public Slf4jImpl(String clazz) {     Logger logger = LoggerFactory.getLogger(clazz);      if (logger instanceof LocationAwareLogger) {       try {         // check for slf4j >= 1.6 method signature         logger.getClass().getMethod("log", Marker.class, String.class, int.class, String.class, Object[].class, Throwable.class);         log = new Slf4jLocationAwareLoggerImpl((LocationAwareLogger) logger);         return;       } catch (SecurityException e) {         // fail-back to Slf4jLoggerImpl       } catch (NoSuchMethodException e) {         // fail-back to Slf4jLoggerImpl       }     }      // Logger is not LocationAwareLogger or slf4j version < 1.6     log = new Slf4jLoggerImpl(logger);   }    @Override   public boolean isDebugEnabled() {     return log.isDebugEnabled();   }    @Override   public boolean isTraceEnabled() {     return log.isTraceEnabled();   }    @Override   public void error(String s, Throwable e) {     log.error(s, e);   }    @Override   public void error(String s) {     log.error(s);   }    @Override   public void debug(String s) {     log.debug(s);   }    @Override   public void trace(String s) {     log.trace(s);   }    @Override   public void warn(String s) {     log.warn(s);   }  }

Mybatis 适配器模式

  • 适配器模式:是一种结构型设计模式,它能使接口不兼容的对象能够相互合作。
  • 场景介绍:正是因为有太多的日志框架,包括:Log4j、Log4j2、Slf4J等等,而这些日志框架的使用接口又都各有差异,为了统一这些日志工具的接口,Mybatis 定义了一套统一的日志接口,为所有的其他日志工具接口做相应的适配操作。
  • 同类场景:主要集中在对日志的适配上,Log 和 对应的实现类,以及在 LogFactory 工厂方法中进行使用。

2. 代理模式

源码详见cn.bugstack.mybatis.binding.MapperProxy

public class MapperProxy<T> implements InvocationHandler, Serializable {      private static final long serialVersionUID = -6424540398559729838L;      private SqlSession sqlSession;     private final Class<T> mapperInterface;     private final Map<Method, MapperMethod> methodCache;      public MapperProxy(SqlSession sqlSession, Class<T> mapperInterface, Map<Method, MapperMethod> methodCache) {         this.sqlSession = sqlSession;         this.mapperInterface = mapperInterface;         this.methodCache = methodCache;     }      @Override     public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {         if (Object.class.equals(method.getDeclaringClass())) {             return method.invoke(this, args);         } else {             final MapperMethod mapperMethod = cachedMapperMethod(method);             return mapperMethod.execute(sqlSession, args);         }     }          // ...  }

Mybatis 代理模式

  • 代理模式:是一种结构型模式,让你能够提供对象的替代品或其占位符。代理控制着对原对象的访问,并允许在将请求提交给对象前进行一些处理。
  • 场景介绍:不吹牛的讲,没有代理模式,就不会有各类的框架存在。就像 Mybatis 中的 MapperProxy 映射器代理实现类,它所实现的功能就是帮助我们完成 DAO 接口的具体实现类的方法操作,你的任何一个配置的 DAO 接口所调用的 CRUD 方法,都会被 MapperProxy 接管,调用到方法执行器等一系列操作,并返回最终的数据库执行结果。
  • 同类场景DriverProxyPluginInvokerMapperProxy

3. 组合模式

源码详见cn.bugstack.mybatis.scripting.xmltags.SqlNode

public interface SqlNode {      boolean apply(DynamicContext context);  }

源码详见cn.bugstack.mybatis.scripting.xmltags.IfSqlNode

public class IfSqlNode implements SqlNode{      private ExpressionEvaluator evaluator;     private String test;     private SqlNode contents;      public IfSqlNode(SqlNode contents, String test) {         this.test = test;         this.contents = contents;         this.evaluator = new ExpressionEvaluator();     }      @Override     public boolean apply(DynamicContext context) {         // 如果满足条件,则apply,并返回true         if (evaluator.evaluateBoolean(test, context.getBindings())) {             contents.apply(context);             return true;         }         return false;     }  }

源码详见cn.bugstack.mybatis.scripting.xmltags.XMLScriptBuilder

public class XMLScriptBuilder extends BaseBuilder {      private void initNodeHandlerMap() {         // 9种,实现其中2种 trim/where/set/foreach/if/choose/when/otherwise/bind         nodeHandlerMap.put("trim", new TrimHandler());         nodeHandlerMap.put("if", new IfHandler());     }       List<SqlNode> parseDynamicTags(Element element) {         List<SqlNode> contents = new ArrayList<>();         List<Node> children = element.content();         for (Node child : children) {             if (child.getNodeType() == Node.TEXT_NODE || child.getNodeType() == Node.CDATA_SECTION_NODE) {              } else if (child.getNodeType() == Node.ELEMENT_NODE) {                 String nodeName = child.getName();                 NodeHandler handler = nodeHandlerMap.get(nodeName);                 if (handler == null) {                     throw new RuntimeException("Unknown element " + nodeName + " in SQL statement.");                 }                 handler.handleNode(element.element(child.getName()), contents);                 isDynamic = true;             }         }         return contents;     }          // ... }

配置详见resources/mapper/Activity_Mapper.xml

<select id="queryActivityById" parameterType="cn.bugstack.mybatis.test.po.Activity" resultMap="activityMap" flushCache="false" useCache="true">     SELECT activity_id, activity_name, activity_desc, create_time, update_time     FROM activity     <trim prefix="where" prefixOverrides="AND | OR" suffixOverrides="and">         <if test="null != activityId">             activity_id = #{activityId}         </if>     </trim> </select>

Mybatis 组合模式

  • 组合模式:是一种结构型设计模式,你可以使用它将对象组合成树状结构,并且能独立使用对象一样使用它们。
  • 场景介绍:在 Mybatis XML 动态的 SQL 配置中,共提供了9种(trim/where/set/foreach/if/choose/when/otherwise/bind)标签的使用,让使用者可以组合出各类场景的 SQL 语句。而 SqlNode 接口的实现就是每一个组合结构中的规则节点,通过规则节点的组装完成一颗规则树组合模式的使用。具体使用源码可以阅读《手写Mybatis:渐进式源码实践》
  • 同类场景:主要体现在对各类SQL标签的解析上,以实现 SqlNode 接口的各个子类为主。

4. 装饰器模式

源码详见cn.bugstack.mybatis.session.Configuration

public Executor newExecutor(Transaction transaction) {     Executor executor = new SimpleExecutor(this, transaction);     // 配置开启缓存,创建 CachingExecutor(默认就是有缓存)装饰者模式     if (cacheEnabled) {         executor = new CachingExecutor(executor);     }     return executor; }

Mybatis 装饰器模式

  • 装饰器模式:是一种结构型设计模式,允许你通过将对象放入包含行为的特殊封装对象中来为原对象绑定新的行为。
  • 场景介绍:Mybatis 的所有 SQL 操作,都是经过 SqlSession 会话调用 SimpleExecutor 简单实现的执行器完成的,而一级缓存的操作也是在简单执行器中处理。那么这里二级缓存因为是基于一级缓存刷新操作的,所以在实现上,通过创建一个缓存执行器,包装简单执行器的处理逻辑,实现二级缓存操作。那么这里用到的就是装饰器模式,也叫俄罗斯套娃模式。
  • 同类场景:主要提前在 Cache 缓存接口的实现和 CachingExecutor 执行器中。

五、类型:行为型模式

1. 模板模式

源码详见cn.bugstack.mybatis.executor.BaseExecutor

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {     if (closed) {         throw new RuntimeException("Executor was closed.");     }     // 清理局部缓存,查询堆栈为0则清理。queryStack 避免递归调用清理     if (queryStack == 0 && ms.isFlushCacheRequired()) {         clearLocalCache();     }     List<E> list;     try {         queryStack++;         // 根据cacheKey从localCache中查询数据         list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;         if (list == null) {             list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);         }     } finally {         queryStack--;     }     if (queryStack == 0) {         if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {             clearLocalCache();         }     }     return list; }

源码详见cn.bugstack.mybatis.executor.SimpleExecutor

protected int doUpdate(MappedStatement ms, Object parameter) throws SQLException {     Statement stmt = null;     try {         Configuration configuration = ms.getConfiguration();         // 新建一个 StatementHandler         StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);         // 准备语句         stmt = prepareStatement(handler);         // StatementHandler.update         return handler.update(stmt);     } finally {         closeStatement(stmt);     } }

Mybatis 模板模式

  • 模板模式:是一种行为设计模式,它在超类中定义了一个算法的框架,允许子类在不修改结构的情况下重写算法的特定步骤。
  • 场景介绍:只要存在一系列可被标准定义的流程,在流程的步骤大部分是通用逻辑,只有一少部分是需要子类实现的,那么通常会采用模板模式来定义出这个标准的流程。就像 Mybatis 的 BaseExecutor 就是一个用于定义模板模式的抽象类,在这个类中把查询、修改的操作都定义出了一套标准的流程。
  • 同类场景BaseExecutorSimpleExecutorBaseTypeHandler

2. 策略模式

源码详见cn.bugstack.mybatis.type.TypeHandler

public interface TypeHandler<T> {      /**      * 设置参数      */     void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException;      /**      * 获取结果      */     T getResult(ResultSet rs, String columnName) throws SQLException;      /**      * 取得结果      */     T getResult(ResultSet rs, int columnIndex) throws SQLException;  }

源码详见cn.bugstack.mybatis.type.LongTypeHandler

public class LongTypeHandler extends BaseTypeHandler<Long> {      @Override     protected void setNonNullParameter(PreparedStatement ps, int i, Long parameter, JdbcType jdbcType) throws SQLException {         ps.setLong(i, parameter);     }      @Override     protected Long getNullableResult(ResultSet rs, String columnName) throws SQLException {         return rs.getLong(columnName);     }      @Override     public Long getNullableResult(ResultSet rs, int columnIndex) throws SQLException {         return rs.getLong(columnIndex);     }  }

Mybatis 策略模式

  • 策略模式:是一种行为设计模式,它能定义一系列算法,并将每种算法分别放入独立的类中,以使算法的对象能够互相替换。
  • 场景介绍:在 Mybatis 处理 JDBC 执行后返回的结果时,需要按照不同的类型获取对应的值,这样就可以避免大量的 if 判断。所以这里基于 TypeHandler 接口对每个参数类型分别做了自己的策略实现。
  • 同类场景PooledDataSource\UnpooledDataSourceBatchExecutor\ResuseExecutor\SimpleExector\CachingExecutorLongTypeHandler\StringTypeHandler\DateTypeHandler

3. 迭代器模式

源码详见cn.bugstack.mybatis.reflection.property.PropertyTokenizer

public class PropertyTokenizer implements Iterable<PropertyTokenizer>, Iterator<PropertyTokenizer> {      public PropertyTokenizer(String fullname) {         // 班级[0].学生.成绩         // 找这个点 .         int delim = fullname.indexOf('.');         if (delim > -1) {             name = fullname.substring(0, delim);             children = fullname.substring(delim + 1);         } else {             // 找不到.的话,取全部部分             name = fullname;             children = null;         }         indexedName = name;         // 把中括号里的数字给解析出来         delim = name.indexOf('[');         if (delim > -1) {             index = name.substring(delim + 1, name.length() - 1);             name = name.substring(0, delim);         }     }          // ...  }

Mybatis 迭代器模式

  • 迭代器模式:是一种行为设计模式,让你能在不暴露集合底层表现形式的情况下遍历集合中所有的元素。
  • 场景介绍:PropertyTokenizer 是用于 Mybatis 框架 MetaObject 反射工具包下,用于解析对象关系的迭代操作。这个类在 Mybatis 框架中使用的非常频繁,包括解析数据源配置信息并填充到数据源类上,以及参数的解析、对象的设置都会使用到这个类。
  • 同类场景PropertyTokenizer

六、总结:"卷王"的心得

一份源码的成体系拆解渐进式学习,可能需要1~2个月的时间,相比于爽文和疲于应试要花费更多的经历。但你总会在一个大块时间学习完后,会在自己的头脑中构建出一套完整体系关于此类知识的技术架构,无论从哪里入口你都能清楚各个分支流程的走向,这也是你成为技术专家路上的深度学习。

如果你也想有这样酣畅淋漓的学习,千万别错过傅哥为你编写的资料《手写Mybatis:渐进式源码实践》目录如图所示,共计20章

No comments:

Post a Comment