Elixir实战:12 构建分布式系统 (2) 构建容错集群
zhezhongyun 2025-01-05 21:29 31 浏览
通过一些分布式原语,您可以开始构建一个待办事项网页服务器集群。目标是使系统对各种故障更加弹性,包括整个节点的崩溃。这里提出的解决方案将是简单的。构建一个合适的分布式系统需要更多关注各种细节,这个主题很容易填满一本书。
从积极的一面来看,构建一个基本的基于 BEAM 的分布式系统并不复杂。在本节中,您将感受到分布式原语如何很好地融入现有的 BEAM 并发模型。
您在这里的大部分工作将基于 GenServer 抽象。这并不令人惊讶,因为消息传递是 BEAM 中的主要分发工具。在继续之前,请确保您记得 GenServer 是如何工作的;如有需要,请回顾第 6 章中的解释。
12.2.1 集群设计
该集群的目标看似简单:
- 该集群将由多个节点组成,所有节点都由相同的代码驱动,并提供相同的服务(用于管理多个待办事项列表的网页界面)。
- 更改应在集群中传播。在一个节点上的单个待办事项列表所做的修改应在所有其他节点上可见。从外部来看,客户端不应关心他们访问的是哪个节点。
- 单个节点的崩溃不应干扰集群。服务应持续提供,崩溃节点的数据不应丢失。
这些目标描述了一个容错系统。您始终提供服务,个别崩溃不会造成干扰。因此,系统变得更加弹性和高度可用。
网络分区
请注意,您不会处理分布式系统中最困难的挑战:网络分区。分区是指两个节点之间的通信通道中断,导致节点断开连接。在这种情况下,您可能会遇到“分裂大脑”情况:当集群被分成两个(或更多)断开的较小集群时——所有这些集群都在工作并提供服务。这种情况可能会导致问题,因为您有多个孤立的系统,每个系统都接受用户的输入。最终,您可能会得到无法调和的冲突数据。在本节的大部分内容中,我们将忽略这个问题,但在结束之前我们会讨论一些后果。
让我们开始将系统分布式化的工作。首先,我们将查看待办缓存。
12.2.2 分布式待办缓存
在某种意义上,待办缓存是系统的核心。这是维护数据一致性的主要元素,因此让我们回顾一下它是如何工作的。主要思想如图 12.2 所示。
当您想要修改待办事项列表时,您会请求待办缓存为您提供相应的待办服务器进程。这个待办服务器随后充当单个待办事项列表的同步点。所有对鲍勃列表的请求都通过该进程,这确保了一致性并防止了竞争条件。
在对系统进行集群时,您将旨在保持此属性。不同之处在于,您的缓存必须以某种方式在集群中的所有节点上工作。无论您在集群中的哪个位置询问“谁负责鲍勃的列表?”答案始终指向集群中的同一进程(当然,直到该进程崩溃)。这是您需要更改的唯一内容,以使您的待办事项缓存分布式。正如您将看到的,变更相对简单。
发现待办服务器
完成集群范围发现有多种方法。可能最简单(尽管不一定是最有效的)的方法依赖于 :global 模块的服务,这些服务允许您在全局别名下注册一个进程——一个标识集群中进程的任意术语。您需要做的是:
- 将 Todo.Server 模块调整为使用全局注册。
- 将 Todo.Cache 调整为与新注册一起工作。
让我们开始实施这个。您需要做的第一件事是修改 Todo.Server 模块以依赖全局注册。到目前为止,您一直在使用 Registry ,这仅适用于单节点注册。对于分布式进程注册和发现,您可以使用 :global 模块。
处理注册
您可能会对这些不同的注册设施感到困惑,因此让我们回顾一下关键区别:
- 基本注册功能是一个本地注册,它允许您使用一个简单的原子作为节点上单个进程的别名。
- Registry 通过允许您使用丰富的别名来扩展这一点——任何术语都可以用作别名。
- :global 允许您注册一个集群范围的别名。
- :pg 对于在集群范围别名(进程组)后注册多个进程非常有用,这通常适用于分布式发布-订阅场景。
将 Registry 替换为 :global 需要在 Todo.Server 模块中进行一次更改。相关代码的当前版本在第 9 章中引入,如下所示:
defmodule Todo.Server do
def start_link(name) do
GenServer.start_link(Todo.Server, name, name: via_tuple(name))
end
defp via_tuple(name) do
Todo.ProcessRegistry.via_tuple({__MODULE__, name})
end
...
end
要进行注册,请使用 :global 模块,您需要从 via_tuple/1 返回 {:global, registered_name} 。同时,您还可以重命名该函数。
清单 12.2 待办服务器的全局注册 (todo_distributed/lib/todo/server.ex)
defmodule Todo.Server do
...
def start_link(name) do
GenServer.start_link(Todo.Server, name, name: global_name(name))
end
defp global_name(name) do
{:global, {__MODULE__, name}} ?
end
...
end
全球注册
通过这个简单的单一更改,您已切换到分布式注册和发现。无需更改其他任何内容;系统将正常工作。
但当前实现存在一个可能的性能问题。当您在全局别名下注册一个进程时, :global 模块会在整个集群中执行同步聊天。这意味着全局注册的成本远高于本地注册,这在第 9 章中引入的当前缓存实现中尤其成问题。让我们回顾相关部分:
defmodule Todo.Cache do
...
def server_process(todo_list_name) do
case start_child(todo_list_name) do ?
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
end
defp start_child(todo_list_name) do
DynamicSupervisor.start_child(
__MODULE__,
{Todo.Server, todo_list_name}
)
end
...
end
? 一个新过程总是开始。
早在第 9.2.3 节中,您选择了这种简单的方法。每当进行子查找时,您都会启动新进程并尝试注册。如果注册失败, DynamicSupervisor.start_child/2 将返回 {:error, {:already_started, pid}} 。这是一个简单的解决方案,曾为您提供了良好的服务。但现在,随着系统的分布式架构,这种无条件的注册尝试可能会成为一个严重的瓶颈。每次您想处理待办事项列表时,即使服务器进程已经在运行,您仍会尝试进行 :global 注册,这将获取一个集群范围的锁,并与系统中的所有其他节点进行通信。
这可以通过先执行显式查找来改进。您将检查待办服务器是否已注册,并仅在查找返回空值时尝试启动服务器。为此,您需要首先使用 whereis/1 函数扩展 Todo.Server 模块,该函数接受一个名称并返回注册进程的 PID,如果在给定名称下没有注册进程,则返回 nil 。
清单 12.3 发现待办服务器 (todo_distributed/lib/todo/server.ex)
defmodule Todo.Server do
...
def whereis(name) do
case :global.whereis_name({__MODULE__, name}) do
:undefined -> nil
pid -> pid
end
end
...
end
值得重申的是, :global.whereis_name/1 不会导致任何跨节点聊天。此功能仅对本地 ETS 表进行一次查找。因此,您可以期待 Todo.Server.whereis/1 功能提供相当良好和稳定的性能。
现在,您可以调整 Todo.Cache 中的代码..
清单 12.4 优化的过程发现 (todo_distributed/lib/todo/cache.ex)
defmodule Todo.Cache do
...
def server_process(todo_list_name) do
existing_process(todo_list_name) || new_process(todo_list_name)
end
defp existing_process(todo_list_name) do
Todo.Server.whereis(todo_list_name)
end
defp new_process(todo_list_name) do
case DynamicSupervisor.start_child(
__MODULE__,
{Todo.Server, todo_list_name}
) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
end
end
依靠一些封装和 || 运算符, server_process/1 函数突出了查找待办服务器的方法。您要么返回现有进程的 PID,要么尝试启动新进程。如第 9.2.3 节所述, new_process/1 中的代码正确处理了两个不同客户端进程同时尝试为同一待办事项列表启动服务器的情况。该代码在分布式环境中也能正常工作,并且能够处理两个不同节点上两个客户端之间的竞争条件。
随着这些更改的实施, Todo.ProcessRegistry 模块不再使用,因此可以从项目中移除。进程注册表项也可以从 Todo.System 模块的子规范列表中移除。
替代发现
请记住,全局注册是冗长且串行的(一次只能有一个进程执行全局注册)。这意味着前述方法在不同待办事项列表的数量或集群中节点的数量方面并不是很可扩展。如果网络较慢,解决方案的性能也会很差。
有替代方案。这里的主要挑战是可靠地发现负责待办事项列表的过程,同时减少网络通信。这可以通过引入一条规则来实现,该规则始终将相同的待办事项列表名称映射到网络中的同一节点。以下是这个想法的简单草图:
def node_for_list(todo_list_name) do
all_sorted_nodes = Enum.sort(Node.list([:this, :visible]))
node_index = :erlang.phash2(
todo_list_name,
length(all_sorted_nodes)
)
Enum.at(all_sorted_nodes, node_index)
end
您获取所有节点的列表并对其进行排序,以确保始终保持相同的顺序。然后,您对输入名称进行哈希,确保结果落在范围 0..length(all_sorted_nodes) 内。最后,您返回给定位置的节点。这确保只要集群稳定(节点列表不变),相同的待办事项列表将始终映射到相同的节点。
现在,您可以通过一次跳转发现目标节点。假设之前的版本 Todo.Cache (不是您刚刚实现的那个),检索目标进程可以简单到如下:
:rpc.call(
node_for_list(todo_list_name),
Todo.Cache,
:server_process,
[todo_list_name]
)
您转发到目标节点并在那里检索所需的进程。您不需要使用全局注册, Todo.Cache 可以像本章之前一样继续工作。前一个调用的结果是一个 PID,您可以使用它进行调用。好处是您可以通过更少的交流发现 PID。
这种方法的主要缺点是,当集群配置发生变化时,它无法正常工作。如果您添加了另一个节点或某个节点断开连接,映射规则将会改变。处理这种情况是复杂的。您需要检测集群中的变化(这是可能的,稍后会解释),并根据新的映射规则将所有数据迁移到不同的节点。在数据迁移期间,您可能希望保持服务运行,这将引入另一层复杂性。如果您使用某种形式的一致性哈希——一种更智能的键到节点的映射,更能抵御集群变化——则需要迁移的数据量可以大大减少。
显然,实施可能会迅速变得更加复杂,这就是为什么你开始时选择了简单的全局注册方法。尽管它的可扩展性不是特别强,但这是一个有效的简单解决方案。但是,如果你需要更好的性能和可扩展性,就必须采用更复杂的方法。与其重新发明轮子,不如考虑查看第三方解决方案,例如 Syn (https://github.com/ostinelli/syn) 或 Swarm (https://github.com/bitwalker/swarm)。
12.2.3 实现一个复制数据库
在您刚刚进行的更改后,您将会有以下行为:
- 当第一个请求到达鲍勃的列表时,在处理该请求的节点上创建一个待办事项列表。
- 所有后续对鲍勃待办事项的请求都被转发到步骤 1 中创建的进程。
- 如果在步骤 1 中创建的节点(或进程)崩溃,新的请求将导致新的待办服务器被注册。
乍一看一切似乎都很好,系统看起来分布得很合理。你现在不会进行测试,因为还有一个重要问题我们尚未解决:数据库无法在崩溃后恢复。假设你在节点 A 上对 Bob 的列表进行了几次更新。如果这个节点崩溃,其他节点,比如节点 B,将接管 Bob 列表的工作。但之前存储的数据不会在那个节点上,你将失去所有的更改。
显然,数据库需要进行复制,以便数据能够在节点崩溃时存活。保存数据的最简单(尽管不是最有效)的方法是在整个集群中进行复制。这个想法在图 12.3 中进行了说明。
这非常简单。当你将数据存储到数据库时,你会将更改传播到集群中的所有节点。如果你依赖于 :rpc 模块的服务,相应的实现可以很简单。我提到过 :rpc ,除了其他功能外,它允许你在集群中的所有节点上发出函数调用。你将依赖这个功能,并对 Database 模块进行一些更改:
- 将现有的 Database.store 函数重命名为 Database.store_local 。代码保持不变。
- 将新的实现提供给 Database.store 。这个新实现将在集群中的所有节点上调用 Database.store_local 。
另一个你将要进行的更改(代码在这里没有呈现)将把 Todo.DatabaseWorker.store/2 转换为一个调用。这个请求本应一开始就作为一个调用来实现。在第七章中,我选择了一个类型转换,多少有些任意,主要是出于教学目的。实际上,如果你要求另一个进程存储数据,你应该请求一个确认消息,以便你知道数据是否已被存储或出现了问题。随着你转向一个不太可靠的通信媒介(网络),这一点变得越来越重要,因为在这种情况下可能会发生各种问题。当你期望请求在节点之间使用时,通常应该将其实现为一个调用。
不需要其他更改。特别是, Todo.Database.get/1 保持不变。当您想要读取数据时,可以从本地节点读取,乐观地假设所有节点都有相同的数据副本。更改很简单,如下所示的列表所示。
清单 12.5 在所有节点上存储数据 (todo_distributed/lib/todo/database.ex)
defmodule Todo.Database do
...
def store(key, data) do
{_results, bad_nodes} =
:rpc.multicall( ?
__MODULE__, ?
:store_local, ?
[key, data], ?
:timer.seconds(5) ?
) ?
Enum.each(bad_nodes, &IO.puts("Store failed on node #{&1}")) ?
:ok
end
...
end
在所有节点上调用 store_local
记录失败的结果
在这里,您依赖于 :rpc.multicall/4 在集群中的所有节点上进行函数调用。 multicall 接受 MFA 和超时。目标函数随后在集群中的所有节点上被调用,所有结果被收集,您将获得一个形式为 {results, bad_nodes} 的元组:一个结果列表和一个在给定时间内未响应的节点列表。
始终提供超时设置
提供给 multicall 的超时时间是重要的。如果没有它, multicall ,进而, store 操作将永远被阻塞。
在进行分布式调用时,通常应该指定一个超时时间。请注意,这与跨进程调用没有什么不同——在调用另一个进程时,通常也希望有一个超时时间,而 GenServer 通过提供默认的 5 秒超时时间来识别这一点。同样,跨节点操作与跨进程操作并没有太大区别,在许多情况下,在开发单节点系统时,您需要考虑类似的一系列问题。
最后,您打印所有请求超时的节点。请注意,在实际操作中,这并不够。您还应该验证每个接收到的响应是否返回 :ok 。此外,在部分成功的情况下,您应该采取一些有意义的措施。否则,您将面临一个不一致的集群,不同的节点包含不同的数据。为了简洁起见,我在这里没有这样做,但在实际项目中,这是一个需要考虑和解决的问题。
我对数据库工作者进行了另一个小改动,这里没有呈现。到目前为止,您一直使用持久化文件夹来存储数据。现在进行了更改以适应节点名称。如果您的节点名为 node1@localhost ,您将把数据存储在 persist/node1 文件夹中。这主要是为了简化测试,并允许您从同一根文件夹本地启动多个节点。
无论如何,这个简单的变化使得在集群中复制数据成为可能。至此,我们对集群待办系统的基本构想完成了,你可以试试看。
12.2.4 测试系统
最后,是时候测试系统了。你需要启动几个节点,连接它们,看看集群是如何工作的。但请记住,在第 11 章中,你让 web 服务器监听端口 5454。你不能让两个节点监听同一个端口,所以你需要更改这个。幸运的是,在 11.4 节中,你通过应用程序环境使 web 端口可配置,因此可以通过命令行更改默认端口。
启动两个实例, node1 和 node2 ,分别监听端口 5454 和 5555:
$ TODO_SERVER_EXPIRY=600 iex --sname node1@localhost -S mix ?
$ TODO_SERVER_EXPIRY=600 TODO_HTTP_PORT=5555 \
iex --sname node2@localhost -S mix ?
? 启动节点 1,监听默认端口
? 启动节点 2,并设置备用端口
在这里,我们通过操作系统环境变量更改系统配置。待办服务器的自动过期超时时间增加到 600 秒,以便不干扰我们的本地实验。第二个节点也配置为使用非默认端口。
接下来,您需要连接这两个节点:
iex(node1@localhost)1> Node.connect(:node2@localhost)
现在,集群已建立,您可以使用您的服务器。在第一个节点上为 Bob 添加一个条目:
$ curl -d "" \
"http://localhost:5454/add_entry?list=bob&date=2023-12-19&title=Dentist"
OK
然后,验证该条目在另一个节点上是否可见:
$ curl "http://localhost:5555/entries?list=bob&date=2023-12-19"
2023-12-19 Dentist
这证明了您的数据在集群中传播。此外,查看单个 iex shell,您会在 node1 shell 中看到“开始为 bob 启动待办服务器”消息,但在 node2 中看不到。这清楚地证明,即使您尝试在另一个节点上访问 Bob 的列表,您也会被转发到 node1 上的相应进程。
因此,您可以安全地在 node2 上修改 Bob 的列表,而不会影响数据:
$ curl -d "" \
"http://localhost:5555/add_entry?list=bob&date=2023-12-19&title=Movies"
$ curl "http://localhost:5454/entries?list=bob&date=2023-12-19"
2023-12-19 Dentist
2023-12-19 Movies
最后,崩溃单个节点不会干扰系统。停止 node1 ,Bob 的待办服务器正在运行,并尝试查询 node2 :
$ curl "http://localhost:5555/entries?list=bob&date=2023-12-19"
2023-12-19 Dentist
2023-12-19 Movies
果然,集群仍在提供服务,数据得以保留。新的待办服务器已在 node2 上创建,并从复制数据库中恢复了状态。
此时,您的基本集群已完成。还有一些剩余问题,我在这里不予讨论,但会提及:
- 您应该设置一个负载均衡器,以作为所有客户端的单一访问点。
- 您需要一个方案来将新节点引入正在运行的集群。当引入新节点时,它应该首先与已连接的节点之一同步数据库;然后,它可以开始处理请求。
- 数据库复制是脆弱的。您需要某种两阶段提交策略。
- 您需要处理网络分区。
这些挑战并不容易解决,但它们是分布式系统固有的,无论底层技术如何,你都必须面对它们。重要的是要理解,Erlang 并不是解决分布式问题的“魔法棒”。在分布式系统中,许多事情可能会出错,如何从各种故障中恢复取决于你自己。没有一种通用的解决方案:你的工作是以适合当前问题的方式组合基本的分布式原语。
当然,将工作卸载到经过验证的第三方组件上通常可以提供帮助。例如,通过使用内置的 Mnesia 数据库,您可以实现更好的写入保证,并能够轻松地将新节点迁移到集群中。但即便如此,了解第三方库在分布式环境中的工作原理仍然很重要。在这个例子中,Mnesia 并没有明确处理网络分区和脑裂场景,而是将这个情况留给开发者来解决。其他组件可能会表现出不同的缺点,因此您需要了解它们在分布式环境中的工作方式。
Erlang 分布式原语可以让你走得很远。即使你没有提前为分布式系统做好准备,只需进行少量更改即可使你的系统分布式。
12.2.5 检测分区
到目前为止,这项工作很简单,但我们方便地忽略了网络分区的问题。这是构建分布式系统时最大的挑战之一。全面讨论这个主题可能很容易变成一本相当大的书,所以我将仅解释检测分区的基本机制。
当你决定采用分布式架构时,分区是你必须处理的问题,无论如何都要面对。在分布式系统中,网络分区不应被忽视,因此即使你选择使用第三方产品(例如,外部数据库)来处理集群和复制,你也应该了解该产品在发生分区时的表现。最好提前了解你将面临的挑战,以便能够做出有意识和明智的决策。
网络分区或网络分裂是指两个节点无法再相互通信的情况。可能有各种潜在原因,且无法将它们区分开来:
- 网络连接丢失。
- 网络连接非常慢。
- 远程节点已崩溃。
- 远程节点过载,忙到无法及时响应。
从一个节点的角度来看,所有这些情况看起来都是一样的。远程节点没有响应,你不知道原因。因此,几乎不可能保证网络分裂永远不会发生。即使在超快且可靠的网络上,错误或过载也可能导致一个主机变得如此繁忙,以至于无法及时响应另一个主机。另一个节点别无选择,只能将这种情况解释为网络分裂,并得出连接丢失的结论。这意味着在实施分布式系统时,你需要考虑网络分区,并制定应对这种情况的策略。
当发生分区时,您可能会得到多个相互独立且互不连接的集群。问题在于,尽管这些集群无法相互通信,但一个集群的客户端可能能够访问所有节点。这种情况也被称为分裂脑。如果不同的集群继续独立为用户提供服务,您可能会遇到不希望出现的行为。在一个集群上发出的请求在另一个集群上将不可见,用户可能会面临更新丢失,或者出现虚假条目。最终,一旦您重新连接这些集群,您可能会面临数据冲突。
要处理分区,您需要能够检测到它们。请记住,分区总是表现为与远程节点的连接丧失,并且可以检测到这种情况。如前所述,节点通过滴答消息定期向其对等体发送 ping,如果对等体未能响应这些消息,则将被视为断开连接。每个进程可以通过 :net_kernel .monitor_nodes/1 (https://erlang.org/doc/man/net_kernel.xhtml#monitor_nodes-1) 订阅有关连接节点变化的通知。
您提供的参数是一个布尔值,指示您是添加一个新订阅( true )还是安装一个覆盖此节点上所有先前订阅者的单个订阅者( false )。无论哪种方式,调用 monitor_nodes 的进程将在远程节点连接或断开连接时接收通知。
让我们试试这个。首先,启动 node1 并订阅通知:
$ iex --sname node1@localhost
iex(node1@localhost)1> :net_kernel.monitor_nodes(true)
这使得调用者进程(在这种情况下是 shell)接收到通知。
现在,启动两个额外的节点并将它们连接到 node1 :
$ iex --sname node2@localhost
iex(node2@localhost)1> Node.connect(:node1@localhost)
$ iex --sname node3@localhost
iex(node3@localhost)1> Node.connect(:node1@localhost)
在 node1 shell 中,您可以看到相应的消息:
iex(node1@localhost)2> flush()
{:nodeup, :node2@localhost}
{:nodeup, :node3@localhost}
断开连接时也会发生同样的事情。您可以停止 node2 和 node3 并检查 node1 中的消息:
iex(node1@localhost)3> flush()
{:nodedown, :node3@localhost}
{:nodedown, :node2@localhost}
或者,如果您想监视特定节点,也可以使用 Node.monitor/2 (https://hexdocs.pm/elixir/Node.xhtml#monitor/2)。
最后,正如我之前提到的,您可以设置一个监视器或与远程进程的链接。这与本地进程的工作方式相同。如果远程进程崩溃(或节点断开连接),您将收到一条消息(使用监视器时)或一个退出信号(使用链接时)。
12.2.6 高可用系统
早在第一章中,我描述了一些高可用系统的特性。虽然这可能不明显,但你在待办事项系统中逐渐达到了这个目标,现在它具有一些不错的特性:
- 响应性——由于您拥有一个高度并发的系统,您可以更有效地利用硬件,并同时处理多个请求。由于 BEAM 进程的工作方式,您不会遇到意外的暂停,例如系统范围的垃圾收集(因为进程是单独且并发地进行垃圾收集的)。偶尔的长时间运行任务不会阻塞整个系统,因为进程会频繁被抢占。最终,您应该拥有一个可预测的运行系统,具有相对恒定的延迟,并且在系统过载时能够优雅地降级。
- 可扩展性——您的系统是并发和分布式的,因此您可以通过使用更强大的机器或向系统添加更多节点来应对日益增长的流行度和负载。系统可以自动利用新硬件。
- 容错——由于进程隔离,您可以限制单个错误的影响。由于进程链接,您可以在系统中传播这些错误并处理它们。监督者可以帮助系统自我修复并从错误中恢复。同时,主代码将遵循正常路径,专注于需要完成的工作,摆脱错误检测结构。最后,由于分布式,您可以在系统中生存整个机器的崩溃。
在这一点上,应该清楚高可用性的主要工具是 BEAM 并发模型。依赖于进程提供了许多良好的特性,使得接近拥有一个高可用系统成为可能。
当然,这个系统是极其简化的:您没有为数据库等方面提供适当的实现,也没有处理网络分裂,这使得这些说法显得过于自信。尽管如此,当您着手实现一个必须持续为众多用户服务的高可用系统时,这些就是您需要实现的属性,而流程是可以带您到达那里主要的工具。
在这一点上,您已经完成了系统的分布式设置。不过,在离开之前,还有一些重要的网络相关事项需要讨论。
相关推荐
- 前端面试:聊聊 meta 标签?(meta标签用法)
-
提供给页面的一些元信息(名称/值对),有助于SEO。Meta标签是HTML中用于定义文档类型声明的标签。它们通常被用在head标签中,与文档的body标签相关联。在一个有head...
- web网页性能分析系列(网页性能指标有哪些)
-
在前端开发中,App或者WebPage性能的好坏和响应速度,尤其是App端显得格外重要,一直都是前端很头疼的问题。专业的测试工具可以知道自己的网页还有哪些需要优化的地方,总体的评分是多少,是否合乎用...
- [抓狂瞬间] 5 大差异 + 布局绝招!前端元素面试通关秘籍
-
刚入行的搬砖工程师,面对面试必问题“行内元素和块级元素有啥区别”,是不是心跳加速,大脑却一片空白?别担心!这看似基础的问题,实则是面试逆袭的突破口。今天就带你深入剖析,用5个关键差异,解锁前端布...
- python中Django视图(view)的详解(附示例)
-
本篇文章给大家带来的内容是关于python中Django视图(view)的详解(附示例),有一定的参考价值,有需要的朋友可以参考一下,希望对你有所帮助。一个视图函数(类),简称视图,是一个简单的Pyt...
- MySQL进行整库数据备份「表(结构+数据)、视图、函数、事件」
-
前言 通常情况下,我们需要改什么地方就备份什么地方就可以了,但也免不了需要整库备份的时候,本文记录实现MySQL使用脚本进行整库数据备份【表(结构+数据)、视图、函数、事件】 主要是使用mys...
- python入门-day14-周末小项目(python周末培训班哪个好)
-
周末小项目-简易记事本的内容。这是一个综合练习,结合之前学过的函数、文件操作和异常处理,设计一个简单的命令行记事本程序。我会用清晰的步骤和代码带你实现添加、查看、删除笔记并保存到文件的功能,确保...
- Python必会的50个代码操作(python代码介绍)
-
学习Python时,掌握一些常用的程序操作非常重要。以下是50个Python必会的程序操作,主要包括基础语法、数据结构、函数和文件操作等。1.HelloWorldprint("Hello,...
- ScalersTalk成长会Python小组第7周学习笔记
-
Scalers点评:在2015年,ScalersTalk成长会完成Python小组完成了《Python核心编程》第1轮的学习。到2016年,我们开始第二轮的学习,并且将重点放在章节的习题上。Pytho...
- 电脑CMD命令与电脑工作效率提升(cmd使用提升命令)
-
在日常使用电脑同时按“windows+R”,可以弹出来一个框然后输入CMD弹出以下框:在这个框中输入一些内容可以简化一些我们的电脑操作好的,以下是一些最常用的CMD命令,按功能分类整理,适合日常使用和...
- 多线程实现消息推送并可重试3次以及1小时后重试
-
#-*-coding:utf-8-*-"""CreatedonTueApr2209:05:462025@author:1""...
- RBAC权限模型(rbac权限模型的优点)
-
RBAC权限模型RBAC权限模型(Role-BasedAccessControl)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。准备工作CREATETABLE`s...
- 如何使用PIL生成验证码?(pixivic验证码)
-
web项目中遇到使用验证码的情况有很多,进行介绍下使用PIL生成验证码的方法。安装开始安装PIL的过程确实麻烦各种问题层出不绝,不过不断深入后就没有这方面的困扰了:windows安装:直接安装Pil...
- 技术是这样应用的(一)(技术的运用)
-
WindowsServer2003路由与远程访问在计算机网络教室使用过程中网络访问控制的实现目前很多计算机网络教室采用双网卡服务器的路由和远程访问功能,通过NAT地址转换实现教室内学生机的互联网访...
- Flask-RESTful 用法指南(flask写restful接口)
-
Flask-RESTful是一个Flask扩展,用于快速构建RESTfulAPI。它提供了简单的语法来创建资源路由,并内置了请求解析和响应格式化功能。##安装首先安装Flask-REST...
- Python办公自动化系列篇之三:PowerPoint演示文稿(.pptx)
-
作为高效办公自动化领域的主流编程语言,Python凭借其优雅的语法结构、完善的技术生态及成熟的第三方工具库集合,已成为企业数字化转型过程中提升运营效率的理想选择。该语言在结构化数据处理、自动化文档生成...
- 一周热门
- 最近发表
- 标签列表
-
- HTML 教程 (33)
- HTML 简介 (35)
- HTML 实例/测验 (32)
- HTML 测验 (32)
- HTML 参考手册 (28)
- JavaScript 和 HTML DOM 参考手册 (32)
- HTML 拓展阅读 (30)
- HTML常用标签 (29)
- HTML文本框样式 (31)
- HTML滚动条样式 (34)
- HTML5 浏览器支持 (33)
- HTML5 新元素 (33)
- HTML5 WebSocket (30)
- HTML5 代码规范 (32)
- HTML5 标签 (717)
- HTML5 标签 (已废弃) (75)
- HTML5电子书 (32)
- HTML5开发工具 (34)
- HTML5小游戏源码 (34)
- HTML5模板下载 (30)
- HTTP 状态消息 (33)
- HTTP 方法:GET 对比 POST (33)
- 键盘快捷键 (35)
- 标签 (226)
- HTML button formtarget 属性 (30)