分类 软件开发 下的文章

编程过程有时候就像一场与丧尸群之间的战斗。在这个系列文章中,我将带你了解怎样将 ZOMBIES 方法应用到实际工作中。

 title=

很久以前,在我还是一个萌新程序员的时候,我们曾经被分配一大批工作。我们每个人都被分配了一个编程任务,然后回到自己的小隔间里噼里啪啦地敲键盘。我记得团队里的成员在自己的小隔间里一呆就是几个小时,为打造无缺陷的程序而奋斗。当时流行的思想是:能一次性做得越多,能力越强。

对于我来说,能够长时间编写或者修改代码而不用中途停下来检验这些代码是否有效,就像荣誉勋章一样。那个时候我们都认为停下来检验代码是否工作是能力不足的表现,菜鸟才这么干。一个“真正的开发者”应该能一口气构建起整个程序,中途不用停下来检查任何东西!

然而事与愿违,当我停止在开发过程中测试自己的代码之后,来自现实的检验狠狠地打了我的脸。我的代码要么无法通过编译,要么构建失败,要么无法运行,或者不能按预期处理数据。我不得不在绝望中挣扎着解决这些烦人的问题。

避开丧尸群

如果你觉得旧的工作方式听起来很混乱,那是因为它确实是这样的。我们一次性处理所有的任务,在问题堆里左砍右杀,结果只是引出更多的问题。着就像是跟一大群丧尸间的战斗。

如今我们已经学会了避免一次性做太多的事情。在最初听到一些专家推崇避免大批量地开发的好处时,我觉得这很反直觉,但我已经从过去的犯错中吸取了教训。我使用被 James Grenning 称为 ZOMBIES 的方法来指导我的软件开发工作。

ZOMBIES 方法来救援!

ZOMBIES 表示以下首字母缩写:

  • Z – 最简场景(Zero)
  • O – 单元素场景(One)
  • M – 多元素场景(Many or more complex)
  • B – 边界行为(Boundary behaviors)
  • I – 接口定义(Interface definition)
  • E – 处理特殊行为(Exercise exceptional behavior)
  • S – 简单场景用简单的解决方案(Simple scenarios, simple solutions)

我将在本系列文章中对它们进行分析讲解。

最简场景

最简场景指可能出现的最简单的情况。

人们倾向于最开始的时候使用硬编码值,因为这是最简单的方式。通过在编码活动中使用硬编码值,可以快速构建出一个能即时反馈的解决方案。不需要几分钟,更不用几个小时,使用硬编码值让你能够马上与正在构建的系统进行交互。如果你喜欢这个交互,就朝这个方向继续做下去。如果你发现不喜欢这种交互,你可以很容易抛弃它,根本没有什么可损失。

本系列文章将以构建一个简易的购物系统的后端 API 为例进行介绍。该服务提供的 API 允许用户创建购物筐、向购物筐添加商品、从购物筐移除商品、计算商品总价。

首先,创建项目的基本结构(将购物程序的代码和测试代码分别放到 apptests 目录下)。我们的例子中使用开源的 xUnit 测试框架。

现在撸起你的袖子,在实践中了解最简场景吧!

[Fact]
public void NewlyCreatedBasketHas0Items() {    
    var expectedNoOfItems = 0;
    var actualNoOfItems = 1;
    Assert.Equal(expectedNoOfItems, actualNoOfItems);
}

这是一个伪测试,它测试的是硬编码值。新创建的购物筐是空的,所以购物筐中预期的商品数是 0。通过比较期望值和实际值是否相等,这个预期被表示成一个测试(或者称为断言)。

运行该测试,输出结果如下:

Starting test execution, please wait...

A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.57] tests.UnitTest1.NewlyCreatedBasketHas0Items [FAIL]
  X tests.UnitTest1.NewlyCreatedBasketHas0Items [4ms]
  Error Message:
   Assert.Equal() Failure
Expected: 0
Actual: 1
[...]

这个测试显然无法通过:期望商品数是 0,但是实际值被硬编码为了 1。

当然,你可以马上把硬编码的值从 1 改成 0,这样测试就能通过了:

[Fact]
public void NewlyCreatedBasketHas0Items() {
    var expectedNoOfItems = 0;
    var actualNoOfItems = 0;
    Assert.Equal(expectedNoOfItems, actualNoOfItems);
}

与预想的一样,运行测试,测试通过:

Starting test execution, please wait...

A total of 1 test files matched the specified pattern.

Test Run Successful.
Total tests: 1
     Passed: 1
 Total time: 1.0950 Seconds

你也许会认为执行一个被强迫失败的测试完全没有意义,但是不管一个测试多么简单,确保它的可失败性是绝对有必要的。只有这样才能够保证如果在后续工作中不小心破坏了程序的处理逻辑时该测试能够给你相应的警告。

现在停止伪造数据,将硬编码的值替换成从 API 中获取的值。我们已经构造了一个能够可靠地失败的测试,它期望一个空的购物筐中有 0 个商品,现在是时候编写一些应用程序代码了。

就跟常见的软件建模活动一样,我们先从构造一个简单的接口开始。在 app 目录下新建文件 IShoppingAPI.cs(习惯上接口名一般以大写 I 开头)。在该接口中声明一个名为 NoOfItems() 的方法,它以 int 类型返回商品的数量。下面是接口的代码:

using System;

namespace app {    
    public interface IShoppingAPI {
        int NoOfItems();
    }
}

当然这个接口什么事也做不了,在你需要实现它。在 app 目录下创建另一个文件 ShoppingAPI。在其中将 ShoppingAPI 声明为一个实现了 IShoppingAPI 的公有类。在类中定义方法 NoOfItems 返回整数 1:

using System;

namespace app {
    public class ShoppingAPI : IShoppingAPI {
        public int NoOfItems() {
            return 1;
        }
    }
}

从上面代码中你发现自己又在通过返回硬编码值 1 的方式来伪造代码逻辑。现阶段这是一件好事,因为你需要保持一切超级无敌简单。现在还不是仔细构想如何实现购物筐的处理逻辑时候。这些工作后续再做!到目前为止,你只是通过构建最简场景来检验自己是否满意现在的设计。

为了确定这一点,将硬编码值换成这个 API 在运行中收到请求时应该返回的值。你需要通过 using app; 声明来告诉测试你使用的购物逻辑代码在哪里。

接下来,你需要 实例化 instantiate IShoppingAPI 接口:

IShoppingAPI shoppingAPI = new ShoppingAPI();

这个实例用来发送请求并接收返回的值。

现在,代码变成了这样:

using System;
using Xunit;
using app;

namespace tests {
    public class ShoppingAPITests {
        IShoppingAPI shoppingAPI = [new][3] ShoppingAPI();
 
        [Fact]        
        public void NewlyCreatedBasketHas0Items() {
            var expectedNoOfItems = 0;
            var actualNoOfItems = shoppingAPI.NoOfItems();
            Assert.Equal(expectedNoOfItems, actualNoOfItems);
        }
    }
}

显然执行这个测试的结果是失败,因为你硬编码了一个错误的返回值(期望值是 0,但是返回的是 1)。

同样的,你也可以通过将硬编码的值从 1 改成 0 来让测试通过,但是现在做这个是在浪费时间。现在设计的接口已经跟测试关联上了,你剩下的职责就是编写代码实现预期的行为逻辑。

在编写应用程序代码时,你得决定用来表示购物筐得数据结构。为了保持设计的简单,尽量选择 C# 中表示集合的最简单类型。第一个想到的就是 ArrayList。它非常适合目前的使用场景——可以保存不定个数的元素,并且易于遍历访问。

因为 ArrayListSystem.Collections 包的一部分,在你的代码中需要声明:

using System.Collections;

然后 basket 的声明就变成这样了:

ArrayList basket = new ArrayList();

最后将 NoOfItems() 中的因编码值换成实际的代码:

public int NoOfItems() {
    return basket.Count;
}

这次测试能够通过了,因为最初购物筐是空的,basket.Count 返回 0。

这也是你的第一个最简场景测试要做的事情。

更多案例

目前的课后作业是处理一个丧尸,也就是第 0 个丧尸。在下一篇文章中,我将带你了解单元素场景和多元素场景。不要错过哦!

(题图:MJ/7917bc47-5325-4c0f-a2dd-4e444f57a46c)


via: https://opensource.com/article/21/2/development-guide

作者:Alex Bunardzic 选题:lujun9972 译者:toknow-gh 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

哈喽!昨天我见识到了一种我以前从没见过的从服务器推送事件的炫酷方法: 服务器推送事件 server-sent events !如果你只需要让服务器发送事件,相较于 Websockets,它们或许是一个更简便的选择。

我会聊一聊它们的用途、运作原理,以及我昨日在试着运行它们的过程中遇到的几个错误。

问题:从服务器流式推送更新

现在,我有一个启动虚拟机的 Web 服务,客户端轮询服务器,直到虚拟机启动。但我并不想使用轮询方式。

相反,我想让服务器流式推送更新。我跟 Kamal 说我要用 Websockets 来实现它,而他建议使用服务器推送事件不失为一个更简便的选择!

我登时就愣住了——那什么玩意???听起来像是些我从来没见过的稀罕玩意儿。于是乎我就查了查。

服务器推送事件就是个 HTTP 请求协议

下文便是服务器推送事件的运作流程。我-很-高-兴-地了解到它们就是个 HTTP 请求协议。

1.客户端提出一个 GET 请求(举个例子)https://yoursite.com/events 2.客户端设置 Connection: keep-alive,这样我们就能有一个长连接 3.服务器设置设置一个 Content-Type: text/event-stream 响应头 4.服务器开始推送事件,就比如下文这样:

event: status
data: one

举个例子,这里是当我借助 curl 发送请求时,一些服务器推送事件的样子:

$ curl -N 'http://localhost:3000/sessions/15/stream'
event: panda
data: one

event: panda
data: two

event: panda
data: three

event: elephant
data: four

服务器可以根据时间推移缓慢推送事件,并且客户端也能够在它们到来时读取它们。你也可以将 JSON 或任何你想要的东西放在事件当中,就比如 data: {'name': 'ahmed'}

线路协议真的很简单(只需要设置 event:data:,或者如果你愿意,可设置为 id:retry:),所以你并不需要任何花里胡哨的服务器库来实现服务器推送事件。

JavaScript 的代码也超级简单(仅使用 EventSource)

以下是用于流式服务器推送事件的浏览器 JavaScript 的代码。(我从 服务器推送事件的 MND 页面 得到的这个范例)

你可以订阅所有事件,也可以为不同类型的事件使用不同的处理程序。这里我有一个只接受类型为 panda 的事件的处理程序(就像我们的服务器在上一节中推送的那样)。

const evtSource = new EventSource("/sessions/15/stream", { withCredentials: true })
evtSource.addEventListener("panda", function(event) {
  console.log("status", event)
});

客户端在中途不能推送更新

不同于 Websockets,服务器推送事件不允许大量的来回事件通讯。(这体现在它的字眼中 —— 服务器 推送所有事件)。初始的时候客户端发出一个请求,然后服务器发出一连串响应。

如果 HTTP 连接结束,它会自动重连

使用 EventSource 发出的 HTTP 请求和常规 HTTP 请求有一个很大的区别,MDN 文档中对此有所说明:

默认情况下,如果客户端和服务器之间的连接断开,则连接会重启。请使用 .close() 方法来终止连接。

很奇怪,一开始我真的被它吓到了:我打开了一个连接,然后在服务器端将其关闭,然后两秒过后客户端向我的传送终端发送了另一条请求!

我觉得这里可能是因为连接在完成之前意外断开了,所以客户端自动重新打开了它以防止类似情况再发生。

所以如果你不想让客户端继续重试,你就得通过调用 .close() 直截了当地关闭连接。

这里还有些其它特性

你还能在服务器推送事件中设置 id:retry: 字段。似乎,如果你在服务器推送事件上设置,那么当重新连接时,客户端将发送一个 Last-Event-ID 响应头,带有它收到的最后一个 ID。酷!

我发现 W3C 的服务器推送事件页面 令人惊讶地容易理解。

在设置服务器推送事件的时候我遇到了两个错误

我在 Rails 中使用服务器推送事件时遇到了几个问题,我认为这些问题挺有趣的。其中一个缘于 Nginx,另一个是由 Rails 引起的。

问题一:我不能在事件推送的过程中暂停

这个奇怪的错误是在我做以下操作时出现的:

def handler
    # SSE is Rails' built in server-sent events thing
    sse = SSE.new(response.stream, event: "status")
    sse.write('event')
    sleep 1
    sse.write('another event')
end

它会写入第一个事件,但不能写入第二个事件。我对此-非-常-困-惑,然后放开脑洞,试着理解 Ruby 中的 sleep 是如何运作的。但是 Cass 将我引领到一个与我有着相同困惑的 Stack Overflow 问答帖,而这里包含了让我为之震惊的回答!

事实证明,问题出在我的 Rails 服务器位于 Nginx 之后,似乎 Nginx 默认使用 HTTP/1.0 向上游服务器发起请求(为啥?都 2021 年了,还这么干?我相信这其中一定有合乎情理的解释,也许是为了向下兼容之类的)。

所以客户端(Nginx)会在服务器推送第一个事件之后直接关闭连接。我觉得如果在我推送第二个事件的过程中 没有 暂停,它继续正常工作,基本上就是服务器在连接关闭之前和客户端在争速度,争着推送第二部分响应,如果我这边推送速度足够快,那么服务器就会赢得比赛。

我不确定为什么使用 HTTP/1.0 会使客户端的连接关闭(可能是因为服务器在每个事件结尾写入了两个换行符?),但因为服务器推送事件是一个比较新的玩意儿,HTTP/1.0 (这种老旧协议)不支持它一点都会不意外。

设置 proxy_http_version 1.1 从而解决那个麻烦。好欸!

问题二:事件被缓冲

这个事情解决完,第二个麻烦接踵而至。不过这个问题实际上非常好解决,因为 Cass 已经建议将 stackoverflow 里另一篇帖的回答 作为前一个问题的解决方案,虽然它并没有是导致问题一出现的源头,但它-确-实-解-释-了问题二。

问题在这个示例代码中:

def handler
    response.headers['Content-Type'] = 'text/event-stream'
    # Turn off buffering in nginx
    response.headers['X-Accel-Buffering'] = 'no'
    sse = SSE.new(response.stream, event: "status")
    10.times do
        sse.write('event')
        sleep 1
    end
end

我本来期望它每秒返回 1 个事件,持续 10 秒,但实际上它等了 10 秒才把 10 个事件一起返回。这不是我们想要的流式传输方式!

原来这是因为 Rack ETag 中间件想要计算 ETag(响应的哈希值),为此它需要整个响应为它服务。因此,我需要禁用 ETag 生成。

Stack Overflow 的回答建议完全禁用 Rack ETag 中间件,但我不想这样做,于是我去看了 链接至 GitHub 上的议题

那个 GitHub 议题建议我可以针对仅流式传输终端应用一个解决方法,即 Last-Modified 响应头,显然,这么做可以绕过 ETag 中间件。

所以我设置为:

headers['Last-Modified'] = Time.now.httpdate

然后它起作用了!!!

我还通过设置响应头 X-Accel-Buffering: no 关闭了位于 Nginx 中的缓冲区。我并没有百分百确定我要那样做,但这么做似乎更安全。

Stack Overflow 很棒

起初,我全身心致力于从头开始调试这两个错误。Cass 为我指向了那两个 Stack Overflow 帖子,一开始我对那些帖下提出的解决方案持怀疑态度(我想:“我没有使用 HTTP/1.0 啊!ETag 响应头什么玩意,跟这一切有关系吗??”)。

但结果证明,我确实无意中使用 HTTP/1.0,并且 Rack ETag 中间件确实给我带来了问题。

因此,也许这个故事告诉我,有时候计算机就是会以奇怪的方式相互作用,其它人在过去也遇到过计算机以完全相同的奇怪方式相互作用的问题,而 Stack Overflow 有时会提供关于为什么会发生这些情况的答案 : )

我认为重要的是不要随意从 Stack Overflow 中尝试各种解决方案(当然,在这种情况下不会有人建议这样做!)。对于这两个问题,我确实需要去仔细思考,了解发生了什么,还有为什么更改这些设置会起作用。

就是这样!

今天我要继续着手实现服务器推送事件,因为昨天一整天我都沉浸在上述这些错误里。好在我学到了一个以前从未听说过的易学易用的网络技术,心里还是很高兴的。

(题图:MJ/4c08a193-086e-4efe-a662-00401c928c41)


via: https://jvns.ca/blog/2021/01/12/day-36--server-sent-events-are-cool--and-a-fun-bug/

作者:Julia Evans 选题:lujun9972 译者:Drwhooooo 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

结合开放的 API 和 Python 编程语言的力量。

围绕 API 创建封装器的开源项目正变得越来越流行。这些项目使开发人员更容易与 API 交互并在他们的应用中使用它们。openshift-python-wrapper 项目是 openshift-restclient-python 的封装器。最初是一个帮助我们的团队使用 OpenShift API 的内部包,后来变成了一个开源项目(Apache 许可证 2.0)。

本文讨论了什么是 API 封装器,为什么它很有用,以及封装器的一些例子。

为什么要使用 API 封装器?

API 封装器是位于应用和 API 之间的一层代码。它通过将一些涉及发出请求和解析响应的复杂性抽象出来,以简化 API 访问过程。封装器还可以提供 API 本身提供的功能之外的附加功能,例如缓存或错误处理。

使用 API 封装器使代码更加模块化并且更易于维护。无需为每个 API 编写自定义代码,你可以使用封装器来提供与 API 交互的一致接口。它可以节省时间,避免代码重复,并减少出错的机会。

使用 API 封装器的另一个好处是它可以保护你的代码免受 API 变化的影响。如果 API 更改了它的接口,你可以更新封装器代码而无需修改你的应用程序代码。随着时间的推移,这可以减少维护应用程序所需的工作。

安装

该应用位于 PyPi 上,因此使用 pip 命令 安装 openshift-python-wrapper

$ python3 -m pip install openshift-python-wrapper

Python 封装器

OpenShift REST API 提供对 OpenShift 平台的许多功能的编程访问。封装器提供了一个简单直观的界面,用于使用 openshift-restclient-python 库与 API 进行交互。它标准化了如何使用集群资源,并提供了统一的资源 CRUD(创建、读取、更新和删除)流程。它还提供额外的功能,例如需要由用户实现的特定于资源的功能。随着时间的推移,封装器使代码更易于阅读和维护。

简化用法的一个示例是与容器交互。在容器内运行命令需要使用 Kubernetes 流、处理错误等。封装器处理这一切并提供 简单直观的功能

>>> from ocp_resources.pod import Pod
>>> from ocp_utilities.infra import get_client
>>> client = get_client()

ocp_utilities.infra INFO Trying to get
client via new_client_from_config

>>> pod = Pod(client=client, name="nginx-deployment-7fb96c846b-b48mv", namespace="default")
>>> pod.execute("ls")

ocp_resources Pod INFO Execute ls on
nginx-deployment-7fb96c846b-b48mv (ip-10-0-155-108.ec2.internal)

'bin\nboot\ndev\netc\nhome\nlib\nlib64\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv\nsys\ntmp\nusr\nvar\n'

开发人员或测试人员可以使用这个封装器,我们的团队在编写代码的同时牢记测试。使用 Python 的上下文管理器可以提供开箱即用的资源创建和删除,并且可以使用继承来扩展特定场景的功能。Pytest fixtures 可以使用代码进行设置和拆卸,不留任何遗留物。甚至可以保存资源用于调试。可以轻松收集资源清单和日志。

这是上下文管理器的示例:

@pytest.fixture(scope="module")
def namespace():
    admin_client = get_client()
    with Namespace(client=admin_client, name="test-ns",) as ns:
        ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=240)
        yield ns

def test_ns(namespace):
    print(namespace.name)

生成器遍历资源,如下所示:

>>> from ocp_resources.node import Node
>>> from ocp_utilities.infra import get_client
>>> admin_client = get_client()
# This returns a generator
>>> for node in Node.get(dyn_client=admin_client): 
        print(node.name)

ip-10-0-128-213.ec2.internal

开源社区的开源代码

套用一句流行的说法,“如果你热爱你的代码,就应该让它自由。” openshift-python-wrapper 项目最初是作为 OpenShift 虚拟化 的实用模块。随着越来越多的项目从代码中受益,我们决定将这些程序提取到一个单独的仓库中并将其开源。套用另一句俗语,“如果代码不回到你这里,那就意味着它从未属于你。” 一旦这种情况发生,它就真正成为了开源。

更多的贡献者和维护者意味着代码属于社区。欢迎大家贡献。

(题图:MJ/5ca32a4a-2194-4b36-ade9-053433e79201)


via: https://opensource.com/article/23/4/cluster-open-source-python-api-wrapper

作者:Ruth Netser 选题:lkxed 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

这篇关于 MySQL 中日期和时间的概述将帮助你在数据库表中处理时间值。

流行数据库系统 MySQL 的新老用户常常会对数据库处理时间值的方式感到困惑。有时用户不会费心去了解时间值的数据类型。这可能是因为他们觉得本身也没有什么好了解的。日期就是日期,对吧?好吧,并非总是如此。花几分钟时间了解 MySQL 如何存储和显示日期和时间是有益的。学习如何最好地利用数据库表中的时间值可以帮助你成为更好的编码者。

MySQL 时间值类型

当你在 MySQL 中新建表时,选择合适的数据类型(INTFLOATCHAR 等)高效地保存插入到表中的数据。MySQL 为时间值提供了五种数据类型。它们是 DATETIMEDATETIMETIMESTAMPYEAR

MySQL 使用 ISO 8601 格式来存储以下格式的值(LCTT 译注:国际标准 ISO 8601,是国际标准化组织的日期和时间的表示方法,全称为《数据存储和交换形式·信息交换·日期和时间的表示方法》):

  • DATEYYYY-MM-DD
  • TIMEHH:MM:SS
  • TIMESTAMPYYYY-MM-DD HH:MM:SS
  • YEARYYYY

DATETIME 与 TIMESTAMP 的比较

你可能已经注意到 日期时间 DATETIME 时间戳 TIMESTAMP 数据类型存有相同的数据。你可能想知道这两者之间是否有差异。答案是:有。

首先,可以使用的日期范围不同。DATETIME 可以保存 1000-01-01 00:00:00 和 9999-12-31 23:59:59 之间的日期,而 TIMESTAMP 的范围更有限,从 1970-01-01 00:00:01 到 2038-01-19 03:14:07 UTC。

其次,虽然两种数据类型都允许你 自动初始化 auto_initialize 自动更新 auto_update 它们各自的值(分别用 DEFAULT CURRENT_TIMESTAMPON UPDATE CURRENT_TIMESTAMP),但在 5.6.5 版本之前,对 DATETIME 值不能这样操作。如果你要用 DATETIME,你可以使用 CURRENT_TIMESTAMP 的 MySQL 同义词之一,例如 NOW()LOCALTIME()

如果你对一个 DATETIME 值使用 ON UPDATE CURENT_TIMESTAMP(或其同义词之一),但没有使用 DEFAULT CURRENT_TIMESTAMP 子句,那么这个列的默认值为 NULL。除非你在表的定义中包含 NOT NULL,在这种情况下,它默认为 0。

另一件需要记住的重要事情是,尽管通常情况下,除非你声明一个默认值,否则 DATETIMETIMESTAMP 列都没有一个默认值,但这个规则有一个例外。如果没有指定 DEFAULT CURRENT_TIMESTAMPON UPDATE CURRENT_TIMESTAMP 这两个子句,并且禁用 explicit_defaults_for_timestamp 这个变量,那么你表中的第一个 TIMESTAMP 列将被隐式创建。

要检查这个变量的状态,请运行:

mysql> show variables like 'explicit_default%';

如果你想打开或关闭它,运行这段代码(用 0 表示关闭,用 1 表示打开):

mysql> set explicit_defaults_for_timestamp = 0;

TIME

MySQL 的 时间 TIME 数据类型可能看起来很简单,但有几件事是一个优秀的程序员应该牢记的。

首先要注意的是,虽然 TIME 经常被认为是一天中的时间,但它实际上是经过的时间。换句话说,它可以是一个负值,或者可以大于 23:59:59。在 MySQL 中,一个 TIME 值的范围可以是 -838:59:59 到 838:59:59。

另外,如果你缩写一个时间值,MySQL 会因你是否使用冒号作出不同解释。例如,10:34 这个值被 MySQL 看作是 10:34:00。也就是说,十点过后的 34 分钟。但是,如果你不使用冒号写作 1034,MySQL 将其视为 00:10:34,意思是 10 分钟 34 秒。

最后,你应该知道 TIME 值(以及 DATETIMETIMESTAMP 字段的时间部分)从 5.6.4 版本开始,可以取一个小数部分。要使用它,请在数据类型定义的结尾处添加一个整数(最大值为 6)的圆括号。

time_column TIME(2)

时区

时区变化不仅在现实世界中产生混乱和疲劳,而且也会在数据库系统中制造麻烦。地球被划分为 24 个独立的时区,通常每隔 15 度经度就会发生变化。我说通常是因为一些国家行事方式不同。例如中国只在一个时区运作,而不是预期的五个时区。

你如何处理处于不同时区的数据库系统的用户就成了一个问题。幸运的是,MySQL 并没有使这个问题变得太困难。

要检查你的会话时区,请运行:

mysql> select @@session.time_zone;

如果结果显示 System,这意味着它正在使用你的 my.cnf 配置文件中设置的时区。如果你在本地计算机上运行你的 MySQL 服务器,这可能就是你会得到的,你不需要做任何改变。

如果你想改变你的会话的时区,请运行如下命令:

mysql> set time_zone = '-05:00';

这将你的时区设置为 美国/东部 US/Eastern ,比 协调世界时 UTC 晚五个小时。

获得一周的日期

为了跟上本教程后面部分的代码,你应该在你的系统中创建一个带有日期值类型的表。比如:

mysql> create table test
( row_id smallint not null auto_increment primary key,
the_date date not null);

然后使用 ISO 8601 格式在表中插入一些随机日期,如

mysql> insert into test (the_date) VALUES ('2022-01-05');

我在我的 test 表中插入了四行日期值,你插入多少行都可以。

有时你可能想知道某一天是星期几。MySQL 给了你几种实现方法。

第一种,也是最显而易见的方法,是使用 DAYNAME() 函数。如下示例表所展示,DAYNAME() 函数可以告诉你每个日期是星期几:

mysql> SELECT the_date, DAYNAME(the_date) FROM test;
+------------+-------------------------------+
| the_date   | DAYNAME(the_date)             |
+------------+-------------------------------+
| 2021-11-02 | Tuesday                       |
| 2022-01-05 | Wednesday                     |
| 2022-05-03 | Tuesday                       |
| 2023-01-13 | Friday                        |
+------------+-------------------------------+
4 rows in set (0.00 sec)

另外两种获取星期几的方法是返回整数值,而不是星期几的名称,分别是 WEEKDAY()DAYOFWEEK()。他们都返回数字,却又各不相同。WEEKDAY() 函数返回从 0 到 6 的数字,其中 0 代表星期一,6 代表星期日。而 DAYOFWEEK() 则返回从 1 到 7 的数字,其中 1 代表星期日,7 代表星期六。

mysql> SELECT the_date, DAYNAME(the_date),
WEEKDAY(the_date), DAYOFWEEK(the_date) FROM test;
+------------+------------------+------------------+--------------------+
| the_date   | DAYNAME(the_date)| WEEKDAY(the_date)| DAYOFWEEK(the_date)|
| 2021-11-02 | Tuesday          | 1                | 3                  |
| 2022-01-05 | Wednesday        | 2                | 4                  |
| 2022-05-03 | Tuesday          | 1                | 3                  |
| 2023-01-13 | Friday           | 4                | 6                  |
+------------+------------------+------------------+--------------------+
4 rows in set (0.00 sec)

当你只想获取日期的一部分时

有时你可能在 MySQL 表中存储了一个日期,但是你只想获取日期的一部分。这并不是问题。

MySQL 中有几个顾名思义的函数,可以轻松获取日期对象的特定部分。以下是一些示例:

mysql> SELECT the_date, YEAR(the_date), MONTHNAME(the_date), 
DAYOFMONTH(the_date) FROM test ;
+-----------+---------------+-------------------+---------------------+
| the_date  | YEAR(the_date)|MONTHNAME(the_date)| DAYOFMONTH(the_date)|
+-----------+---------------+-------------------+---------------------+
| 2021-11-02| 2021          | November          | 2                   |
| 2022-01-05| 2022          | January           | 5                   |
| 2022-05-03| 2022          | May               | 3                   |
| 2023-01-13| 2023          | January           | 13                  |
+-----------+---------------+-------------------+---------------------+
4 rows in set (0.00 sec)

MySQL 也允许你使用 EXTRACT() 函数来获取日期的一部分。你提供给函数的参数是一个单位说明符(确保是单数形式)、FROM 和列名。因此,为了从我们的 test 表中仅获取年份,你可以写:

mysql> SELECT EXTRACT(YEAR FROM the_date) FROM test;
+----------------------------------------------+
| EXTRACT(YEAR FROM the_date)                  |
+----------------------------------------------+
| 2021                                         |
| 2022                                         |
| 2022                                         |
| 2023                                         |
+----------------------------------------------+
4 rows in set (0.01 sec)

插入和读取不同格式的日期

正如之前提到的,MySQL 使用 ISO 8601 格式存储日期和时间值。但是如果你想以另一种方式存储日期和时间值,例如 MM-DD-YYYY 格式,怎么办?首先,不要尝试这样做。MySQL 以 8601 格式存储日期和时间,就是这样。不要尝试更改它。但是,这并不意味着你必须在将数据输入到数据库之前将数据转换为特定的格式,或者你不能以任何你想要的格式展示数据。

如果你想要将非 ISO 的格式的日期输入到表中,你可以使用 STR_TO_DATE() 函数。第一个参数是你想要存储在数据库中的日期的字符串值。第二个参数是格式化字符串,它让 MySQL 知道日期的组织方式。让我们看一个简单的例子,然后我将更深入地研究这个看起来很奇怪的格式化字符串是什么。

mysql> insert into test (the_date) values (str_to_date('January 13, 2023','%M %d, %Y'));

Query OK, 1 row affected (0.00 sec)

你将格式化字符串放在引号中,并在每个特殊字符前加上百分号。上面代码中的格式序列告诉 MySQL 我的日期由一个完整的月份名称 %M,后跟一个两位数的日期%d,然后是一个逗号,最后由一个四位数的年份 %Y 组成。请注意,大写很重要。

一些其他常用的格式化字符串字符是:

  • %b 缩写月份的名称(例如: Jan
  • %c 数字月份(例如: 1)
  • %W 星期名称(例如: `Saturday)
  • %a 星期名称的缩写(例如: Sat
  • %T 24 小时制的时间(例如: 22:01:22
  • %r 带 AM/PM 的 12 小时制的时间(例如: 10:01:22 PM
  • %y 两位数的年份(例如: 23)

请注意,对于两位数年份 %y,年份范围是 1970 到 2069。因此,从 70 到 99 的数字被假定为 20 世纪,而从 00 到 69 的数字被假定为 21 世纪。

如果你有一个日期存储在你的数据库中,你想用不同的格式显示它,你可以使用这个 DATE_FORMAT() 函数:

mysql> SELECT DATE_FORMAT(the_date, '%W, %b. %d, %y') FROM test;
+-----------------------------------------+
| DATE_FORMAT(the_date, '%W, %b. %d, %y') |
+-----------------------------------------+
| Tuesday, Nov. 02, 21                    |
| Wednesday, Jan. 05, 22                  |
| Tuesday, May. 03, 22                    |
| Friday, Jan. 13, 23                     |
+-----------------------------------------+
4 rows in set (0.00 sec)

总结

本教程应该为你提供了一个关于 MySQL 中的日期和时间值的有用的概述。我希望本文教会了您一些新知识,使您能够更好地控制和理解 MySQL 数据库如何处理时间值。

(题图:MJ/76b6481a-a271-4e81-bc17-dd7fbe08a240)


via: https://opensource.com/article/23/2/temporal-values-mysql

作者:Hunter Coleman 选题:lkxed 译者:hanszhao80 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

推进你的 Rust 学习,熟悉 Rust 程序的变量和常量。

该系列的第一章中,我讲述了为什么 Rust 是一门越来越流行的编程语言。我还展示了如何 在 Rust 中编写 Hello World 程序

让我们继续 Rust 之旅。在本文中,我将向你介绍 Rust 编程语言中的变量和常量。

此外,我还将讲解一个称为“ 遮蔽 shadowing ”的新编程概念。

Rust 变量的独特之处

在编程语言中,变量是指 存储某些数据的内存地址的一个别名

对 Rust 语言来讲也是如此。但是 Rust 有一个独特的“特性”。每个你声明的变量都是 默认 不可变的 immutable 。这意味着一旦给变量赋值,就不能再改变它的值。

这个决定是为了确保默认情况下,你不需要使用 自旋锁 spin lock 互斥锁 mutex 等特殊机制来引入多线程。Rust 会保证 安全的并发。由于所有变量(默认情况下)都是不可变的,因此你不需要担心线程会无意中更改变量值。

这并不是在说 Rust 中的变量就像常量一样,因为它们确实不是常量。变量可以被显式地定义为可变的。这样的变量称为 可变变量

这是在 Rust 中声明变量的语法:

// 默认情况下不可变
// 初始化值是**唯一**的值
let variable_name = value;

// 使用 'mut' 关键字定义可变变量
// 初始化值可以被改变
let mut variable_name = value;

? 尽管你可以改变可变变量的值,但你不能将另一种数据类型的值赋值给它。

这意味着,如果你有一个可变的浮点型变量,你不能在后面将一个字符赋值给它。

Rust 数据类型概观

在上一篇文章中,你可能注意到了我提到 Rust 是一种强类型语言。但是在定义变量时,你不需要指定数据类型,而是使用一个通用的关键字 let

Rust 编译器可以根据赋值给变量的值推断出变量的数据类型。但是如果你仍然希望明确指定数据类型并希望注释类型,那么可以这样做。以下是语法:

let variable_name: data_type = value;

下面是 Rust 编程语言中一些常见的数据类型:

  • 整数类型:分别用于有符号和无符号的 32 位整数的 i32u32
  • 浮点类型:分别用于 32 位和 64 位浮点数的 f32f64
  • 布尔类型bool
  • 字符类型char

我会在下一篇文章中更详细地介绍 Rust 的数据类型。现在,这应该足够了。

? Rust 并不支持隐式类型转换。因此,如果你将值 8 赋给一个浮点型变量,你将会遇到编译时错误。你应该赋的值是 8.8.0

Rust 还强制要求在读取存储在其中的值之前初始化变量。

{ // 该代码块不会被编译
    let a;
    println!("{}", a); // 本行报错
    // 读取一个**未初始化**变量的值是一个编译时错误
}

{ // 该代码块会被编译
    let a;
    a = 128;
    println!("{}", a); // 本行不会报错
    // 变量 'a' 有一个初始值
}

如果你在不初始化的情况下声明一个变量,并在给它赋值之前使用它,Rust 编译器将会抛出一个 编译时错误

虽然错误很烦人,但在这种情况下,Rust 编译器强制你不要犯写代码时常见的错误之一:未初始化的变量。

Rust 编译器的错误信息

来写几个程序,你将

  • 通过执行“正常”的任务来理解 Rust 的设计,这些任务实际上是内存相关问题的主要原因
  • 阅读和理解 Rust 编译器的错误/警告信息

测试变量的不可变性

让我们故意写一个试图修改不可变变量的程序,看看接下来会发生什么。

fn main() {
    let mut a = 172;
    let b = 273;
    println!("a: {a}, b: {b}");

    a = 380;
    b = 420;
    println!("a: {}, b: {}", a, b);
}

直到第 4 行看起来都是一个简单的程序。但是在第 7 行,变量 b —— 一个不可变变量 —— 的值被修改了。

注意打印 Rust 变量值的两种方法。在第 4 行,我将变量括在花括号中,以便打印它们的值。在第 8 行,我保持括号为空,并使用 C 的风格将变量作为参数。这两种方法都是有效的。(除了修改不可变变量的值,这个程序中的所有内容都是正确的。)

来编译一下!如果你按照上一章的步骤做了,你已经知道该怎么做了。

$ rustc main.rs
error[E0384]: cannot assign twice to immutable variable `b`
 --> main.rs:7:5
  |
3 |     let b = 273;
  |         -
  |         |
  |         first assignment to `b`
  |         help: consider making this binding mutable: `mut b`
...
7 |     b = 420;
  |     ^^^^^^^ cannot assign twice to immutable variable

error: aborting due to previous error

For more information about this error, try `rustc --explain E0384`.
? “binding” 一词是指变量名。但这只是一个简单的解释。

这很好的展示了 Rust 强大的错误检查和信息丰富的错误信息。第一行展示了阻止上述代码编译的错误信息:

error[E0384]: cannot assign twice to immutable variable b

这意味着,Rust 编译器注意到我试图给变量 b 重新赋值,但变量 b 是一个不可变变量。所以这就是导致这个错误的原因。

编译器甚至可以识别出错误发生的确切行和列号。

在显示 first assignment to b 的行下面,是提供帮助的行。因为我正在改变不可变变量 b 的值,所以我被告知使用 mut 关键字将变量 b 声明为可变变量。

?️ 自己实现一个修复来更好地理解手头的问题。

使用未初始化的变量

现在,让我们看看当我们尝试读取未初始化变量的值时,Rust 编译器会做什么。

fn main() {
    let a: i32;
    a = 123;
    println!("a: {a}");

    let b: i32;
    println!("b: {b}");
    b = 123;
}

这里,我有两个不可变变量 ab,在声明时都没有初始化。变量 a 在其值被读取之前被赋予了一个值。但是变量 b 的值在被赋予初始值之前被读取了。

来编译一下,看看结果。

$ rustc main.rs
warning: value assigned to `b` is never read
 --> main.rs:8:5
  |
8 |     b = 123;
  |     ^
  |
  = help: maybe it is overwritten before being read?
  = note: `#[warn(unused_assignments)]` on by default

error[E0381]: used binding `b` is possibly-uninitialized
 --> main.rs:7:19
  |
6 |     let b: i32;
  |         - binding declared here but left uninitialized
7 |     println!("b: {b}");
  |                   ^ `b` used here but it is possibly-uninitialized
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

error: aborting due to previous error; 1 warning emitted

For more information about this error, try `rustc --explain E0381`.

这里,Rust 编译器抛出了一个编译时错误和一个警告。警告说变量 b 的值从来没有被读取过。

但是这是荒谬的!变量 b 的值在第 7 行被访问了。但是仔细看;警告是关于第 8 行的。这很令人困惑;让我们暂时跳过这个警告,继续看错误。

这个错误信息说 used binding b is possibly-uninitialized。和之前的例子一样,Rust 编译器指出错误是由于尝试在第 7 行读取变量 b 的值而引起的。读取变量 b 的值是错误的原因是它的值没有初始化。在 Rust 编程语言中,这是非法的。因此编译时错误出现。

?️ 这个错误可以很容易地通过交换第 7 和第 8 行的代码来解决。试一下,看看错误是否消失了。

示例程序:交换数字

现在你已经熟悉了常见的变量相关问题,让我们来看一个交换两个变量值的程序。

fn main() {
    let mut a = 7186932;
    let mut b = 1276561;

    println!("a: {a}, b: {b}");

    // 交换变量值
    let temp = a;
    a = b;
    b = temp;

    println!("a: {}, b: {}", a, b);
}

我在这里声明了两个变量 ab。这两个变量都是可变的,因为我希望在后面改变它们的值。我赋予了一些随机值。最初,我打印了这些变量的值。

然后,在第 8 行,我创建了一个名为 temp 的不可变变量,并将存储在 a 中的值赋给它。之所以这个变量是不可变的,是因为 temp 的值不会改变。

要交换值,我将变量 b 的值赋给变量 a,在下一行,我将 temp 的值(它包含 a 的值)赋给变量 b。现在值已经交换了,我打印了变量 ab 的值。

在编译并执行上面的代码后,我得到了以下输出:

a: 7186932, b: 1276561
a: 1276561, b: 7186932

正如你所见,值已经交换了。完美。

使用未使用的变量

当你声明了一些变量,打算在后面使用它们,但是还没有使用它们,然后编译你的 Rust 代码来检查一些东西时,Rust 编译器会警告你。

原因是显而易见的。不会被使用的变量占用了不必要的初始化时间(CPU 周期)和内存空间。如果不会被使用,为什么要在程序写上它呢?尽管编译器确实会优化这一点。但是它仍然是一个问题,因为它会以多余的代码的形式影响可读性。

但是,有的时候,你可能会面对这样的情况:创建一个变量与否不在你的控制之下。比如说,当一个函数返回多个值,而你只需要其中的一些值时。在这种情况下,你不能要求库维护者根据你的需要调整他们的函数。

所以,在这种情况下,你可以写一个以下划线开头的变量,Rust 编译器将不再显示这样的警告。如果你真的不需要使用存储在该未使用变量中的值,你可以简单地将其命名为 _(下划线),Rust 编译器也会忽略它!

接下来的程序不仅不会生成任何输出,而且也不会生成任何警告和/或错误消息:

fn main() {
    let _unnecessary_var = 0; // 没有警告
    let _ = 0.0; // 完全忽略
}

算术运算

数学就是数学,Rust 并没有在这方面创新。你可以使用在其他编程语言(如 C、C++ 和/或 Java)中使用过的所有算术运算符。

包含可以在 Rust 编程语言中使用的所有运算符和它们的含义的完整列表可以在 这里 找到。

示例程序:一个生锈的温度计

(LCTT 译注:这里的温度计“生锈”了是因为它是使用 Rust(生锈)编写的,原作者在这里玩了一个双关。)

接下来是一个典型的程序,它将华氏度转换为摄氏度,反之亦然。

fn main() {
    let boiling_water_f: f64 = 212.0;
    let frozen_water_c: f64 = 0.0;

    let boiling_water_c = (boiling_water_f - 32.0) * (5.0 / 9.0);
    let frozen_water_f = (frozen_water_c * (9.0 / 5.0)) + 32.0;

    println!(
        "Water starts boiling at {}°C (or {}°F).",
        boiling_water_c, boiling_water_f
    );
    println!(
        "Water starts freezing at {}°C (or {}°F).",
        frozen_water_c, frozen_water_f
    );
}

没什么大不了的……华氏温度转换为摄氏温度,反之亦然。

正如你在这里看到的,由于 Rust 不允许自动类型转换,我不得不在整数 32、9 和 5 后放一个小数点。除此之外,这与你在 C、C++ 和/或 Java 中所做的类似。

作为练习,尝试编写一个程序,找出给定数中有多少位数字。

常量

如果你有一些编程知识,你可能知道这意味着什么。常量是一种特殊类型的变量,它的值永远不会改变它保持不变

在 Rust 编程语言中,使用以下语法声明常量:

const CONSTANT_NAME: data_type = value;

如你所见,声明常量的语法与我们在 Rust 中看到的变量声明非常相似。但是有两个不同之处:

  • 常量的名字需要像 SCREAMING_SNAKE_CASE 这样。所有的大写字母和单词之间用下划线分隔。
  • 常量的数据类型必须被显性定义。

变量与常量的对比

你可能在想,既然变量默认是不可变的,为什么语言还要包含常量呢?

接下来这个表格应该可以帮助你消除疑虑。(如果你好奇并且想更好地理解这些区别,你可以看看我的博客,它详细地展示了这些区别。)

一个展示 Rust 编程语言中变量和常量之间区别的表格

使用常量的示例程序:计算圆的面积

这是一个很直接的关于 Rust 中常量的简单程序。它计算圆的面积和周长。

fn main() {
    const PI: f64 = 3.14;
    let radius: f64 = 50.0;

    let circle_area = PI * (radius * radius);
    let circle_perimeter = 2.0 * PI * radius;

    println!("有一个周长为 {radius} 厘米的圆");
    println!("它的面积是 {} 平方厘米", circle_area);
    println!(
        "以及它的周长是 {} 厘米",
        circle_perimeter
    );
}

如果运行代码,将产生以下输出:

有一个周长为 50 厘米的圆
它的面积是 7850 平方厘米
以及它的周长是 314 厘米

Rust 中的变量遮蔽

如果你是一个 C++ 程序员,你可能已经知道我在说什么了。当程序员声明一个与已经声明的变量同名的新变量时,这就是变量遮蔽。

与 C++ 不同,Rust 允许你在同一作用域中执行变量遮蔽!

? 当程序员遮蔽一个已经存在的变量时,新变量会被分配一个新的内存地址,但是使用与现有变量相同的名称引用。

来看看它在 Rust 中是如何工作的。

fn main() {
    let a = 108;
    println!("a 的地址: {:p}, a 的值 {a}", &a);
    let a = 56;
    println!("a 的地址: {:p}, a 的值: {a} // 遮蔽后", &a);

    let mut b = 82;
    println!("\nb 的地址: {:p}, b 的值: {b}", &b);
    let mut b = 120;
    println!("b的地址: {:p}, b的值: {b} // 遮蔽后", &b);

    let mut c = 18;
    println!("\nc 的地址: {:p}, c的值: {c}", &c);
    c = 29;
    println!("c 的地址: {:p}, c的值: {c} // 遮蔽后", &c);
}

println 语句中花括号内的 :p 与 C 中的 %p 类似。它指定值的格式为内存地址(指针)。

我在这里使用了 3 个变量。变量 a 是不可变的,并且在第 4 行被遮蔽。变量 b 是可变的,并且在第 9 行也被遮蔽。变量 c 是可变的,但是在第 14 行,只有它的值被改变了。它没有被遮蔽。

现在,让我们看看输出。

a 的地址: 0x7ffe954bf614, a 的值 108
a 的地址: 0x7ffe954bf674, a 的值: 56 // 遮蔽后

b 的地址: 0x7ffe954bf6d4, b 的值: 82
b 的地址: 0x7ffe954bf734, b 的值: 120 // 遮蔽后

c 的地址: 0x7ffe954bf734, c 的值: 18
c 的地址: 0x7ffe954bf734, c 的值: 29 // 遮蔽后

来看看输出,你会发现不仅所有三个变量的值都改变了,而且被遮蔽的变量的地址也不同(检查十六进制的最后几个字符)。

变量 ab 的内存地址改变了。这意味着变量的可变性或不可变性并不是遮蔽变量的限制。

总结

本文介绍了 Rust 编程语言中的变量和常量。还介绍了算术运算。

做个总结:

  • Rust 中的变量默认是不可变的,但是可以引入可变性。
  • 程序员需要显式地指定变量的可变性。
  • 常量总是不可变的,无论如何都需要类型注释。
  • 变量遮蔽是指使用与现有变量相同的名称声明一个 变量。

很好!我相信和 Rust 一起的进展不错。在下一章中,我将讨论 Rust 中的数据类型。敬请关注。

与此同时,如果你有任何问题,请告诉我。

(题图:MJ/7c5366b8-f926-487e-9153-0a877145ca5)


via: https://itsfoss.com/rust-variables/

作者:Pratham Patel 选题:lkxed 译者:Cubik 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

使用这些 Drupal 模块,使你的网站对每个人都可以无障碍访问。

随着网站的 无障碍访问 accessibility 继续成为人们日益关注的问题,网站所有者和开发人员需要确保他们的网站符合美国残疾人法案(ADA)。Drupal 是一种流行的开源内容管理系统(CMS),它提供各种工具和模块以确保所有用户都可以访问你的网站,而无论他们的能力如何。本文讨论了网站无障碍访问的重要性、ADA 合规性的基本要求,以及 Drupal 如何帮助你实现合规性。

为什么网站无障碍访问很重要

出于多种原因,网站无障碍访问很重要。首先,它确保残障人士可以访问和使用你的网站。这包括有视觉、听觉、身体和认知障碍的人。通过使你的网站可以无障碍访问,你不仅遵守了法律,而且还为所有用户提供了更好的体验。

此外,网站无障碍访问可以改善你网站的搜索引擎优化(SEO)并提高网站的可用性。搜索引擎优先考虑无障碍的网站,如果易于使用,用户更有可能在你的网站上停留更长时间并与你互动。

ADA 合规性的基本要求

ADA 要求所有网站和数字内容都可供残障人士访问。ADA 合规性的一些基本要求包括:

  • 为所有图像和非文本内容提供替代文本描述。
  • 确保所有视频都有字幕和文字说明。
  • 使用颜色对比和其他设计元素使你的网站更具可读性。
  • 提供访问内容的替代方式,例如音频描述和键盘导航。
  • 确保你的网站与辅助技术兼容,例如屏幕阅读器和盲文显示器。

Drupal 如何帮助你实现合规性

Drupal 提供各种工具和模块来帮助你实现网站的无障碍和 ADA 合规性。以下是我认为最有用的七个:

总结

出于法律原因和为所有用户提供更好的用户体验,确保网站无障碍访问和 ADA 合规性非常重要。从无障碍访问检查器到颜色对比分析器,Drupal 提供了多种方法来确保你的网站符合 ADA 标准。使用 Drupal 的工具和模块,你可以让每个人都可以访问你的网站,无论他们的能力如何,并提供更好的用户体验。


via: https://opensource.com/article/23/4/drupal-modules-website-accessibility

作者:Neeraj Kumar 选题:lkxed 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出