SOLID框架——控制反转(第一部分)
演示框架
结论
锻炼
如果您符合以下情况,请阅读本教程:
- 您希望构建能够随着时间推移而扩展或改进的系统。
- 你听说过控制反转,但从未实际应用过。
- 您正在构建的系统由不同的开发团队负责,他们各自开发不同的功能,您希望更好地协同工作。
- 您正在构建一个需要针对不同用户或客户进行不同定制的解决方案。
- 你想编写模块化、封装化且易于测试的代码。
- 你想用 Javascript 构建 SOLID 架构
- 你想通过一个有用的示例项目来练习实际应用控制反转原理。
是什么?以及为什么?
我们最初听到的项目需求往往与最终实现的需求有所不同。随着项目推进和用户反馈的收集,新的想法不断涌现,最初的假设被推翻,整个项目可能会危险地偏离我们最初设计的系统边界。有很多方法可以解决这个问题,但我为大多数非平凡的解决方案选择的是“控制反转”。
控制反转 (IoC) 与我们入门编程时学习的命令式编程风格截然相反,它是一种解决问题的方式。我们不是告诉计算机该做什么,而是声明我们知道如何做的事情,并使用松耦合的抽象事件来协调交互。这些事件构成了一个框架契约,其中包含一系列事件及其接口。该契约本质上是可扩展的,使得多年后编写的代码元素能够无缝集成并扩展初始解决方案,通常无需更改核心代码。因此,IoC 解决方案易于维护和扩展。
乍听之下可能有些奇怪,但使用 IoC 构建的解决方案却有着一种非常简洁优美的特质,它能够恰当地封装功能,并轻松分离关注点。
架构合理的 IoC 解决方案可带来显著优势:
- 我们编写的模块完全封装,因此我们可以轻松地与编写代码不同部分的团队成员合作,而无需担心大量的团队间沟通来确定代码应该放在哪里。
- 由于模块本身是隔离的,并且通信机制清晰明确,因此我们可以轻松地为模块编写测试。
- 对 UI 和后端代码都适用,效果极佳。
- 我们可以轻松地调整解决方案,使其在不同情况下具备不同的功能。客户 X 需要功能 Y,但客户 Z 希望以不同的方式实现?没问题。
- 我们可以邀请部分客户或测试人员试用新功能。
- 说实话,这简直太解放人了!IoC 消除了很多对改变行之有效的事物的恐惧——因为它本身就渴望改变……
本系列文章将从事件和行为这两种并非互斥的架构范式来探讨控制物联网 (IoC)。前几部分将重点介绍事件驱动的 IoC,并使用我专门构建的一个游戏项目作为示例,该项目旨在展示 IoC 下 UI 和处理的实际应用。第二部分将扩展到行为,行为在游戏开发框架中被广泛使用,但正如我将要展示的,它同样可以应用于业务系统。
我们通常会在解决方案中看似“相同”的各个元素实际表现不同,且需要赋予不同功能时,使用行为来定义它们。例如,一个文档驱动系统可能包含图纸或文本描述等文档。在列表中,这两种文档对用户来说看起来相同,具有一些共同的属性和功能,但在特定上下文中交互时,它们的行为却截然不同。
事件驱动的 IoC 非常适合应用程序的整个 UI 和框架——您可能已经看到,一个复杂的应用程序最终很可能会同时使用两者——不过我们通常从事件开始,因为它们驱动着我们的框架。
演示游戏
本系列教程将使用这款游戏来展示控制井(IoC)的优势和原理。您可以随时参考游戏及其源代码,深入了解相关概念或实际应用。随着系列教程的推进,我们将进一步扩展代码。
这款游戏采用了一种“框架”,而你实际玩到的游戏内容正是基于该框架及其一些应用而形成的。本文末尾我们将介绍这些要素,之后会引导你运用文中介绍的技巧来制作一个自定义版本的游戏。
固态解决方案
Michael Feathers 创造了 SOLID 这个缩写词来描述 Robert C Martin 于 2000 年提出的面向对象设计的核心原则,该原则旨在描述如何使软件解决方案易于理解和维护。
控制反转(Inversion of Control,简称 Ic)是一种构建符合 SOLID 原则的面向对象系统的方法。它尤其有助于实现某些原则,并且可以轻松地通过编码来遵循其他原则。以下是 SOLID 及其维基百科描述:
让我们看看它们是如何应用的。
单一责任
控制反转 (IoC) 的核心原则是识别事件和状态,并让一个或多个组件对这些信息做出适当的响应。IoC 显著简化了组件的职责划分,使它们各自承担单一职责,并解放了代码的其他部分,让它们可以自由地声明感兴趣的信息,而无需考虑这些信息的具体用途。
在我们的示例游戏中,戳破泡泡或收集苹果会用相应的值声明该事件。另一个完全不同的程序会使用该值来更新总分,而另一个程序则会使用该分数来播放一个不断上升的“小铃铛”动画,以提升玩家的满意度!所有这些程序都不需要了解其他程序的具体信息,即使没有分数或特效,游戏也能正常运行。
得分者懂得得分。苹果懂得收集。使命者懂得收集苹果的价值。
plug(
"mission-indicator",
({ item }) => !item.red && !item.green,
BonusIndicator
)
function BonusIndicator({ isCurrent }) {
useEvent("collect", handleCollect)
return null
function handleCollect(apple) {
if (!isCurrent) return
cascadeText({
x: apple.x,
y: apple.y,
color: "gold",
number: 12,
duration: 3.5,
speed: 300,
scale: 4
})
raiseLater("score", { score: 1500, x: apple.x, y: apple.y })
}
}
暂且略过 IoC 事件的具体实现细节(我们稍后再谈……),这里我们可以看到负责在任务期间显示苹果数据的指示器组件。该plug()指示器会插入到“任务步骤”中,而这些步骤对红苹果或绿苹果没有特定要求。在这种情况下,收集一个苹果即可获得奖励。
该组件本身不渲染任何内容,但会添加一个事件处理程序,用于处理苹果到达银行时发送的“收集”事件。收集成功后,该组件会播放一个金色星星飞溅动画,表示收集成功,然后显示“我认为这值 1500 分,就在这里”。
我选择这样处理分数:
import React from "react"
import { Box, makeStyles } from "@material-ui/core"
import { floatText } from "../utilities/floating-text"
const { handle, useEvent } = require("../../lib/event-bus")
let gameScore = 0
handle("ui", (items) => {
items.push(<Score key="score" />)
})
const useStyles = makeStyles((theme) => {
return {
scoreBox: {
fontSize: 48,
textShadow: "0 0 4px black",
position: "absolute",
left: theme.spacing(1),
top: 0,
color: "white",
fontFamily: "monospace"
}
}
})
function Score() {
const classes = useStyles()
const [score, setShownScore] = React.useState(gameScore)
const [visible, setVisible] = React.useState(false)
useEvent("score", updateScore)
useEvent("startGame", () => {
gameScore = 0
setShownScore(0)
setVisible(true)
})
useEvent("endGame", () => setVisible(false))
return (
!!visible && (
<Box className={classes.scoreBox}>
{`${score}`.padStart(6, "0")}
</Box>
)
)
function updateScore({ score, x, y }) {
gameScore = gameScore + score
setShownScore(gameScore)
let duration = score < 500 ? 2 : 3.5
let scale = score < 1000 ? 1 : score < 200 ? 2.5 : 4
floatText(x, Math.max(100, y), `+ ${score}`, "gold", duration, scale)
}
}
我们稍后会再次讨论事件总线的工作原理。这里只需说明,我们通常会在“用户界面”(UI)中添加一个分数组件——UI 是游戏框架提供的渲染服务。框架除了提供组件空间之外一无所知,它并不知道分数是什么。
我们的得分组件监听“startGame”事件,并将总分设为0并显示分数。当发生“score”事件时,它会更新总分,并弹出一个文本“提示”,其大小和持续时间取决于分数。换句话说,它非常擅长理解和响应分数,但它并不知道分数的由来。
苹果系统的一部分也了解收集苹果时会发生什么。它与苹果动画的组件完全独立,而动画组件本身又与移动苹果的组件完全独立。收集红苹果的组件知道收集绿苹果是不明智的。
plug("mission-indicator", ({ item }) => item.red !== undefined, RedIndicator)
function RedIndicator({ item, isCurrent, next }) {
const [red, setRed] = React.useState(item.red)
useEvent("collect", handleCollect)
return (
<Badge color="secondary" invisible={!isCurrent} badgeContent={red}>
<Avatar src={apple1} />
</Badge>
)
function handleCollect(apple) {
if (!apple.color) return
if (!isCurrent) return
if (apple.color() === "red") {
raise("success", apple)
cascadeText({
x: apple.x,
y: apple.y,
color: "gold",
number: 12,
duration: 3.5,
speed: 300,
scale: 4
})
item.red--
setRed(item.red)
if (!item.red) {
next()
}
raiseLater("score", { score: 2500, x: apple.x, y: apple.y })
} else {
raise("error", apple)
cascadeText({
x: apple.x,
y: apple.y,
color: "red",
text: "❌",
number: 6,
duration: 3.5,
speed: 300,
scale: 3
})
}
}
}
当你捡到红苹果时,它会欢呼一声;当你捡到绿苹果时,它会播放一个错误动画,并将该错误作为事件抛出。它根本不知道“生命”是什么……它只知道用户做了错事,然后抛出一个错误。它甚至不知道苹果是什么,只知道它必须支持一个接口,该接口包含一个color()方法,该方法有时会返回“红色”和一个坐标。
它还知道当前的“任务步骤”有一个界面,上面用数字表示“红色”,并且它还提供了一个方法来表示“我在这里感兴趣的内容已经完成了” next()。你知道,就是那个提供最初“红色”计数的东西——没错,它也是一个组件,它只会读取配置文件或者生成一个苹果的数量……所有功能都高度分离,并且只传递必要的最低限度信息。
开闭原理
根据SOLID原则,物体应当对延伸开放,对修改封闭。
要获取 RedIndicator 的唯一方法是发出一个“collect”事件并传递一个带有 color() 属性的值。因此,无法直接修改它,所以这个方案符合“封闭”原则的要求。但根据“开放”原则,我们也声明了如何扩展它。我们触发了“score”、“success”和“error”事件,这些事件是扩展的连接点。
不过,由于我的 IoC 方法本身的工作原理,我们也可以根据需要完全替换 RedIndicator 的功能。假设我们添加 RedIndicator 一无所知的“魔法苹果”(我们将在后面的部分进行练习,但这里先透露一下):
- 我们可以通过创建一个优先级更高的指示器来覆盖整个红色指示器,该指示器只需有条件地
plug()禁用当前的指示器即可。 - 我们可以为魔法苹果添加一个额外的渲染器,该渲染器显示在现有红色指示器之前或之后。
- 我们可以使用 RedIndicator 处理“收集”事件。
- 我们可以将“收集”事件的优先级设置得比 RedIndicator 更高,并修改发送的内容,或者干脆不再传递该事件。
因此,无需修改框架中的任何一行代码,也无需修改 RedIndicator 中的任何一行代码,我们就可以扩展系统,使其拥有一个全新的功能,并可以根据条件启用和禁用该功能。我们甚至不需要查看 RedIndicator 的代码,只需让某个程序加载这段代码即可使其正常运行。
开闭原则至关重要,我希望您能开始明白,我们只需几行非常简单的代码就能充分利用它。
利什科夫替换
该原则指出,派生项的功能应与其祖先项完全相同,但可根据需要添加其他功能。
对于控制流 (IoC) 来说,这有点牵强。显然,我们可以利用原型继承从 RedIndicator 及其同类函数中派生出一些东西,然后通过重写 RedIndicator 的方法来使用它们plug,但 Liskov 更多地指的是经典继承,而控制流更倾向于组合。两者都可以,但现代的理念是,除非我们能想到继承带来好处的充分理由,否则应该使用组合。
IoC 为我们提供了一种增强或替换组件的绝佳方法,如果您要覆盖它,那么只需实现相同的小接口即可获得一个功能齐全的覆盖版本。
界面隔离
我们在控制流 (IoC) 中通过事件传递的消息定义了我们的接口,而且正如接口隔离原则所建议的那样,这些接口通常非常简洁。实际上,在组件之间,我们往往根本不调用方法,而只是提供可以通过一个极简接口来使用的信息。
让我们来探讨一下游戏的核心——苹果。你看到的漂浮的苹果实际上是由两个松散耦合的组件构成的。一个组件负责在屏幕上绘制苹果并将其映射到物理世界模型中;另一个组件则负责移动苹果并使其被收集。
除了物理属性和移动方式之外,苹果产品也承载着一项使命。为了完成这项“使命”,苹果产品提供了一个简洁明了的界面,其中包含一个…… x,y并color()通过该collect界面……
如前所述,苹果也是物理世界的一部分。它通过声明自身位置和界面来体现这一点radius。circle只要它可见,它就会在每一帧中声明这一点。苹果也使用这个界面,以此将自己与其他苹果和瓶子区分开来——当然,也包括任何你想添加的东西。
最后,移动组件的耦合性更强,因为它需要根据从与玩家和世界其他部分的交互中获得的速度来旋转苹果并移动它,它还使用该速度来控制苹果在水下的下沉深度。
即使耦合如此紧密,需要传递的信息仍然很少——一个Apple 对象有一个move(x,y)函数,setDepth()还有一个用于旋转的函数(此处未显示)。yield这里使用的多帧功能是通过js-coroutines实现的。
while(mode==='float') {
//Apply friction
v.x = interpolate(v.x, baseX, t)
v.y = interpolate(v.y, 0, t)
//Apply buouancy
coreDepth = coreDepth > 0 ? coreDepth - 0.02 : 0
//Apply downward pressure based on speed (v.length)
coreDepth = Math.max(
0,
Math.min(2, coreDepth + Math.min(0.027, v.length() / 34))
)
//Set the depth
apple.setDepth(coreDepth)
//Wait for the next frame
yield
//Update the apple (v.x/v.y may have been modified by events)
apple.move(apple.x + v.x, apple.y + v.y)
//Collect if at the bank
if (apple.y < 100) {
mode = "collect"
}
//Remove if off screen to left or right
if (apple.x < -50 || apple.x > 1050) {
mode = "lost"
}
}
依赖倒置
这意味着代码应该只依赖于注入到其中的内容。IoC 更进一步,它不声明依赖项,而是依靠事件和接口作为与更广泛系统交互的方法。
演示框架
好了,我们花了很多时间讨论原理,也看了演示游戏中的一些例子。现在是时候谈谈IoC在这里是如何实现的了。
IoC 的首要原则是创建一个框架,以便将组件放入其中。这是一个涵盖面很广的话题,你可以做出各种各样的决定,通常最好的方法是先尝试,然后不断调整直到有效为止。这通常是在项目初期进行一系列快速迭代,之后再决定将已构建的内容“提升”到框架中。
系统级框架的核心通常是事件系统。在我们的演示中,我们正是采用了这种方式。
活动巴士
你不必只使用一个事件源,但通常来说,使用多个事件源会很有帮助。在游戏框架中,我们基于EventEmitter2实现了一个事件总线(一个全局事件源) 。我喜欢这个模块,因为它支持通配符、多部分事件、异步事件,而且速度很快。
该计划旨在提供简单的事件发起方式和便捷的事件消费方式。
触发事件即声明一种状态,事件及其参数共同构成接口。处理事件则注册一项能力。
我们的活动策划团队拥有一套核心的活动筹办和执行方法。筹办活动我们有以下流程:
raise- 立即触发事件 - 当我们将使用处理程序提供的值时,我们会这样做。raiseLater- 当主线程下次空闲时引发事件,我们将其用于“我刚刚收集了一些数据”之类的通知。raiseAsync- 引发异步事件,并在所有处理程序返回后继续执行。我们通常在希望允许处理程序花费一些时间执行异步操作时使用此方法。因此,它通常用于配置和设置阶段。
为了处理事件,我们有:
handle注册事件的全局处理程序。这通常用于注册系统的整个组件,例如 Apple 和 Bubble。useEvent这是一个 React Hook,用于在组件挂载事件中添加和移除事件处理程序。它确保我们不会意外地将某些东西留在总线上,并且是组件注册相关响应的主要方式。using是一个生成器函数,它将一个用于处理事件的“on”函数传递给内部生成器协程。这确保当协程因任何原因退出时,所有事件处理程序都会被移除。
触发事件有点像调用方法,但你可能会收到很多响应,也可能根本没有响应,因此处理返回结果的方式略有不同。我们通常也会通过事件的参数返回值:
const [elements] = React.useState(() => {
const [elements] = raise("initialize", { game: [], top: [] })
elements.game.sort(inPriorityOrder)
elements.top.sort(inPriorityOrder)
return elements
})
raise(event, ...params) -> params
我们引发一个事件并返回参数数组,从而可以将变量初始化与实际调用结合起来。
// Instead of writing this
const elements = {game: [], top: []}
raise("initialize", elements)
// It is replaced by
const [elements] = raise("initialize", { game: [], top: [] })
在此,我们声明系统正在初始化,并欢迎任何感兴趣的用户在两个图层中添加 SVG 元素。一个是位于河岸下方(水面以上)的游戏内图层,用于显示苹果、瓶子等;另一个是位于所有图层之上的图层(用于显示气泡、收集到的苹果、分数等)。
由于元素众多,我们经常需要对结果进行排序。但事件处理程序也有优先级,这决定了它们的顺序。
handle("initialize", addMyThing, -2)
插头和插座
在这个基于 React 的框架实现中,我们还需要编写动态组件,使整个用户界面能够遵循控制反转 (IoC) 原则运行。这些组件也使用事件总线,但它们提供了非常有用的函数和组件,从而使我们的 UI 也完全实现了控制反转。
以下是任务介绍界面部分代码。在中间Grid可以看到我们使用了一个Socket类型为“任务项”的组件。所有其他属性都传递给一个组件,plug()该组件会填充这个插槽。实际上,可以使用多个插槽,并且插槽或组件都可以选择只渲染一个或全部。插槽还会将其子组件渲染为动态组件,因此您只需编写一个普通的包装器,仍然可以提供一个钩子点,以便稍后插入额外的功能和接口,或者移除默认实现。
<CardContent>
{!!levelSpec.instructions && levelSpec.instructions}
<Grid container spacing={2} justify="center">
{levelSpec.mission.map((item, index) => (
<Grid item key={index}>
<Socket
index={index}
type="mission-item"
step={item}
/>
</Grid>
))}
</Grid>
</CardContent>
然后,我们用类似这样的插件填充一个任务项 Socket:
plug("mission-item", ({ step }) => step && step.red, RedItem)
function RedItem({ step, index }) {
return (
<Card elevation={4}>
<CardHeader subheader={` `} />
<CardMedia
style={{ paddingTop: 60, backgroundSize: "contain" }}
image={apple1}
/>
<CardContent>
{step.red} red apple{step.red !== 1 ? "s" : ""}
</CardContent>
</Card>
)
}
plug它接受一个“类型”和一个可选的谓词,后跟要渲染的组件和一个可选的优先级。最低要求是必须包含类型和组件。
plug("mission-item", ImAlwaysThere)
使用插头和插座,稍后编写或加载的模块可以根据我们的 IoC 原则填充接口、覆盖现有行为或对其进行增强。
A 函数Socket接受一个类型和一个可选的过滤器,过滤器会接收一个待显示项的数组。它可以根据需要对数组进行各种操作,例如,只取第一个元素以显示优先级最高的项,或者显示所有非默认值的项等等。
<Socket type={"anything"} filter={arrayFilter}/>
plug(type, predicate, Component, priority)如上所述,该函数至少需要一个类型和一个组件,它还可以有一个基于 props 的谓词和一个优先级。
框架
我们游戏的核心框架非常简单。我们用 HTML 封装了一个 SVG 图形。该框架还负责追踪玩家的手指或鼠标。
在第一个例子中,框架还包括河流和河岸——这是框架选择之一,我们可以很容易地将它们颠倒过来,但我把它留作后面的练习。
export default function App() {
const [uiElements] = raise("ui", [])
return (
<div className="App">
<GameSurface>{uiElements}</GameSurface>
</div>
)
}
因此,我们的应用程序非常简单。我们首先请求一些要放置在游戏表面上的用户界面元素,然后再渲染游戏界面。
游戏界面本身负责屏幕大小调整和所有玩家交互。它对其他任何内容都一无所知,但允许模块包含其组件和用户界面。
export function GameSurface({ children }) {
const [windowWidth, setWidth] = React.useState(window.innerWidth)
const playing = React.useRef(false)
const ref = React.useRef()
const [elements] = React.useState(() => {
const [elements] = raise("initialize", { game: [], top: [] })
elements.game.sort(inPriorityOrder)
elements.top.sort(inPriorityOrder)
return elements
})
React.useEffect(() => {
window.addEventListener("resize", updateWidth)
return () => {
window.removeEventListener("resize", updateWidth)
}
function updateWidth() {
setWidth(window.innerWidth)
}
}, [])
useEvent("startLevel", () => (playing.current = true))
useEvent("endLevel", () => (playing.current = false))
let ratio = Math.max(1, 1000 / windowWidth)
let height = Math.min(window.innerHeight, 700 / ratio)
let width = (height / 700) * 1000
let offset = (windowWidth - width) / 2
let x = 0
let y = 0
let lastTime = Date.now()
React.useEffect(() => {
return update(standardPlayer(getPosition, playing.current)).terminate
})
return (
<Box
ref={ref}
onTouchStart={startTouch}
onTouchMove={captureTouch}
onMouseMove={captureMouse}
position="relative"
width={width}
style={{ marginLeft: offset }}
>
<svg
viewBox="0 0 1000 700"
width={width}
style={{ background: "lightblue", position: "relative" }}
>
<RiverBank>{elements.game}</RiverBank>
{elements.top}
</svg>
<Box
position="absolute"
style={{ zoom: 1 / ratio }}
left={0}
top={0}
right={0}
bottom={0}
>
{children}
</Box>
</Box>
)
function captureTouch(event) {
event.stopPropagation()
event.preventDefault()
lastTime = Date.now()
const rect = ref.current.getBoundingClientRect()
const p = width / 1000
x = (event.targetTouches[0].clientX - rect.left) / p
y = (event.targetTouches[0].clientY - rect.top) / p
}
function startTouch() {
lastTime = 0
}
function captureMouse(event) {
lastTime = Date.now()
const p = width / 1000
const rect = ref.current.getBoundingClientRect()
x = (event.clientX - rect.left) / p
y = (event.clientY - rect.top) / p
}
function getPosition() {
return { x, y, time: Date.now() - lastTime }
}
}
我们再次使用协程来处理玩家,在这种情况下,协程会计算手指或鼠标每帧移动的距离,并在事件总线上宣布这一信息。
function* standardPlayer(getPosition, playing) {
yield* using(function* (on) {
on("startLevel", () => (playing = true))
on("endLevel", () => (playing = false))
let lx = undefined
let ly = undefined
while (true) {
yield
if (!playing) continue
const { x, y, time } = getPosition()
if (time > 500) {
lx = undefined
ly = undefined
}
lx = lx || x
ly = ly || y
let dx = x - lx
let dy = y - ly
let distance = Math.sqrt(dx ** 2 + dy ** 2)
lx = x
ly = y
raise("player", { x, y, dx, dy, distance })
}
})
}
结论
本文旨在介绍控制反转 (IoC) 的原理,并举例说明如何使用事件总线以一个简单的 JavaScript/React 游戏为例来轻松实现这些原理。希望您能从中看出,这种简单的技术在可扩展性和单一职责方面带来了显著的优势。后续章节将探讨如何重构框架,如何使用代码分割和动态加载来扩展 IoC 应用程序,以及如何利用行为来创建多样化且动态的解决方案,以应对各种问题。
锻炼
修改示例游戏,并添加成就系统,在以下情况下向玩家显示消息:
- 他们戳破了最初的10个泡泡
- 他们戳破了最初的100个泡泡
- 他们戳破了最初的500个泡泡
- 他们戳破了最初的1000个泡泡
- 他们采摘了第一个红苹果
- 他们采摘了第一个青苹果
- 他们完成了第一关。
- 他们收集了50个苹果,颜色不限。
- 他们收集了100个苹果,颜色不限。
您应该添加一个源文件并从中导入它App.js。
在这个文件中,您将使用handle它将组件注册到用户界面。handle("ui", items=>items.push(<YourComponent key="myComponent"/>))
然后,您的组件将用于useEvent()处理各种事件,并使您的组件显示几秒钟,同时显示成就和一些有趣的文字。
有趣的事件是popped(collect它接受一个apple带有color()函数的参数)endLevel