Linux-设备设计-全-

Linux 设备设计(全)

原文:Linux Appliance Design

译者:飞龙

协议:CC BY-NC-SA 4.0

引言

烤面包机、烤箱和洗碗机是我们日常生活中常见的设备。尽管我们对它们的使用非常熟悉,但很少有人停下来思考这些设备内部是如何工作的,甚至是什么让设备成为设备。这本书将设备定义为主要执行单一功能的设备。如果你考虑一下刚才提到的设备,你会发现这个定义是正确的——烤面包机烤面包,烤箱烘焙,洗碗机洗碗。与能够根据安装的硬件和软件执行数千种不同功能的 PC 相比,传统设备显得无聊且简单。

这与 Linux 有什么关系?首先,传统的设备不再那么简单。曾经只是电动但仍然机械的设备,如吸尘器,现在不仅电子化,还包括处理器、电路板和复杂的用户界面。随着这些变化,需要在设备上运行操作系统来管理新特性。Linux 是这一领域的自然选择,因为它成本低(在大多数情况下,它是免费的)且开源(这意味着你可以修改它以更好地满足你的需求)。然而,Linux 真正适合的地方是随着新类型设备的出现。数字视频录像机(DVR)几年前还闻所未闻,但第一个也是最流行的 DVR 设备,TiVo,运行在 Linux 上,许多其他家庭网络和娱乐设备也是如此。

如果你要构建下一个伟大的家用清洁机器人系统,你将希望避免从头开始设计它。你会尽可能多地重用你早期机器人中的部件,并在可能的情况下使用现成的组件。同样的重用理念也适用于 Linux 设备,这正是这本书能帮到你的地方。

本书涉及的内容

这本书展示了如何构建一个 Linux 设备,并包括一个原型设备,你可以将其作为你设备的起点,如果你愿意的话。我们将设备分为守护进程和用户界面,并展示了如何创建和管理守护进程,以及如何构建五种不同类型的用户界面。

我们涵盖了以下主题:

• 设备架构

• 如何与运行中的守护进程通信

• 如何构建和保障守护进程

• Laddie,我们的示例设备

• 记录和事件处理

• 基于 Web 的用户界面

• 命令行界面(CLIs)

• 前面板接口

• 帧缓冲接口,包括红外遥控

• SNMP 接口,包括工具、MIBs 和代理

大多数章节都有相同的基本布局。我们定义并解释为什么这个特性是必要的,我们描述了特性的挑战和常见方法,以及我们如何在我们的示例设备中实现这个特性。

本书不涉及的内容

本书不涵盖 C 编程、Linux 操作系统或定义特定 Linux 设备的主要应用程序。此外,本书也不是关于嵌入式 Linux 的。我们相信您会发现以下关于嵌入式 Linux 的书籍很有用:

• 《嵌入式 Linux》,作者 John Lombardo(SAMS/New Riders,2001 年)

• 《嵌入式 Linux:硬件、软件和接口》,作者 Craig Hollabaugh(Addison-Wesley,2002 年)

• 《嵌入式和实时应用的 Linux,第二版》,作者 Doug Abbott(Newnes,2006 年)

• 《嵌入式 Linux 系统设计与开发》,作者 P. Raghavan、Amol Lad 和 Sriram Neelakandan(Auerbach Publications,2005 年)

谁应该阅读这本书

这本书是为想要构建定制 Linux 设备并支持多个用户界面、构建安全的守护进程、提供日志和事件管理的 Linux 程序员而写的。这本书也可能对任何希望将守护进程的用户界面移植到不同的操作系统或不同的编程语言的开发者有所帮助。我们唯一的重大假设是,读者在 Linux 平台上对 C 语言编程感到舒适。

为什么使用 Linux?

在深入探讨大多数 Linux 设备上常见的架构之前,我们应该回答一个问题,“为什么要在设备上使用 Linux?”虽然具体的论点各不相同,但我们发现以下观点适用于我们构建的设备。

源代码的可用性

源代码的可用性使得根据特定设备的需要定制操作系统成为可能。当使用专有、封闭源代码的操作系统时,这种定制是不可能的。

支持的硬件范围

Linux 内核支持广泛的处理器,从用于消费电子的低端嵌入式处理器到用于超级计算机的高端 64 位处理器。例如,Linux 运行在 Marvell 的基于 ARM 的 XScale 处理器(用于 Palm 手持电脑)、德州仪器的基于 ARM 的 OMAP 处理器(用于 E28 智能手机)、IBM 的 PowerPC(用于 TiVo 和 PlayStation 3)、日立的 H8/300 处理器,以及 Compaq Alpha AXP、Sun SPARC、IBM S/390、MIPS、HP PA-RISC、Intel IA-64 和 AMD x86-64 处理器。

Linux 开发者的可用性

Linux、C 和 C++是美国许多计算机科学毕业生的核心能力,实际上,在全球范围内也是如此。确切的估计各不相同,但共识是,全球有数百万名具备 Linux 能力的开发者。

可靠性

Linux 的可靠性足够高,以至于在数据中心得到广泛应用。Linux 的运行时间以年为单位,并不罕见,而且蓝屏死机从未成为问题。

高质量的编译器

GNU 编译器集合是一套综合的编译器、汇编器、链接器和调试器,支持在多个平台上使用多种语言。它是 Linux 上 C 和 C++ 开发的首选编译器。此外,它是免费的。更多信息请访问 http://gcc.gnu.org

良好的文档

在互联网上可以找到大量关于 Linux 的文档。Linux 文档项目(见 http://en.tldp.org)是一个很好的文档起点。该网站包括特定主题的 HOWTO、FAQ 和深入指南。

现有的软件包

有数千个软件包可供帮助您在 Linux 上开发设备。例如,有 Net-SNMP 用于简单网络管理协议(SNMP)支持,lm_sensors 用于监控设备硬件环境,以及 lighttpd 用于 Web 支持。

低开发成本

Linux 设备程序员通常可以使用他们的桌面机器进行大部分软件开发,因为设备用户界面和服务很少需要在目标硬件上开发。因此,软件开发可以并行进行,一个团队为设备开发嵌入式 Linux,另一个团队开发主要应用程序和用户界面。

部署无需许可费用

设备制造商通常可以构建基于 Linux 的设备,而无需支付分发设备软件的许可费用,尽管有一些例外。例如,Trolltech 的 Qt 库和 MySQL 数据库的许可证可能需要支付商业使用费。

安全

Linux 设备开发者使用如 grsecurity 和 systrace 等软件包来加强安全性,达到 Windows 开发者难以想象的水平。设备还有额外的优势,即多用途的桌面或服务器永远无法像经过良好加固的单用途设备那样安全。

Linux 设备设计

我们试图编写这本书和配套的光盘,以便用作一种“零件箱”,您可以在组装设备时从中取用。第一章提供了我们零件箱中组件的精彩概述,它展示了大多数 Linux 设备共有的架构的高级视图。

设备架构

我们将从对 Linux 设备架构的高层次概述开始我们的冒险。然后,我们将降低视角,从进程的角度来看待 Linux 设备。正如你很快就会看到的,从这个较低的角度来看,与本书中使用的组织和章节相匹配。

在本章中,我们将涵盖以下内容:

• UI 和守护进程

• Laddie 设备的架构

我们已经研究过从小型手持设备到大型、多吉字节、多处理器的网络服务器的各种 Linux 设备。这些设备中的大多数都具有非常相似的软件架构。

图 1-1 显示了我们在 Linux 设备中通常看到的软件堆栈。在这个堆栈的底部是嵌入式 Linux 内核。在内核之上是各种用户界面和常见服务,如网络管理和日志记录,而在顶部是定义设备的特定功能。

在设备方面,术语用户界面(UI)指的是用户通过它管理设备配置并查看其状态和统计信息的界面。没有屏幕和键盘是设备的标志,但不要被这蒙蔽了——所有设备都有 UI。当然,UI 越不明显,设备越好,但 UI 总是存在的。此外,网络设备通常具有 Web、SNMP 和命令行界面,而消费设备具有帧缓冲区和小型、字母数字 LCD 界面。

图 1-1:Linux 设备软件堆栈

UI 和守护进程

假设我们的 Linux 设备将拥有多个、同时运行的 UI,当我们从运行进程的角度来看这个设备时,我们得到的架构类似于图 1-2 所示。UI 程序与用户交互,接受命令和配置,并显示状态和统计信息。另一方面,守护进程与硬件、其他守护进程和 UI 交互,以提供设备的定义服务以及状态和统计信息。

图 1-2:常见的设备架构

守护进程

守护进程是启动 Linux 后通常立即启动的后台程序。守护进程的特点是它们没有像从 bash 命令行启动的程序那样的控制终端。让我们看看在典型设备上发现的守护进程类型。

定义应用程序

图表中的定义应用程序指的是提供设备独特功能的守护进程。例如,高级电话答录机的定义应用程序是实际接听电话并记录通话的守护进程。

日志守护进程

图 1-2 中显示的日志守护程序收集日志消息,要么将它们保存到磁盘,要么将它们路由到网络上的另一个主机。syslog 守护程序是大多数 Linux 系统上的默认日志守护程序。

事件处理器

事件处理器提供对事件的本地、主动响应。通常,日志守护程序和事件处理守护程序是同一个,就像在我们的示例设备上运行的 logmuxd 守护程序一样。

安全监控器

安全监控器控制对关键配置或资源的访问,例如身份验证凭证。安全监控器还应响应强制访问控制(MAC)违规。

硬件监控器

硬件监控器监视温度警报和磁盘驱动器问题。大多数基于 PC 的 Linux 设备将使用 lm_sensors 包来监控 CPU 和主板传感器,以及 smartd 守护程序来监控硬盘的温度和错误统计。硬件监控器可能将来自这些和其他来源的信息组合成一个关于设备健康状况的综合报告。

用户界面

当我们最初开始构建 Linux 设备时,我们认为设备的性质决定了它将拥有的 UI 类型。哎呀,我们错了。客户总是要求有多种管理设备的方式。智能手机需要帧缓冲区接口通过蓝牙的 Web 接口。网络设备需要 Web 接口SNMP 接口。当你查看图 1-2 时,不要想“哪一个?”而要思考“有多少?”

图 1-2 中展示的 UI(用户界面)并不是唯一可能的选择。例如,你可能需要一个在 Windows PC 上原生运行的界面,或者如果你正在构建网络设备,你可能想要将接口添加到 LDAP 或 RADIUS 认证服务器,或者网络的计费系统和数据库。图 1-2 展示了最常见的 UI 以及书中描述的 UI。

Web 接口

如果你的设备有网络接口,则必须要有Web 接口。你在这里有很多决定要做:你是否使用 JavaScript?后端是用 Perl、PHP、C 还是 Java 编写的?你使用哪个?你是否假设所有浏览器都支持层叠样式表?第八章关于 Web UI 的内容将帮助你评估所有这些问题的权衡。

帧缓冲区接口

帧缓冲区接口在电视机顶盒、如 TiVo 或 PVR、独立式自助终端和一些手持设备中很受欢迎。帧缓冲区硬件让你能够直接控制屏幕上的每个像素。这为你提供了很大的灵活性,但代价是必须管理屏幕上的每个像素。一些库和图形工具集,如 Simple DirectMedia Layer (SDL),可以提供帮助。构建帧缓冲区接口的艺术在于选择正确的工具集。

前面板

前面板接口,无论是简单还是复杂,几乎出现在所有 Linux 设备上。一个简单的前面板可能只有几个灯和按钮,而一个更复杂的可能有一个字母数字液晶显示器(LCD)或真空荧光显示器。即使是简单的前面板也可能需要深入了解底层硬件。

SNMP 接口

我们听说,一个SNMP 接口是商业上可行的网络设备和爱好之间的区别。根据我们的经验,我们必须同意。如果你将其分解成小块,SNMP 并不太难。首先,你需要熟悉 SNMP 中使用的概念以及 Linux 中可用的 SNMP 命令。然后,你需要为你的 SNMP 接口可见的数据设计一个管理信息库(MIB),或称为模式。最后,你需要编写使 MIB 可供 SNMP 命令使用的软件。

命令行界面

命令行界面(CLIs)通常被用作网络设备的最后手段的控制接口。即使在整个网络断开的情况下,串行端口上的 CLI 仍然可用。CLI 还用作设备特定的脚本语言。

进程间通信

最后,您可能已经注意到了图 1-2 中线的全网状互连。不要让它吓到你。我们的观点是,任何 UI 都应该能够连接到任何守护进程。这个要求决定了在 UI 和守护进程之间使用的进程间通信(IPC)机制中要查找的许多功能。(我们将在第二章中对此有更多讨论。)

Laddie 设备的架构

本书中的示例设备是一个使用并行端口上的输入引脚从警报传感器接收输入的警报系统。用户界面包括网页、命令行、带键盘的 LCD、带红外遥控的帧缓冲区以及 SNMP。

我们设备上的守护进程包括警报系统守护进程和一个响应设备事件的守护进程。我们选择不实现图 1-2 中显示的所有守护进程,以便我们可以专注于描述如何构建和确保守护进程的一般方法。

当然,我们的示例设备包括 ladd(定义性应用程序)、事件处理程序以及一个实用程序,它使用所有 UI 通用的协议使常见的 Linux 配置文件可见。

图 1-3 显示了 Laddie 设备的架构,并将 UI、功能或守护进程映射到章节编号或附录,以便您可以看到全书如何将事物组合在一起。

图 1-3:Laddie 设备的章节图

我们限制了 UI 的功能,使它们作为教程更有用。只有网页界面是全功能的,并且代表了真实设备可能拥有的功能。

摘要

大多数 Linux 设备都拥有一个共同的架构:底层是 Linux 操作系统,顶层是定义性的应用程序,中间是常见的服务和 UI。我们讨论了包含各种守护进程和 UI 的一些原因,并将本书的章节映射到一个架构图中。

下一章将探讨 UI 和守护进程之间的 API,因为所选的 API 会影响 UI 和守护进程。

图片

管理守护进程

图片

在本质上,大多数设备都有一个应用程序或守护进程执行设备的定义功能,一个或多个用户界面管理核心应用程序或守护进程。图 2-1 显示了一种典型的设备架构,这可能已经与您对设备的想法相似。

正如定义应用程序是由用户界面(UI)管理一样,常见的服务,如网络服务器或系统日志,也需要进行管理。由于主要应用程序和大多数常见服务都是作为守护进程实现的,因此管理问题归结为管理守护进程的问题。本章的重点是如何最好地管理守护进程。

在本章中,我们将介绍以下内容:

• 管理守护进程的常见方法

• 控制和状态协议

图片

图 2-1:应用程序的典型用户界面

管理守护进程的常见方法

通过管理守护进程,我们指的是配置守护进程,从它那里收集统计数据,并能够查看其当前状态。大多数 Linux 守护进程使用 ASCII 文本文件进行此类通信,但在构建您的守护进程时,您还有其他选项可以考虑。接下来的几节将描述各种守护进程管理方法及其优缺点。

基于文件的管理

守护进程通常通过几个配置文件进行管理或监控,这些文件控制它们的运行时参数、状态和日志。例如,DHCP 守护进程 dhcpd 由 /etc/dhcpd.conf 配置文件控制;其状态显示在 /var/state/dhcp/dhcpd.leases 中;其启动脚本在 /etc/rc.d/init.d/dhcpd 中;其日志在 /var/log/messages 中。然而,在守护进程的配置存储方式或其状态如何提供方面,几乎没有一致性。状态和其他状态变化通常使用 syslog() 进行记录,但许多应用程序使用自定义例程进行记录,并将它们的日志文件存储在非标准格式中。图 2-2 显示了使用文件进行配置和管理的守护进程的典型流程。

基于文件的方法对设备有以下局限性:

• 从大多数运行中的应用程序中获取状态或统计数据没有好方法。虽然应用程序可以将状态和统计数据写入文件,但在实时(或接近实时)这样做可能会对 CPU 和文件系统造成过重的负载。

您需要能够编辑配置文件,这在设备上并不总是容易的。

• 要应用新设置,通常必须重新启动正在运行的守护进程,这可能会中断服务并给用户带来问题。

图片

图 2-2:管理守护进程最常见的方式

尽管基于文件的接口存在局限性,但许多应用程序使用它们进行 Unix 系统管理,并且它们可能会继续保持流行。如果您正在构建新的应用程序,并且您已经选择使用基于文件的程序管理,请考虑使用 libini 或 XML 解析库。此外,像 Webmin 这样的应用程序可以通过提供允许您显示和编辑许多配置文件的 Web 前端来提供帮助。

请记住,非常简单的应用程序(包括一些守护进程)可能永远不需要运行时访问状态、统计信息和配置。可能没有理由从传统的 Unix .conf 和 .log 文件方法切换。决定哪种方法最适合您的特定应用程序取决于您自己。

基于守护进程的 Web 界面

另一种常见的守护进程管理方法是直接从守护进程提供 Web 界面。例如,cupsd 是 CUPS 打印队列守护进程的守护进程,它在 TCP 端口 631 上提供自己的 Web 界面。这种方法适用于简单的守护进程,但它有两个问题:

• 您需要在您的守护进程中维护代码以支持许多不同浏览器中的 HTTP 实现。

• 当需要添加额外的接口时,这可能很困难。

以此为例,我们项目中需要运行时访问状态和配置,因此我们添加了一个内置的 Web 界面。多么糟糕的编码噩梦!似乎需要花费很长时间才能正确处理所有 HTTP 细节,并使生成的代码与所有主要网络浏览器兼容。如果您决定直接在您的守护进程中构建 Web 界面,请自己行个方便,使用 Hughes Technologies 的 libhttpd 这样的 HTTP 库。因为其他程序员,HTTP 专家,会不断更新有关各种浏览器怪癖的信息,您的维护工作将会容易得多。

这个相同的项目也突出了第二个问题。一旦 Web 界面开始工作,客户要求我们添加一个 SNMP 接口。最快的方法是将 SNMP 直接添加到守护进程中,就像我们添加 Web 界面一样。这次添加使我们走上了所谓的“一体化”方法的道路,这在下一节中将有描述。

一体化方法

如果您知道您的用户需要在守护进程运行时与之交互,并且您的运行守护进程需要超过一种类型的接口,您可能会倾向于直接将必要的接口添加到守护进程中。图 2-3 显示了一个不仅试图执行其实际任务,还试图支持多个、同时用户界面的守护进程。

图片

图 2-3:直接将所有用户界面添加到守护进程中

我们在早期设计中使用了一种类似的捆绑方法,但我们发现它带来了很多问题。因为一次只能有少数开发者对设备进行操作,所以开发变成了串行,开发者必须同时编写主守护进程和所有用户界面。UI 和守护进程之间的紧密耦合使得隔离代码的一个部分变得更加困难。我们担心即使是简单的用户界面更改也可能有副作用,因此我们要求每次更改都要等待主要发布版本的全面回归测试。整个开发和发布周期变得非常缓慢。

全面方法的一个问题是性能。如果所有用户界面都直接从守护进程运行,守护进程可能会将所有的 CPU 周期都花在一些可能并不重要的接口请求上,而忽略了它需要做的真正工作。

控制和状态协议

克服上述方法局限性的一个方法是通过控制和状态协议将守护进程与用户界面分离。图 2-4 展示了守护进程提供一个单一的应用程序编程接口(API),供所有客户端和用户界面使用。

图 2-4:在守护进程和用户界面之间使用一个协议

控制和状态协议相对于其他替代方案具有几个优点:

减少多个用户界面的复杂性

控制和状态协议简化了守护进程的用户界面逻辑,因为守护进程只需要实现该协议。用户界面可以使用适合该界面的语言和工具独立实现。例如,可以使用 Apache 和 PHP 构建 Web 界面,而 SNMP 界面则可以使用 Net-SNMP 和 C 语言构建。

在守护进程运行时访问它

用户希望在应用程序运行时能够访问它,以便获取状态、统计信息和运行时调试信息。控制和状态协议可以使你的应用程序在启动时和 SIGHUP 信号时仅限于配置文件访问的应用程序中具有竞争优势。你可能已经注意到,Microsoft 用户不是通过编辑文件来配置守护进程的;他们通过守护进程本身来配置守护进程。因此,设计你的守护进程以这种方式进行配置可以使 Microsoft 用户更容易迁移到你的软件。

远程网络访问

远程访问可以加快开发和测试速度,因为你可以从几乎任何联网的工作站上对设备进行操作。对于管理大量设备的中枢运营管理中心,远程访问对你的客户非常有用。此外,你的技术支持人员也需要良好的远程访问来帮助诊断现场的问题。

并行开发

将管理用户界面与守护进程解耦意味着你可以让两个团队并行工作在项目上。人员配备更容易,因为你可以雇佣只需要开发项目特定部分所需技能的人。将用户界面开发者和守护进程开发者分开还有一个优点:它迫使你在开发早期就考虑并定义你的守护进程接口,那时进行更改最容易。

简单的测试框架

由于用户界面与守护进程分离,围绕每段代码构建测试框架是一个干净且简单的过程。一旦构建了框架,即使所有部件尚未就位,你也可以进行测试。

增强安全性

使用控制和状态协议为你的守护进程可以以两种方式提高你的设备的安全性。首先,用户界面不需要与守护进程运行相同的特殊权限,这意味着在任何给定时间运行的具有特殊权限的代码更少。其次,使用严格定义的协议让你能够专注于保护协议及其 API。这比保护,比如说,一个一体化的方法要容易得多。

控制和状态协议可以使用串行连接、Unix 或 TCP 套接字、文件读写,或者它可能隐藏在库调用中。这些技术将在本章后面进行描述。作为预览,考虑以下示例,它们设置了一个名为cntl_pt的单个位。

AT 命令 ATS301=1

XML <cntrl_pt>1</cntrl_pt>

库调用 ret = set_cntl_pt(1);

/proc echo 1 > /proc/sys/mydev/cntl_pt

SQL UPDATE my_table SET cntl_pt = 1

控制和状态协议的要求

如果你设计自己的控制和状态协议,你应该根据以下标准来评估你的设计:客户端和守护进程的数据模型,对现有协议和软件的重用,对客户端和守护进程施加的约束,以及你发现其系统的容易程度:

数据模型

控制和状态协议应允许客户端和守护进程具有相同的数据模型。也就是说,如果守护进程使用变量、结构、列表和数组,那么控制状态协议另一侧的客户端也应支持变量、结构、列表和数组。在协议的两端具有相同的数据模型可以更容易地重用代码,并帮助程序员保持对试图解决的问题的一致看法。

使用现有标准和代码

控制和状态协议应尽可能使用现有的软件和标准。你可能能够找到已经了解这些协议和软件的开发者,并且现有的协议和软件很可能为需要学习它们的开发者提供了良好的文档。使用现有代码几乎总是好主意,因为新代码越少,新错误就越少。

对守护进程和客户端的限制很少

理想情况下,协议应该对您设计守护进程的方式施加很少的限制,并且不会增加守护进程的大小。您应该能够通过在主要源文件中做很少的更改就将控制和状态协议添加到您的程序中。当将控制和状态协议添加到旧程序中时,您应该能够将大部分新代码放入单独的源文件中,而不是将更改交织到主要代码库中。您的协议客户端绑定应该对所有主要编程语言都可用:至少 Java 和 PHP 用于网络界面,以及 C 和 C++ 用于编译代码。

发现机制

我们希望在不依赖文档的情况下发现设备可用的信息。例如,ls 命令可以发现 Unix 文件系统中可用的文件;get-next 操作符可以发现 SNMP MIB 中的内容;数据库中的系统表描述了数据库本身。以类似的方式,我们希望有一个机制,用户可以通过它发现可以在设备上配置的内容以及设备可提供的信息。

常见控制和状态协议

在我们的工作中,我们开发了几个控制和状态协议,我们将在本节中描述它们。在阅读时,请尝试根据前一个章节中提出的四个标准来评估它们。

AT 命令

在我们的第一个控制和状态协议中,我们使用了 Hayes AT 命令集的变体。我们正在工作的设备是无线电调制解调器,因此我们的大多数客户已经熟悉该命令集,这使得它成为一个合理的选择。我们的守护进程监听传入的 TCP 连接,并为已接受的连接提供一个 AT 命令解释器。使用 TCP 给我们提供了远程访问以进行诊断和配置。

我们将系统配置存储为 AT 命令列表。在系统启动时,守护进程会读取配置文件并通过 AT 命令解释器运行它。这种方法意味着我们不需要为处理不同格式的配置文件添加代码。虽然 XML 和 INI 是存储配置的标准,但我们不想在不必要的情况下增加代码和复杂性。

AT 命令协议有两个限制。首先,我们无法方便地使用标准的 AT S-register 语法访问数据数组。其次,客户端程序员必须编写大量代码来生成 AT 命令并解析回复。

可扩展标记语言

我们在一个项目中使用了可扩展标记语言(XML)作为管理 Juniper 路由器的控制和状态格式。XML 协议的例子包括 XML-RPC、SOAP 和 JUNOScript。JUNOScript 通过 telnet 或 SSH 连接管理 Juniper 路由器。它允许你使用 XML 编码命令,然后路由器会以 XML 响应回复。例如,请求运行配置看起来像这样:

在 JUNOScript 可用之前,Juniper 路由器通过命令行界面(CLI)进行配置。当使用程序而不是手动管理路由器时(尤其是当你必须编写该程序时),XML 相对于 CLI 的优势变得明显。解析 XML 响应的代码比解析 CLI 响应的代码更容易编写。XML 的其他优点包括其在表示丰富数据方面的灵活性以及处理 XML 格式的软件库的可用性。

在客户端和服务器之间交换 XML 数据需要添加传输协议。你可以使用 telnet 或 SSH,就像 JUNOScript 所做的那样,或者你可以使用 SOAP 和 XML-RPC 标准所指定的 HTTP。

通常,你会在服务器端使用库来解析 XML,然后将解析后的 XML 元素映射到服务器的内部数据结构中。将 XML 映射到内部数据结构的代码可能很复杂且容易出错,因为 XML 结构很少直接映射到守护进程中使用的数据模型。

如果你使用 XML 构建你的控制和状态协议,你应该考虑使用简单的 XML API(SAX)。SAX 使用事件驱动模型来处理 XML,它更适合控制和状态协议中发现的对话类型。

库调用

对于另一个项目,我们使用了流行的技术,通过将协议封装在 API 的子例程中来隐藏协议,从而避免开发者直接接触协议。在这种情况下,守护进程和客户端程序员在他们的代码中包含了共享对象库,并且都没有直接处理协议。对于开发者来说,API 中的库例程变成了协议。这种方法如此普遍,以至于许多程序员无法想象有其他替代方案。他们开始一个项目时,假设他们将构建一个守护进程和一个库,而守护进程的客户端必须包含该库才能与守护进程通信。

我们的建议是让其他程序员编写库,而你自己避免这样做。大多数说这个的理由归结为最小化你必须编写和维护的代码行数。在库 API 中隐藏协议并不能消除编写协议和库的需要。你仍然需要考虑使用哪种 IPC 以及在该 IPC 上使用哪种实际协议。可能最大的负担是编写和维护所有感兴趣编程语言的客户端库——那些可以编写 C 库并完成所有工作的日子已经过去了。你希望库在 Java 和 PHP 中可用,用于 Web 界面,以及在 Perl 和 shell 命令中用于脚本和测试。很少有公司拥有编写和记录这些库所需的所有专家,更少的公司有时间和资源在每次修订更改后正确维护和更新库。

我们尝试为我们的守护进程编写库,我们发现我们一直在重复造轮子。每个库,无论与之前的相似程度如何,都是为当前的守护进程编写的,因此我们必须为每个守护进程编写不同的库。虽然我们试图重用代码,但这个过程远远达不到理想状态。如果只有一个库(以及一个底层协议)我们可以用于所有守护进程,那该有多好。

对于嵌入式系统来说,每个守护进程有一个库尤其成问题,因为你可能不得不放弃大量的系统 RAM 来加载所有需要的库。

结构化查询语言

为了让所有守护进程只有一个控制和状态协议,我们尝试在 TCP 上使用结构化查询语言(SQL)文本命令,并将我们守护进程中的数据建模为数据库中的表。守护进程接受 TCP 连接并向客户端提供了一个 SQL 命令行解释器。此协议使我们能够在控制和状态协议中表示数组(和列表),从而解决了 AT 命令协议的一个限制。图 2-5 说明了基本思想。

图 2-5:SQL 作为控制和状态协议

例如,TCP 连接上的典型命令可能是以下字符串:

SELECT Column_A FROM Table_A

那时,命令解释器负责解析 SQL 字符串并访问适当的“表”。表用引号括起来,因为守护进程中的数据可以表示为任意数据结构,而将任意数据结构呈现为表格是 SQL 解释器的责任。

与 AT 控制和状态协议类似,我们的配置文件存储为包含 SQL 命令的文本文件;这消除了解析 XML 或 INI 文件的需要。

这种方法的最好部分是,守护进程中的数据已经以结构体的数组形式存储,因此对于程序员来说,转向“表”范式是极其容易的。这种协议的限制在于用户界面或客户端必须开发的代码量。因为该协议是基于文本的,我们必须编写客户端代码来格式化和发送请求以及解析响应。这些代码必须为特定客户端编写的每种编程语言重写。在我们的案例中,需要大量的努力,因为我们开发了 Windows C++客户端、Java 客户端、Mac OS 9 C 客户端和 Linux C 客户端。

PostgreSQL

我们最终的控制和状态协议克服了之前协议的限制。它在某种程度上类似于我们的 SQL 协议,因为它将守护程序的数据建模为数据库中的表,并在守护程序和客户端之间使用 TCP 或 Unix 套接字。区别在于,它使用的是 PostgreSQL 协议,而不是专有的基于文本的协议。使用 PostgreSQL 意味着我们可以使用 C、Java、PHP、bash 等客户端的 PostgreSQL 绑定。

将守护程序的内部数据结构作为数据库表提供出来的所有艰苦工作都由一个名为运行时访问(RTA)的库来处理。我们将我们的守护程序与 RTA 库链接,在守护程序告诉 RTA 关于我们的表之后,它将它们作为 PostgreSQL 数据库表提供出来。

虽然 RTA 使用 PostgreSQL 作为控制和状态协议,但它不是一个数据库。相反,它使用 PostgreSQL 协议的一个子集和客户端绑定,作为在运行中的守护程序中读取和写入内存变量的手段。

PostgreSQL 和 RTA 作为控制和状态协议具有几个优势。如前所述,已经有一些 PostgreSQL 绑定在各种语言中可用,包括 C、C++、Java、Perl、Tcl 和 Python。这些绑定的可用性意味着您在 UI 或客户端上的开发代码将更少,因此您在编写 UI 客户端时受到的限制更少。PostgreSQL 具有宽松的许可证,并且文档非常完善,RTA 的系统表可以用作浏览提供给 UI 程序的数据的方式。

通过使用 Unix 套接字并仔细设置所有权和读写权限,可以提高安全性。使用 SELinux 和 Linux 安全模块,可以对连接到守护程序上的 RTA 的程序进行更精确的控制。考虑使用 Stunnel 或 SSH 与端口转发来实现安全的远程访问。

虽然 XML 很流行,但 RTA 和 PostgreSQL 相对于它有一些优势。PostgreSQL 提供了一种数据交换格式和传输协议。使用 RTA,您不需要将代码序列化以将 XML 的树结构映射到守护程序的内部数据结构,因此 RTA 方法比基于 XML 的方法需要更少的开发。使用 RTA,客户端可以直接查看守护程序的内部内存变量,并且这种功能不需要额外的开发。

RTA 将在下一章中更详细地介绍,但一个简单的示例可以展示 RTA 从 UI 客户端的角度是如何工作的。假设守护程序有一个以下结构的数组:

图片 1

在向 RTA 告知数组(使用 rta_add_table())之后,您可以使用任何启用了 PostgreSQL 的客户端来读取和写入数组中的数据。如果您使用 psql,一个 PostgreSQL 外壳程序,您可以使用以下 SELECT 命令读取区域表:

图片 2

此示例展示了从运行中的守护程序读取变量的简单方法。

RTA 有一些缺点。其中之一是 RTA 库是用 C 语言编写的,这意味着如果你的服务器进程是用另一种语言编写的——比如说 Java,你就无法使用 RTA。另一个缺点是,如果你的设备由多个守护进程组成,你需要开发一个管理过程来管理这些守护进程,同时只向客户端暴露一个单一的管理点。公平地说,这个最后一个缺点适用于所有控制和状态协议。

摘要

在本章中,我们讨论了管理守护进程的各种方法。简单的守护进程可以使用文件进行所有管理,但我们认为对于具有多个用户界面的 Linux 设备,控制和状态协议是最好的。我们描述了使用控制和状态协议的原因,并提出了如果你决定自己构建时需要遵循的一些指南。

在下一章中,我们将向您展示如何将 RTA 集成到守护进程中,以便客户端可以访问守护进程的状态、配置和统计信息。

本书余下的所有示例都使用 PostgreSQL 和 RTA 库作为守护进程和用户界面之间的管理协议。尽管如此,如果你选择不使用 RTA,也无需担心。本书更多地关注设备设计,而不是使用特定的库。

图片

使用运行时访问

图片

本章为您提供了使用 RTA 库开发 Linux 设备的实用介绍。请将此视为您的“Hello, world!”示例。在本章中,我们将讨论以下内容:

• RTA 设备架构

• RTA 守护进程架构

• 告诉 RTA 关于您的列和表的信息

• 构建您的第一个 RTA 程序

• 一点 SQL

• RTA 内置表的简介

• RTA 表编辑器

RTA 设备架构

您可能还记得上一章中,在 UI 程序和守护进程之间放置一个定义良好的协议有多个原因。协议在 UI 和守护进程中都提供了简化的复杂性,在守护进程运行时提供对守护进程的访问,允许您独立地工作并测试 UI 和守护进程,并有助于提高安全性。协议的重要要求是协议的数据模型与您对数据的看法相匹配,您不必自己定义或编写协议,并且协议将对大多数 UI 编程语言可用。

我们在这本书中使用的数据模型是数据库。因为我们把我们的结构数组看作是数据表,所以 UI 程序(或 客户端)将守护进程中的数据视为数据库中的数据。虽然 UI 程序认为它们正在处理 PostgreSQL 数据库,但实际上它们是在与守护进程通信。这种安排导致了一种类似于图 3-1 所示的设备架构,其中帧缓冲区 UI 使用 libpq.so 中的 PostgreSQL C 语言绑定;Web UI 使用 pgsql.so 中的 PostgreSQL PHP 绑定;测试和调试 UI 使用命令行程序 psql。

图片

图 3-1:使用 RTA 的示例设备

在通过 Unix 或 TCP 套接字连接到守护进程后,如图 3-1 所示的 UI 可以显示守护进程中可用的配置、状态和统计信息。librtadb.so 库将守护进程的数据呈现为来自 PostgreSQL 数据库。此图显示了本书中使用的 PostgreSQL 客户端绑定,但还有许多其他语言绑定可用,包括 Java、Python、Tcl、Perl 和 Microsoft C++。

图 3-1 提供了该设备的全局视图。现在让我们看看 RTA 在守护进程内部是如何工作的。

RTA 守护进程架构

将 RTA 添加到您的守护进程相对简单,因为它通常只需要使用库中的两个例程。第一个例程,rta_add_table(),使您的守护进程中的一个表对客户端可见。第二个例程,dbcommand(),处理来自客户端的协议和 SQL 命令。图 3-2 阐述了一个提供 RTA 访问两个 UI 可见表的守护进程。

图片

图 3-2:使用 RTA 的守护进程

dbcommand()例程不直接与客户端通信。您的程序必须创建一个监听 TCP 或 Unix 套接字,并且必须能够接受和管理来自 UI 或其他客户端的连接。一旦建立连接,应通过调用 dbcommand()将连接的所有数据传递给 RTA。dbcommand()例程解析来自客户端请求中的 SQL 命令;如果请求有效,它将执行 SQL 命令并返回一个包含要发送回客户端的任何数据的缓冲区。

如果 RTA 只能读取和写入您表中的值,那么它的实用性将非常有限。它的真正力量在于它能够在 UI 读取或写入您表中的值时调用例程。这些读取和写入回调类似于传统的数据库触发器。回调与列定义相关联,并且分别针对读取和写入进行指定。(我们将在下一节中更详细地描述回调。)

向 RTA 告知您的列和表

表是数据结构数组和链表的集合。您的数据结构中的每个成员被视为表中的一列,每个数据结构的实例被视为一行。从现在开始,当您看到术语时,请考虑我的数据结构中的成员。为了使表对客户端可见,您需要以 RTA 可以理解的方式描述表。这意味着描述整个表,然后描述表中的每一列。

TBLDEF 结构描述了整个表;它包含一个指向由 COLDEF 结构组成的数组的指针,以定义数据表中的每一列。最初您可能会发现创建 COLDEFs 和 TBLDEFs 的过程既繁琐又乏味,但一旦您有一些经验,您会发现它既简单又机械。

RTA 的一个大优点是您不需要将数据在协议中序列化和反序列化。RTA 使用您程序中已经存在的数据。当然,您必须描述您的数据,以便 RTA 能够智能地访问它。表由列组成,我们需要描述表中的每一列。这就是 RTA 的 COLDEF 数据结构的目的。

您的数据结构中也可以有不是由 COLDEF 定义的成员。这些隐藏的列可能包括您不希望对 UI 可见的信息,或者如果显示给用户将没有意义的二进制数据。

COLDEF 包含关于您结构成员的九条信息。

表字段指定了从 UI 程序中看到的表名。

名称

名称字段指定了列的名称。在选择或更新此列时使用此名称。

类型

列的类型用于语法检查和 SQL SELECT 输出格式化。当前定义的类型包括:

length

RTA 使用本地编译器数据类型以匹配您在结构中使用的数据类型。对于整数、长整型、浮点型和它们相关的指针类型,长度成员被忽略,但对于字符串和字符串指针,它具有意义,两者都应报告字符串中的字节数(包括终止的空字符)。

offset

偏移量是从数据结构开始到正在描述的结构成员的字节数。例如,使用具有 int、20 个字符的字符串和长整型的数据结构的表会将长整型的偏移量设置为 24(假设 int 是 4 字节)。

计算结构成员的偏移量是费时且容易出错的。gcc 编译器套件提供了 offsetof()宏来自动计算结构成员的偏移量。

flags

列有两个由标志成员指定的二进制属性。第一个属性指定列是否可以被覆盖或是否为只读。统计信息通常被标记为只读。如果标记为只读的列是 UPDATE 语句的主题,则生成错误。此属性的#define 是 RTA_READONLY。

第二个属性指定是否将写入此列的值保存到与表关联的配置文件中。应该从一次程序调用持续到下一次调用的值应使用#define RTA_DISKSAVE 属性标记。

标志字段是 RTA_DISKSAVE 和 RTA_READONLY 的按位或。

readcb()

如果定义了,每次列的值被使用时都会调用读取回调例程 readcb()。这对于计算量大但使用频率不高的值来说很方便。每次引用列时都会调用读取回调——如果你的 SQL 语句使用了列名两次,则读取回调会被调用两次。

读取回调传递五个参数:表名、列名、SQL 请求的文本、受影响的行指针以及零索引的行号。以下是一个读取回调的函数原型。

int readcb(char *tbl, char *col, char *sql, void *pr, int rowid);

读取回调在成功时返回零,如果回调中发生错误则返回错误代码。(有关错误代码的列表和回调的更多详细信息,请参阅附录 A。)请检查客户端的返回值以增强可靠性和安全性。

writecb()

写回调可以是驱动您应用程序的真正引擎。如果定义了,则在 UPDATE 中的所有列都已更改后调用写回调 writecb()。考虑以下 SQL 命令:

UPDATE ifcfg SET addr="192.168.1.1", mask = "255.255.255.0";

如果在 addr 上有写回调,它将在 addr 和 mask 都已更新后调用。RTA 在所有字段都已更新后执行写回调,以帮助保持一致性。

写回调传递六个参数:表名、列名、UPDATE 语句的文本、受影响的行的指针、零索引的行号以及一个指向在更改之前所做的行的副本的指针。(此参数在您想同时知道行的旧值和新值时非常有用。)旧行的副本在动态分配的内存中,在写回调返回后释放。以下是一个写回调函数的原型。

int writecb(char *tbl, char *col, char *sql, void *pr, int rowid, void *poldrow);

写回调在成功时返回零,在失败时返回非零值。在失败的情况下,行将恢复到其初始值,并向客户端返回一个 SQL 错误,触发动作异常。写回调允许您强制执行一致性,并可以为您的系统提供安全检查。

帮助

您的列帮助文本应包括对列如何使用的描述,列的任何限制或约束,以及由任何读取或写回调引起的副作用。(为您的列提供有意义的帮助文本,以便更容易维护和调试您的代码。)

表格

您通过调用 RTA 例程 rta_add_table()来告诉 RTA 关于您的每个表。rta_add_table()的单个参数是描述表的 TBLDEF 结构的指针。

TBLDEF 结构使用 10 条信息来描述您的表。其中最重要的是表名、结构数组的起始地址、每个结构的宽度(即每行的宽度)、行数以及指向描述表中列的 COLDEF 结构数组的指针。TBLDEF 结构中的大多数字段应该是自解释的。

保存文件

从一个启动到下一个启动保存配置数据的需求如此普遍,以至于 RTA 的作者包括了在数据更新时自动将表数据保存到文件中的功能。每个表有一个文件,文件名在 TBLDEF 结构中作为保存文件字符串指定。您可以通过向列定义中添加 RTA_DISKSAVE 标志来标记要保存的列。

保存文件包含一个 UPDATE 语句列表,每个表中的一行一个。当您使用 rta_add_table()调用初始化表时,从磁盘读取保存文件并将其应用于表。在列上使用 RTA_DISKSAVE 和表的保存文件组合消除了解析 XML 或 INI 文件以获取初始或保存配置值的需求。当然,如果您更喜欢使用 XML 或 INI,只需将保存文件指针设置为 null。

迭代器

迭代器 是你代码中的一个子程序,它遍历你表中行的链表或其他排列。迭代器让你可以将链表、B 树或几乎任何其他组织数据的方式视为数据在表中。

迭代函数使用三个参数调用:当前行的指针,来自 TBLDEF 的 void 指针 it_info,以及零索引的行号。函数返回下一行的指针。当 RTA 请求第一行时,当前行指针是 NULL,所需的行索引为零。当 RTA 请求列表中最后一行之后的行时,函数应返回 NULL。如果定义了迭代器,TBLDEF 中的地址和 nrows 成员将被忽略。以下是它的函数原型。

void iterator(void *cur_row, void *it_info, int rowid);

在使用迭代函数时有一个注意事项:如果你还没有为链表中的所有链接分配内存,加载保存文件可能会失败。(记住,保存文件是一系列 UPDATE 语句,并期望行已经存在。)幸运的是,有一个简单的方法可以解决这个问题。始终保留一个未使用的行,当该行被 UPDATE 语句写入时,让写回调分配另一行,这样你就可以领先一步处理 UPDATEs。第七章中介绍的 logmuxd 程序就使用了这种技术。

构建您的第一个 RTA 程序

现在我们将看看如何使用 RTA 来在运行程序中暴露一个表。为此,需要遵循以下五个基本步骤:

  1. 定义问题。

  2. 检查代码。

  3. 安装 RTA。

  4. 构建和链接。

  5. 测试。

定义问题

我们希望将 UI 程序暴露给包含一个用户可编辑的字符串和两个整数的结构数组。其中一个整数,zalarm,由用户设置。另一个,zcount,在 zalarm 从一变为零或从零变为一时递增。每当发生转换时,我们都会向控制台打印一条消息。字符串,zname,被视为配置值,并在更新时保存在磁盘文件中。由于 zcount 是一个统计值,我们将其标记为只读。这个示例问题是在第五章中实际 Laddie 设备应用程序之前的一个先导。下面展示的代码也包含在本书的配套 CD 上的 myapp.c 文件中。

检查代码

这段代码示例应该能给你一个关于在 RTA 启用程序中可以期待什么的想法。

包含、定义和内存分配

首先,我们将查看代码:

zalarm 的转换是在写回调中检测到的。这里, 是它的前向引用。

我们需要为客户端的 SQL 命令文本和返回给客户端的响应分配缓冲区。在中,我们分别使用了 500 和 5,000 字节。这些值被选择以容纳我们预期使用的最大 SQL 语句和预期返回的最大结果。

的结构定义是应用程序数据的核心。这个数据结构的一个实例对各种 UI 和客户端来说就像数据库中的一行。

我们在看到我们的表格中有五行。

列定义

这里是定义我们表格中列的 COLDEF 数组。COLDEF 中的信息来源于我们想要使其可见的数据结构以及我们的问题陈述。

注意中 COLDEF 上 zedgedetect 写回调的定义。我们在该回调中执行 zalarm 的转换检测。

表格定义

在 TBLDEF 中,我们给出了表格的名称、起始地址、每行的大小、行数、指向此表格的 COLDEF 表的指针以及表格中的列数。保存文件/tmp/zsave.sql 将用于保存 RTA_DISKSAVE 列,在这种情况下,只有名称列。

main() 例程

这是一段相当标准的代码。我们分配我们的套接字结构和其它局部变量,然后初始化表格值并使用 rta_add_table()告诉 RTA 关于我们的表格。

设置监听套接字

记住,每个 UI 程序都将我们的应用程序视为一个 PostgreSQL 数据库,我们必须接受来自这些客户端的 Unix 或 TCP 连接。因此,作为初始化的最后一步,我们设置了套接字以监听传入的客户端连接。我们的程序正在监听 TCP 端口 8888,因此我们需要告诉我们的 PostgreSQL 客户端使用此端口。

注意

以下代码存在一些严重的缺陷(例如,阻塞 I/O、忽略错误条件以及乐观地假设套接字 I/O)。然而,我们的目标是通过尽可能缩短代码来使代码易于理解。

上面的 read()调用使用了阻塞 I/O。在实际应用中,我们希望接受连接并使用 select()或 poll()为我们进行多路复用。然而,在这个例子中,我们试图保持行数低。

dbcommand() 调用

以下调用是 RTA 真正工作的地方。我们将从客户端读取的 SQL 命令传递给 RTA 库,该库解析它、验证它、执行它,并将结果填充到 buf 中。我们根据 dbcommand()调用的结果切换,以确定是否应将结果发送回客户端或关闭连接。在正常情况下,PostgreSQL 客户端将进行有序关闭,dbcommand()调用将返回 RTA_CLOSE。

图片

写回调

这是 UI/客户端程序设置 zalarm 列之后调用的子例程。对此更新的一个典型 SQL 命令可能是 UPDATE ztable SET zalarm = 0。

注意

当刚开始学习使用回调时,您可能想在回调中添加一个打印语句来显示表、列、输入 SQL 和行号。

图片

图片

通过比较 zalarm 的旧值和新值来检测过渡。将行的旧值和新值都作为参数传递给例程。在这个例子中,我们总是返回成功。

return(0); /* 总是成功 */ }

注意

提醒一下,如果写回调返回非零值,受影响的行将恢复到其旧值,客户端程序将接收到它发送的 SQL 命令的错误结果。

安装 RTA

您可以在本书的配套 CD 和 RTA 项目网站上找到 RTA 包的副本(librta.org)。请检查网站以获取最新版本。RTA 中的 SQL 解析器使用 yacc 和 lex 编写,因此如果您从源代码构建 RTA,您的开发系统将需要安装这两个工具。

RTA 的默认安装将.a 和.so 库放入/usr/local/lib 目录。如果您不想使用/usr/local/lib,您可以在安装之前编辑 makefile。

下载 RTA 包后,提取文件并构建库。下面的示例代码展示了如何操作。

图片

构建和链接

现在在 rta-X.Y.Z 下创建一个测试目录,并将 myapp.c 复制到其中。接下来,使用以下命令构建应用程序:

gcc myapp.c -o myapp -L/usr/local/lib -lrtadb

要编译和运行应用程序,我们需要告诉系统在运行时在哪里找到 RTA 库。您可以通过编辑/etc/ld.so.conf 并运行 ldconfig 或导出 LD_LIBRARY_PATH 环境变量来实现。如果编译成功,您应该可以使用以下命令运行应用程序:

export LD_LIBRARY_PATH=/usr/local/lib

./myapp

就这些了!您的示例应用程序应该已经启动并运行,准备好响应 PostgreSQL 请求。

测试

在本节中,我们假设您已从 Linux 发行版或本书配套 CD 中安装了 PostgreSQL,并且 psql 命令已存在于您的路径上。如果一切顺利,您现在应该有一个运行中的应用程序,它假装是一个 PostgreSQL 数据库服务器。然而,我们的示例应用程序提供其内部表供各种 PostgreSQL 客户端使用。我们将首先使用的客户端是命令行工具,psql。

假设一切正常,打开另一个终端窗口并启动 psql,指定数据库服务器的主机和端口如下。(请记住,我们告诉应用程序在端口 8888 上监听。)

psql -h localhost -p 8888

PostgreSQL 应该响应如下:

如果您的 SQL 命令没有显示上述表,您需要调试 RTA 安装。最常见的问题是客户端和 RTA 之间 PostgreSQL 协议版本不匹配。psql 客户端可能会给出警告,但它对使用较新客户端与较旧服务器相当宽容。如果您使用的是最新的 psql 客户端,这种情况可能发生。

检查 RTA 网站,看看您的 RTA 版本是否与您的 PostgreSQL 版本兼容。如果存在不匹配,请更新 RTA 或 PostgreSQL。本书的配套 CD 包含已知与 RTA 和 PostgreSQL 库兼容的版本。您还可以使用 netstat -natp 来验证应用程序是否真的在端口 8888 上监听。

在进入 SQL 教程之前,让我们尝试几个命令,看看应用程序如何响应。

UPDATE ztable SET zalarm = 1;

UPDATE 5

这应该在您启动 myapp 时所在的控制台上打印出一个转换消息。(请注意,psql 会响应已更改的行数,因为我们没有指定更改哪一行,所以所有五行都被更新了。)

现在再次执行相同的命令。

UPDATE ztable SET zalarm = 1;

UPDATE 5

这次不应该在控制台上打印任何消息,因为没有发生转换。

将 zalarm 设置回零应导致一个转换,并且转换的计数现在应该是 2。

当您第一次启动./myapp 时,保存的表配置文件/tmp/zsave.sql 不存在。通过在一个标记为 RTA_DISKSAVE 的列上执行更新来创建它。

UPDATE ztable SET zname = "row name";

UPDATE 5

您可以通过在/tmp/zsave.sql 上执行 cat 来验证上述内容。您应该看到以下内容:

UPDATE ztable SET zname = "row name" LIMIT 1 OFFSET 0

UPDATE ztable SET zname = "row name" LIMIT 1 OFFSET 1

UPDATE ztable SET zname = "row name" LIMIT 1 OFFSET 2

UPDATE ztable SET zname = "row name" LIMIT 1 OFFSET 3

UPDATE ztable SET zname = "row name" LIMIT 1 OFFSET 4

为了结束本节关于 RTA 的内容,让我们生成一些错误并查看相应的错误消息。

UPDATE ztable SET zcount = 0;

错误:无法更新只读列 'zcount'

UPDATE ztable SET zname = "abcdefghijklmnopqrstuvwxyz";

错误:'zname' 字符串太长

一点 SQL

结构化查询语言(Structured Query Language,简称 SQL)是操作数据库数据的一种标准方式。RTA 仅使用两个 SQL 命令:SELECT,用于从表中获取数据,和 UPDATE,用于向表中写入数据。RTA 的 SELECT 和 UPDATE 语法是标准 SQL 语法的子集,有一个小的扩展。

SELECT

SELECT 语句从表中读取值。RTA SELECT 语句的语法是:

SELECT column_list FROM table [where_clause] [limit_clause]

column_list 是一个以逗号分隔的列名列表或单个星号(*)以检索所有列。变量 table 是您要检查的表名。where_clause 指定要返回的行,limit_clause 告诉返回多少行。以下是一些简单的例子。

SELECT * FROM ztable

select * from ztable

SELECT zcount, zname FROM ztable

您可以以任何顺序指定列,并且可以多次请求相同的列。

注意

SQL 解析器识别 SQL 保留字的大小写形式。在我们的示例中,我们使用大写字母来使保留字更加明显。

此外,SQL 不需要在行尾使用分号,但 psql 命令行工具需要。

UPDATE

UPDATE 语句将值写入表中。RTA UPDATE 语句的语法是:

UPDATE table SET update_list [where_clause] [limit_clause]

update_list 是一个以逗号分隔的值赋值列表,格式如下:

column_name = value[, column_name = value...]

在上面的例子中,value 是一个字面值。让我们看看一些更多的例子。

UPDATE ztable SET zalarm = 44

UPDATE ztable SET zalarm = 0, zname = Terminator

UPDATE ztable SET zalarm = 1, zname = "Mr. Terminator"

包含空格的字符串必须用单引号或双引号括起来。一种引号可以嵌套在另一种引号中。

UPDATE ztable SET zname = "Baker's Pride"

UPDATE ztable SET zname = 'Just say "no"'

WHERE

WHERE 子句指定要选择或更新的行,基于行中的数据。WHERE 可能是使用 SQL 的最大原因之一。WHERE 子句的形式是:

col_name rel_op value [AND col_name rel_op value ...]

支持的比较运算符包括等于、不等于、大于、小于、大于等于和小于等于。仅逻辑 AND 可用于连接列比较,并且值必须引用一个字面值。例如:

SELECT * FROM ztable WHERE zalarm != 0

UPDATE ztable SET zalarm = 1 WHERE zname = "Front Door"

LIMIT

LIMIT 子句可以限制选择的行数,并可以指定忽略第一个 OFFSET 行。LIMIT 子句的形式是:

[LIMIT limit [OFFSET offset]]

正常的 SQL 不支持“只给我第三行”的想法,但如果你试图管理嵌入式应用程序,这个功能很重要。LIMIT 和 OFFSET 子句允许你指定应该返回多少行,以及开始读取或写入之前应该忽略多少候选行。如果有 WHERE 子句,偏移量和限制只适用于匹配 WHERE 条件的行。例如:

UPDATE ztable SET zname = "Front Door" LIMIT 2

UPDATE ztable SET zname = "Back Door" LIMIT 3 OFFSET 2

UPDATE ztable SET zalarm = 1 LIMIT 2 OFFSET 1

SELECT zname FROM ztable LIMIT 4

UPDATE ztable SET zname = "Garage" LIMIT 1 OFFSET 2

SELECT * FROM ztable WHERE zalarm = 1 LIMIT 1

注意

一种逐行遍历表的好方法是设置 LIMIT 为 1,并将 OFFSET 从 0 递增到行数减 1。

你可能还记得我们说过,我们将 RTA_DISKSAVE 列存储在表定义中给出的保存文件中,并且我们希望将配置存储为 SQL 命令,以便我们可以通过 SQL 解析器运行它。你可以通过查看/tmp/zsave.sql 来看到一个关于 LIMIT 子句和保存文件的优秀示例。

cat /tmp/zsave.sql

UPDATE ztable SET zname = "Front Door" LIMIT 1 OFFSET 0

UPDATE ztable SET zname = "Front Door" LIMIT 1 OFFSET 1

UPDATE ztable SET zname = "Garage" LIMIT 1 OFFSET 2

UPDATE ztable SET zname = "Back Door" LIMIT 1 OFFSET 3

UPDATE ztable SET zname = "Back Door" LIMIT 1 OFFSET 4

真正的 SQL 纯粹主义者阅读这篇文档可能会用鞋底猛击桌子,并喊道,“ORDER_BY 和 INSERT 和 DELETE 在哪里……和……和……?”它们都不在那里。记住,RTA 不是数据库——它是一个接口。我们只需要 SELECT 和 UPDATE。

RTA 内置表的介绍

RTA 库有几个内置表。附录 A 有完整细节,所以我们将在这里介绍它们。第一个表只有一行。

rta_dbg

rta_dbg 表允许你控制如何以及记录什么。你可以通过将 trace 设置为 1 来开启所有 SQL 的跟踪,并且你可以通过将 target 设置为 0、1、2 或 3 来分别将日志消息定向到既不是、syslog、stderr 或两者。你还可以为 syslog()指定优先级、设施和 ident 值。从 psql 中我们得到:

图片

rta_stat

rta_stat 表包含与 RTA 调用相关的统计信息。它包含不同类型错误的计数、已打开到 RTA 中的连接数以及 SELECT 和 UPDATE 的数量。

图片

rta_tables

rta _tables 元表是一个表定义结构的集合。为了将一个表添加到 RTA 中,你必须填写一个包含你的表描述的数据结构。表定义结构的集合本身在 RTA 中也是一个表。这是 RTA 的元表之一。

图片

两个 RTA 元表在地址字段中为零,因为它们实际上是一个指针数组,所以它们使用迭代函数。元表中的所有列都被标记为只读,因为所有值都是从 rta_add_table()调用中设置的。

rta_columns

rta_columns 元表是一组列定义。所有表的列定义都收集到 rta_columns 表中。(该表实际上持有指向 COLDEF 结构的指针。)我们可以使用元表和 WHERE 子句来查看表中的列。

图片

您认为如果我们结合 RTA 元表和 PHP 会得到什么?请继续阅读。

RTA 表编辑器

RTA 包有一个基于 web/PHP 的实用工具,称为表编辑器,它读取 RTA 元表,并允许您查看和编辑系统中的任何表。图 3-3 显示了允许您选择要查看或编辑的表的屏幕。图 3-3、3-4 和 3-5 中的截图是在我们处理 myapp.c 应用程序时从我们的开发系统中拍摄的。您可以通过启动本书的配套 CD 并使用另一台 PC 上的浏览器来查看192.168.1.11/rta/rta_tables.php?port=8885来看到类似的屏幕。

图片

图 3-3:RTA 表编辑器

从顶部屏幕中选择一个表会打开一个包含所选表内容的网页。图 3-4 显示了示例程序的 ztable 显示。

图片

图 3-4:一个示例表显示

您可以从表显示中选择要编辑的行。图 3-5 显示了选择行号 3 后的视图。

图片

图 3-5:一个示例行编辑屏幕

RTA 表编辑器有一个 HTML 文件和四个 PHP 文件,可以放在任何支持 PHP 的 web 服务器上。实际上,web 服务器甚至不需要在运行应用程序的同一台机器上运行。

HTML 文件包含正在使用的 RTA 端口号列表。您为每个运行的 RTA 启用应用程序都有一个不同的端口号。在我们的开发机器上,我们有一个包含端口号和 RTA 应用程序名称的 HTML 表格,看起来像这样:

图片

总结

本章介绍了如何使用 RTA 构建应用程序的细节,以便几种不同类型的 UI 程序可以管理它。您已经看到,您需要通过使用 TBLDEFs 和 COLDEFs 来描述它们,告诉 RTA 您想要使其可见的数据结构。

虽然这一切一开始可能看起来有些令人不知所措,但请坚持下去。经过一点练习,您会发现编写 TBLDEFs 和 COLDEFs 非常直接,主要是机械的。将 RTA 添加到您的守护进程中的额外努力,通过运行时访问配置、状态和统计信息得到了充分的补偿。

图片

图片

构建和确保守护进程的安全

图片

几乎所有 Linux 设备的核心都是一个或多个守护进程,这些后台程序提供了网络或系统服务。您可以通过查看/etc/rc.d/init.d 目录或使用 ps ax 命令来显示您系统上正在运行的守护进程,以了解您 Linux 系统上可用的守护进程。

“守护进程”一词指的是在没有控制终端的情况下在后台运行的程序。守护进程也在它们自己的进程组中运行,以避免意外接收其他进程的信号。守护进程通常将标准输入、输出和错误重定向到/dev/null 或日志文件。许多守护进程使用进程 ID 文件(或 pidfile)来强制对资源的互斥访问;这防止了守护进程的多个副本同时运行。

本章向您展示如何构建和确保您在设备中使用的守护进程的安全。它分为三个主要部分。

  • 如何构建守护进程

  • 如何确保守护进程的安全

  • 守护进程原型

如何构建守护进程

本节向您展示如何构建守护进程,并简要解释为什么每个步骤都是必要的。您的应用程序可能不需要列出所有步骤,您可能需要以不同的顺序执行它们以满足您的需求,但这将给您一个大致的概念。

  1. 加载配置。

  2. 进入后台。

  3. 成为进程和会话领导者。

  4. 设置工作目录。

  5. 重定向 stdin、stdout 和 stderr。

  6. 设置日志记录。

  7. 设置组 ID 和用户 ID。

  8. 检查 pidfile。

  9. 设置 umask。

  10. 设置信号处理器。

注意

本章后面提供的示例守护进程包含了每个步骤的代码。以下的一些部分使用了来自示例守护进程的代码。

加载守护进程的配置

当守护进程启动时,它需要加载一组控制其操作的参数。这通常意味着解析命令行上的选项并从配置文件中读取设置。

用于启动守护进程的命令行通常包含配置文件的位置、运行时使用的用户和组 ID,以及程序是否应该成为守护进程或保持为前台进程。一些守护进程允许您指定守护进程的工作目录,以及是否在启动前执行 chroot()。

配置信息有优先级。具体来说,编译内嵌的值总是首先加载,因为它们是在程序启动时加载的。接下来,加载配置文件中的配置值,覆盖编译内嵌的值。最后,加载命令行上的值,覆盖配置文件中的值。

集成的值应该更多地关注安全性而不是功能,因为攻击者可能会在入侵过程中删除或修改配置文件。作为安全预防措施,一些守护进程如果不能打开和加载配置文件则拒绝运行。

由于配置文件通常在命令行中指定,您的程序可能需要通过它进行两次遍历:一次获取配置文件,第二次在配置文件加载后再次解析命令行。在调试过程中,通常使用命令行参数,因此它们的值通常覆盖配置文件中的值。

注意

请确保您的程序通过验证配置的一致性来进行合理性检查,并在发现任何问题时报告错误或退出。

进入后台

一旦加载了配置,下一步是让进程(可选地)进入后台,在那里它可以与控制终端断开连接。这是通过调用 fork() 函数来创建子进程实现的。父进程应该在 fork 之后退出。

为了进入后台,子进程关闭了控制终端的文件描述符。结果是,我们有一个后台进程,它没有连接到控制终端。

您的代码可能看起来像这个示例,其中父进程进行 fork 并退出,留下子进程继续设置守护进程:

图片

有两种情况你不应该将您的进程发送到后台:当调试时(因为您希望您的终端仍然是程序的控制器,以便您可以看到任何诊断消息,并在需要时杀死程序),以及当您希望程序死亡时自动重启它。在后一种情况下,守护进程应该保持在前台,以便在守护进程退出时(无论是优雅地退出还是由于某些错误)父进程将接收控制权。

以下示例 shell 脚本展示了如何自动重启守护进程。

图片

两种常见的替代方案是向 /etc/inittab 添加您的守护进程并让 init 进程重启它,或者编写一个自定义监控程序来重启设备上的各种守护进程。/etc/inittab 方法可能节省内存和进程表中的几个条目,并且您不需要编写任何新软件。重启 mydaemon 的脚本可以用 /etc/inittab 中的单行替换。如果默认运行级别是 3,该行可能如下所示:

ap:3:respawn:/usr/local/bin/mydaemon

单词 respawn 告诉 init 程序在 mydaemon 死亡时重启它。

成为进程和会话领导者

Linux 内核将每个进程分配到一个进程组和一个会话,这两个都用于信号的分配。在一个会话中,所有进程通常都是从 xterm 窗口或从虚拟控制台登录启动的。在一个进程组中,所有进程都是在命令行管道中启动的。每个会话只有一个进程组,它从控制终端接收输入;这个进程组被称为前台进程组

例如,打开一个 xterm 或登录到虚拟控制台,并输入以下命令:

cat | sort | uniq | tr a d &

cat | sort | uniq | tr a d

从另一个 xterm 或控制台,ps xj 的输出可能如下所示:

示例

所有从第一个命令行开始的过程都将出现在一个进程组中,其中以进程组中的 cat 进程(如上例中的 PID 5327)作为进程组长。

现在看看 ps xj 输出中的进程组 ID(PGID)列。每行命令中的所有程序都将 PGID 设置为启动命令行的 cat 命令的 PID。第一行的所有命令的 PGID 为 5327,第二行的所有命令的 PGID 为 5331。第二个命令示例,您没有将其放入后台的那个命令,是会话的前台进程组,因此它的 PID(5331)是会话组 ID(TPGID)。回想一下,会话组长(本例中的 5331)是从终端获取标准输入的进程(因此有终端进程组 ID,TPGID)的称呼。

为进程组和会话保留单独的 ID 的原因是,如果您杀死一个进程组,您希望内核向该组中的所有进程发送 TERM 信号。如果您想杀死会话中的进程,也是如此。

我们不希望守护进程接收到不是为其设计的信号,因此我们希望守护进程在其自己的会话和进程组中。以下代码展示了如何使用 setsid()使您的守护进程成为会话和进程组长:

示例

作为练习,您可以尝试输入 ps jax 命令,并检查您系统上运行的守护进程的会话、进程组和前台进程组。您应该能够判断哪些进程属于不同的会话和进程组。

注意

作为安全预防措施,在调用 setsid()之后进行另一个 fork(),并立即退出父进程,留下子进程继续作为守护进程。这样,守护进程的会话组长状态就被移除了,这样它就永远无法再获得控制终端。

设置工作目录

守护进程传统上使用根目录,/,作为工作目录。这允许守护进程即使在大多数其他文件系统未挂载的情况下也能继续工作。使用根目录也使得将你的守护进程放入 chroot 监狱以增加安全性变得更容易。(Chroot 监狱在“如果可能的话,使用 Chroot”一节中描述,见第 59 页。)

一些守护进程允许你在配置文件或命令行中指定工作目录。无论你使用根目录、/tmp 目录还是配置文件中的值,你应该在指定守护进程的工作目录时慎重考虑。

使用 chdir()设置守护进程的工作目录。

重定向 stdin、stdout 和 stderr

为了将自己从控制终端中移除,守护进程通过关闭然后重新打开它们(通常到/dev/null 设备)来重定向 stdin、stdout 和 stderr 文件描述符。守护进程继承了父进程的所有打开的文件描述符。因此,许多守护进程会遍历所有可能的文件描述符并将每个都关闭。你可以在编译时从 OPEN_MAX 获取最大文件描述符数量,或者在运行时从 mx = getdtablesize();获取。

一旦关闭了所有打开的文件,重新打开 stdin、stdout 和 stderr 是一个好的做法;一些库会写入 stderr,因此 stderr 应该使用有效的文件描述符初始化。一些守护进程不是使用/dev/null,而是打开一个日志文件作为 stderr。

以下代码通过关闭它们然后重新打开到/dev/null 设备来重定向这三个文件描述符。该代码还关闭了从 getdtablesize()返回的最大文件描述符。

图片

设置日志记录

你的守护进程应该报告错误和其他感兴趣的事件。当你正在处理守护进程时,你将想要看到调试信息,你可能还希望在守护进程运行时记录其活动。日志记录可以满足所有这些需求。

日志消息的三个常见目的地是 syslog、stderr 和日志文件。将调试信息指向 stderr、错误指向 syslog 以及将活动日志放入文件中是相当常见的。

注意

如果你将日志文件保存到本地磁盘,你可能需要运行 crond 并让 logrotate 删除旧日志文件。务必将任何自定义日志文件添加到 logrotate 的配置中。

如果你正在构建网络设备,你可能希望将错误和用法日志都发送到 syslog,然后配置 syslog 将日志消息发送到网络上的日志主机,而不是保存在本地磁盘文件中。这有助于最小化你的设备磁盘需求,并且由于所有日志消息都保存在一个主机上,使得分析消息变得更加容易。

许多守护进程允许你通过命令行上的参数设置调试日志的详细程度。例如,输入-d 5 可能会打开详细程度为 5 的调试输出。

在 Linux 中,调试级别的标准含义并不统一。一些守护进程有一个简单的开启/关闭选项,而另一些则使用介于 0 到 9 之间的级别。一些守护进程允许你在程序运行时通过发送 SIGUSR1 和 SIGUSR2 信号来开启和关闭调试,而一些守护进程则从配置文件中读取调试级别。

在一个有多个开发者的大型项目中,您可能希望为代码的不同部分设置不同的调试级别,以便每个开发者可以独立控制其代码中的日志记录。如果在程序运行时可以设置调试级别,那就更好了。(第六章更详细地介绍了日志记录,并展示了我们如何使用 RTA 在运行时修改调试级别。)

设置组和用户 ID

许多守护进程在启动时从 inittab 或 rc 脚本启动;其他守护进程由 cron 启动。因此,大多数守护进程以 root 用户 ID 启动,如果程序被破坏,这会带来安全风险。

为了限制程序被破坏时可能造成的损害,许多守护进程尽可能快地放弃 root 权限。例如,一个 web 服务器可能在绑定到 TCP 端口 80 后立即放弃 root 权限。

同样,如果可能的话,您的守护进程应该放弃 root 权限。但如果不是 root 用户,应该使用哪些用户 ID 和组 ID?许多应用程序会创建自己的用户和组。(快速查看/etc/passwd 和/etc/group 可以确认这一点。)如果您决定为您的守护进程创建用户,尽量保持用户 shell 为/bin/nologin。您的守护进程可以从配置文件或命令行中获取用户 ID 和组 ID。

您可以使用 setuid()系统调用来放弃 root 权限并成为另一个用户。其他可以更改用户 ID 的例程包括 seteuid()和 setreuid(),它们设置真实用户 ID 和有效用户 ID。您应该根据需要决定使用哪一个。

我们示例守护进程中的以下代码从 Config[]全局配置表中获取用户 ID(UID)名称,并调用 getpwnam()将名称转换为数值 UID。对 setuid()的调用为守护进程设置了 UID。我们设置组 ID(GID)的例程与此类似,使用 setgid()代替 setuid()。(LOG 宏将在后面解释。)

检查 pidfile

许多守护进程需要独占访问计算机资源,例如 TCP 端口或打印机。在这些情况下,不应同时运行两个守护进程实例,因为这两个实例都无法独占访问资源。最常见的方式是通过使用 pidfile 来保留访问权限。

pidfile是一个包含运行守护进程的进程 ID(PID)的文本文件,通常位于/var/run/xxx.pid,其中xxx是守护进程的名称。例如,您可能在/var/run 中看到以下内容:

当守护进程启动时,它会检查 pidfile 是否存在。如果文件不存在,守护进程会创建它并将自己的 PID 写入其中。如果文件存在,守护进程会检查文件中指定的进程是否仍在运行。然后它从文件中读取 PID 并调用 kill(0)向进程发送信号(这只是一个测试,kill(0)实际上不会终止正在运行的进程)。如果 kill()调用成功,这意味着文件中指定的进程正在运行并能接受信号,因此新的守护进程可以简单地退出(可选地记录事件)。无法原子性地检查和创建 pidfile,因此您必须使用 Linux 文件锁来确保另一个守护进程实例不会也创建 pidfile。本节后面给出的代码示例说明了如何使用文件锁。

作为安全预防措施,您可能希望配置您的设备,以便一个进程不允许杀死()另一个进程。为此,通过在/proc 目录中查找其 PID 来检查守护进程的存在。如果 pidfile 中指定的 PID 没有运行,新的守护进程将用其 PID 覆盖 pidfile 并继续。(您的守护进程还应验证具有匹配 PID 的进程是您的守护进程的实例,而不是某些其他程序,该程序偶然具有与 pidfile 中指定的 PID 匹配的 PID。)

过时的 pidfile 很麻烦,因此当您的守护进程退出时,它应该删除其 pidfile。编写一个删除 pidfile 的子例程,并使用 atexit()注册该子例程在程序终止时执行。您还可能希望修改 rc.sysinit 或其他初始化脚本,以从/var/run 中删除所有旧的 pidfile。

注意

务必在启动守护进程之前,在引导序列的早期删除过时的 pidfile,以免系统初始化意外删除活动 pidfile。

pidfile 的名称和位置通常在配置文件中;如果不在那里,它可以从命令行传入。能够在配置文件或命令行中指定 pidfile 使得在需要时(例如在调试期间)运行多个守护进程实例变得更容易。

下面的代码取自我们的示例守护进程,展示了一种自愿互斥 pidfile 的方法。我们获取 pidfile 的名称并尝试打开它。如果打开成功,我们从文件中读取 PID 并尝试向进程发送信号。如果 kill()调用成功,这意味着 pidfile 中指定的进程仍在运行,此实例应退出。如果 pidfile 存在,但指定的进程没有运行,pidfile 已过时,应将其删除。如果此守护进程实例是有效的,它将创建一个 pidfile,锁定它,并将 PID 写入其中。

图片

图片

设置 umask

umask 命令设置了当前 shell 中创建的文件的默认读写权限。通常,将守护进程的 umask 设置为 0 是一个好习惯,这会强制你明确设置你创建的任何文件的权限。

由于不需要保存 umask 的旧值,我们将返回值强制转换为 void:

(void) umask((mode_t) 000);

设置信号处理程序

信号处理程序 是一个与你的应用程序其余部分一起编译的函数。你不会直接调用该函数,而是使用信号或 sigaction 来告诉操作系统在信号到达时调用该函数。

设置守护进程的最后一步是配置信号处理程序。你的应用程序的要求决定了要捕获哪些信号以及如何处理它们。运行 man 7 signal 命令将给你一个关于你可能想要捕获的信号的想法。一些最常见的信号和操作如下:

SIGHUP 重新读取配置文件并适当地重新初始化。关闭并重新打开任何日志文件,以便 logrotate 有机会工作。

SIGTERMSIGQUIT 守护进程优雅地关闭并退出。

SIGUSR1 切换调试模式开或关。

SIGCHLD 处理任何子进程的死亡。

在实现信号处理程序之前,你应该查阅 sigaction() 的手册页和你的最爱 Linux 编程书籍,但这个简单的示例可能有助于你开始:

处理信号的例程会接收到一个包含信号编号的整数。该例程的类型应该是 void。

注意

信号处理程序中的代码不在你的程序的主执行路径中执行,并且由于信号可以在信号处理程序本身运行时发生,因此信号处理程序必须是可重入的。

编写可重入的代码可能有点棘手,你可能想要考虑只是设置一个易失性标志,让主循环定期检查该标志,将实际的工作留到主循环中完成。该标志必须是易失性的,这样编译器就不会在主循环中优化掉对它的测试。如果你决定在信号处理程序中做更多的事情,比如设置标志,请确保你的信号处理程序中的所有 glibc 和系统调用都是可重入安全的。

如何安全地配置守护进程

本节将为你提供一些一般性指南,帮助你编写更安全的程序。然而,由于你的守护进程的安全性非常重要,不能仅使用本文档作为你安全信息的唯一来源,我们强烈建议你阅读本章末尾参考文献中列出的书籍。这里的信息实际上只是你需要考虑的点的概述。此外,本节不会告诉你如何安全地配置 Linux 内核或你的设备。

我们将把守护进程安全的话题分为三个部分:

  • 设计一个安全的守护进程

  • 编写安全的守护进程

  • 在安全漏洞发生时限制损害

设计安全的守护进程

保护你的守护进程从你开始考虑其规范、架构和设计时开始。当你为你的守护进程奠定基础时,你拥有使你的应用程序更安全的最大能力。

我们所说的“安全”,是指守护进程应以可预测的方式对错误和恶意攻击做出响应。这意味着我们必须首先检测错误(和攻击),然后适当地处理它们。一种思考方式是,为每个可能的错误条件和攻击都制定一个计划。

始终制定应急计划

许多建筑物在电梯和楼梯旁边张贴逃生计划。逃生计划是一张在紧急情况下应采取的最佳路线图。当你设计你的守护进程时,考虑你将如何从每个可能出现的错误条件中恢复或逃生。在守护进程开发之后尽早为良好的逃生计划打下基础,会使你添加代码时更加轻松。

退出可能意味着核心转储和程序终止,或者可能意味着中止单个请求、关闭网络连接或执行其他错误恢复操作。如果在启动期间或配置更改期间检测到错误,或者由于任何原因你认为安全受到侵犯,则程序终止可能是合适的。例如,如果你的守护进程是一个处理客户端请求的网络服务器,那么当守护进程收到格式错误的请求时,关闭网络连接可能是合适的。

在实践中,拥有错误逃生计划通常意味着所有子例程都返回一个错误代码。无论子例程调用嵌套有多深,你都应能够将错误指示符传递到子例程返回链中。事件驱动或状态机驱动的程序可以使用标志或单独的状态来指示错误。

你的逃生应始终从描述错误位置和生成它的输入的日志消息开始。你可以有两个日志消息,一个用于检测错误,另一个在更高层次上报告你决定如何处理错误。

限制权限

当从头开始设计守护进程时,你可以详细指定其操作。你的规范和生成的代码应仅允许最简单的请求和配置数据子集。设定严格的标准将使你的守护进程更安全,并可能有助于消除微妙的错误。

例如,让我们考虑你可以对配置或其他内部文件名施加的限制。在 bash 提示符下输入以下内容(注意单引号和双引号的位置):

图片

难道这不令人惊讶吗?上面的命令是有效的。字符串 cd ..; cd ..; cd ..; cd etc; echo 'nameserver 1.2.3.4'>resolv.conf 是一个完全有效的 Linux 文件名。虽然 bash 必须将其视为一个有效的文件名,但你不需要这样做。考虑在规范中声明文件名仅限于字符 [_a-zA-Z/.],但 .. 和 // 序列是无效的。此外,Linux 路径和文件名的最大长度在 limits.h 中定义为 PATH_MAX,通常设置为 4096 个字符。你可能希望将文件名长度限制为你的守护进程所需的最小长度。

文件名只是其中一个例子。考虑其他你可以用来加强你的守护进程规范的方法。

编写一个安全的守护进程

安全性只有在其链条中最薄弱的环节才是有效的。设计一个安全的守护进程是不够的。你还必须 编写 一个安全的守护进程。

验证输入

许多最近的 Linux 漏洞源于缓冲区溢出,这允许入侵者将可执行代码放置在堆栈上。对此类攻击最有效的防御是验证来自用户或任何非安全来源的所有输入。验证字符串长度,并确保字符串不包含任何非法字符。验证整数是否合理,相对于它们的用途,以及计数整数始终为正。

在分配其他资源之前,尽可能进行应用程序特定的检查。例如,确保 HTTP 请求格式正确,以及 SQL 语句有效。早期检查有助于防止在为请求分配了缓冲区、套接字或其他资源后尝试撤销请求的问题。

不要让任何格式不正确的输入进入你的守护进程。记住:如果它只有 99%正确,那么它仍然是错误的。

检查所有返回代码

为了增强安全性,你可以做的最好的事情之一就是检查所有返回代码,尤其是来自系统调用的返回代码。通常,这会感觉像是一项负担,但如果你已经设计了一个带有错误逃生计划的设计,你会发现测试每个返回代码并不需要太多的思考或努力。

避免缓冲区溢出攻击

一些库函数被认为是不安全的,因为它们没有限制它们将覆盖多少内存。例如,strcpy() 函数被认为是不安全的,而 strncpy() 函数被认为是安全的。

尽管如此,我们并不确信 strn 例程有多么安全,因为它们不能保证结果字符串是空终止的。最好的做法是在复制之前检查字符串的长度。让我们看看一些例子。

虽然它使用了更多的代码,但上面的方法保护了程序,并报告了可能是对程序攻击的一部分的源字符串。

还有一些其他函数族被认为是不安全的。具体来说,这些包括 strcat()、sprintf()、gets() 和 scanf()。

其他安全软件

即使你遵循最佳编码实践,你也可能想要以下软件提供的额外保护:

IBM 的 ProPolice:GNU 编译器集合(GCC)补丁,有助于防止缓冲区溢出

StackGuard:GCC 补丁,有助于防止缓冲区溢出

Libsafe:strcpy()和其他不安全函数的替代库

grsecurity:一种内核补丁,可以使堆栈不可执行(以及其他功能)

Systrace:一种内核补丁,可以限制你的守护进程可以做出的系统调用

我们强烈建议使用 grsecurity,并配置你的系统,以便代码永远不会从堆栈中执行。这个功能使用内存管理单元的硬件,不会影响你的程序性能。

在发生违规时限制损害

几乎每个主要的 Linux 应用程序都曾一度被发现存在漏洞。由于你的守护进程在某个时刻也可能发生同样的事情,你想要限制被破坏的守护进程可能对设备造成的风险量。

防止库和路径攻击

如果攻击者获得了对你的设备的访问权限,他们可能会首先设置 LD_LIBRARY_PATH 或 PATH 指向受损害的库和命令,然后运行你的守护进程。如果你的程序是 Set User ID (SUID) root,你的攻击者刚刚获得了对你的设备完全的 root 控制权。不要绝望。如果你发现守护进程被破坏,你可以做几件事情来限制灾难的发生。

首先,不要使用 root 的 SUID 运行你的应用程序。在设备上这样做比在多用户系统中更容易,因为在多用户系统中,像 passwd 和 X 服务器这样的程序必须以 SUID root 运行。最好是放弃 root 权限或以非特权用户身份运行。(你将在下一节中了解更多关于这个问题的细节。)

第二道防线是使用 gcc 调用中的-static选项来对你的守护进程进行静态构建。静态链接的可执行文件可能不会像你想象的那样增加你的可执行文件的大小,如果你使用 chroot 监狱,它实际上可能节省磁盘空间。静态链接的可执行文件通常也加载得更快。

防止库或路径攻击的另一种方法是忽略告诉你的程序在哪里查找共享对象库和系统命令的环境变量。如果你非常注重安全,可以使用 glibc 的 clearenv()函数来取消定义所有环境变量。你需要为使用 system()运行的任何命令提供完整的路径,但这可能是一个好主意。

避免使用 root 权限

攻击者想要 root 权限,以便他们可以控制你的设备。如果你以 root 身份运行你的守护进程,你使你的守护进程成为他们攻击的目标。尽可能避免使用 root 权限。创建一个新的用户(登录 shell 设置为/bin/nologin),并使用 setuid()和 setgid()切换到该用户。这种技术被大多数 Web 和数据库服务器使用。

另一种方法是修改你的 rc 初始化脚本,使用 sudo 启动你的守护进程以切换到适当的用户。例如,你的 rc 脚本可能以以下命令启动你的 webui 守护进程作为用户wuser

sudo -l wuser webui

放弃根权限并设置能力

如果你必须拥有根权限来打开低于 1024 的网络端口或写入根拥有的文件,尽量放弃尽可能多的根权限。2.2 及以后的内核通过能力实现了这一点。能力是执行非常特定操作的独立权限。你的 SUID root 程序可以放弃单个能力并保留其他能力。

内核为每个程序跟踪三组能力:

有效 当前允许的内容

允许 进程可以使用的最大能力

继承 在 execve()过程中要传输的内容

设置能力的系统调用是 capset()。你也许还能使用 cap_set_proc(),它更具有可移植性。

能力在 Linux 中得到了很多活跃的开发。以下是一个示例,展示了你的守护进程如果可能的话应该放弃的超过 25 种能力。所有能力的列表可以从 man capabilities 的输出中获取。

内核本身尊重一组能力,在你的引导过程的最后一步,你可能想要限制内核能做什么。例如,如果你的内核使用模块,在系统引导结束时,你可能想要完全移除内核加载或卸载模块的能力。

注意

能力的完整描述超出了我们在这里可以展示的范围。一个好的起点是在你的 Linux 系统上查看 man capabilities。

如果可能,使用 chroot

在发生违规时限制损害的最古老和最可靠的技巧之一是在 chroot 监狱中运行守护进程。想法是将你的守护进程需要的所有文件放在一个目录子树中,然后告诉你的守护进程子树的顶部是文件系统的“根”。系统调用是 chroot(),这是在守护进程被突破时使真实文件系统其余部分不可见的好方法。

根用户拥有的进程突破 chroot 监狱相对容易,所以在 chroot()调用后一定要放弃根权限。构建 chroot 监狱的典型调用序列如下:

chdir("/var/app_jail");

chroot("/var/app_jail")

setuid(500);

在 chroot()调用之后,应用程序将只能看到 chroot()调用中指定的目录下的文件和目录。你需要关闭 chroot 监狱外目录的文件描述符,因为它们可能提供突破监狱的手段。

构建成功的 chroot 监狱的技巧在于限制监狱中的文件和设备数量。当然,你需要你守护进程的所有工作文件,但如果启动配置目录包含例如定位 chroot 监狱的位置,则不要包括它。如果你的程序是动态链接的,你需要包括/lib 目录以及你的程序使用的任何共享对象库。考虑进行应用程序的静态构建以避免添加/lib 目录的必要性。

标准库日志例程 syslog()假设可以访问/dev/log 的 Unix 套接字。在你的监狱中创建一个/dev 目录,并告诉系统日志守护进程 syslogd 使用-a 命令行选项监听额外的套接字。以下是如何启动 syslogd 以便它监听额外套接字的示例:

syslogd -a /var/app_jail/dev/log

chroot 监狱的一个常见替代方案是虚拟机。例如,VMware、VServer 和 User-mode Linux 等程序提供的隔离性比 chroot 监狱更好,但代价是更高的内存或 CPU 需求。

原型守护进程

本书包含一个可启动的 CD,可以将 PC 转换为基于 Linux 的设备。本书示例设备的编程工作由作者分担,我们每个人都编写了一些程序。为了使设备代码更容易阅读(以及更容易编写),我们决定从为每个程序构建一个共同的核心开始。

空守护进程的代码作为我们设备的代码的一部分提供,你可以从 CD 或从本书的网站上获取。我们试图构建空守护进程以反映上述部分学到的所有经验教训,欢迎你复制我们的空守护进程代码并按需使用。

摘要

在本章中,我们展示了典型守护进程的初始化步骤——例如,重定向 stdin、stdout 和 stderr,并进入后台。我们还介绍了一些你可能用来自我增强守护进程安全性的概念和技术。

进一步阅读

我们发现以下书籍在确定如何确保守护进程的安全性方面非常有用。

  • Secure Programming for Linux and Unix HOWTO by David A. Wheeler (www.dwheeler.com/secure-programs, 2003)

  • Real World Linux Security by Bob Toxen (Prentice Hall, 2000)

  • Network Security Hacks by Andrew Lockart (O’Reilly, 2004)

  • SSH, The Secure Shell: The Definitive Guide by Daniel J. Barrett and Richard E. Silverman (O’Reilly, 2001)

  • Linux Security by Shadab Siddiqui (Premier Press, 2002)

图片

LADDIE 报警系统:一个示例设备

图片

前几章介绍了如何构建和确保守护进程,以及如何在守护进程运行时与其通信。我们将通过构建Laddie,一个基于 Linux 的报警系统,将这些主题结合起来。¹

Laddie 使用标准 PC 并行端口上的五个状态输入作为报警系统的传感器输入。Laddie 设备的核心是ladd(发音为lad-dee)守护进程,它轮询状态线并使用 syslog()报告输入转换。由于大多数读者对报警系统都有一定的了解,并且报警系统应用程序易于编写、理解和修改,因此报警系统是一个很好的示例应用程序。

本章包括以下五个部分:

  • 报警系统简介

  • Laddie 的功能规范

  • Laddie 硬件设计

  • Laddie 软件设计

  • 构建 和 测试 Laddie

在阅读本章时,请记住,报警系统本身并不像构建它的技术那样重要。不要让实现细节掩盖了正在教授的设计原则。

报警系统简介

本节介绍了用于描述通用报警系统和 Laddie 特定概念的术语和定义。

传感器

报警传感器是一个小型设备或开关,用于检测房间内的运动或区域内的活动。报警系统监控多个报警传感器,并报告它们检测到的任何意外活动。传感器保护的区域称为区域。区域被赋予名称,通常描述受保护的区域;典型的区域名称可能包括车库、二楼窗户冰箱

图 5-1 展示了小型企业中传感器和区域的一个示例配置。前门和后门上都有门传感器,还有一个检测办公室和储藏室附近运动的运动探测器。

图片

图 5-1:一个示例报警系统

传感器类型

由于报警系统只能报告其传感器检测到的内容,因此选择传感器非常重要。让我们考虑可用的传感器类型。

磁簧开关

这些通常用于监控门;它们被放置在门框上带有开关,门上带有磁铁。

PIR 运动探测器

被动红外(PIR)运动探测器检测红外(热量)源运动的微小变化。人或动物可以触发 PIR 运动探测器,但例如,棒球不能。

声学传感器

声学传感器检测特定的声音。它们通常用于检测破碎玻璃的声音,并且非常敏感,以至于单个声学传感器可以保护房间内所有的窗户。

地板垫传感器

地毯传感器具有可以检测人体重量的开关。它们非常薄,通常放置在入口处的地毯下。

振动传感器

振动传感器可以检测非常微小的物理运动。它们通常用于保护汽车。

烟雾和一氧化碳探测器

这些传感器用于检测潜在的火灾。

温度传感器

温度控制器和其他温度传感器在达到一定温度时触发,或者简单地报告区域内的当前温度。它们通常用于保护对温度敏感的设备和供应。

传感器接触类型

对于报警系统来说,大多数传感器看起来像开关。开关触点在没有报警时可以是开路(称为常开NO传感器),或者在没有报警时是闭路(称为常闭NC传感器)。当你安装传感器时,你必须告诉报警系统传感器的接触类型——也就是说,触点是否是常开或常闭。大多数传感器是常闭的。常闭传感器具有在传感器线被切断时触发报警的优良特性。

传感器和区域设置的另一个有用功能是,只要级联的传感器都是同一接触类型,就可以在区域内级联传感器。图 5-2 展示了如何级联常开传感器,图 5-3 展示了如何级联常闭传感器。

图 5-2:如何级联常开传感器

图 5-3:如何级联常闭传感器

从逻辑上讲,报警系统在每个区域内只看到一个传感器,即使实际上那里有几个级联的传感器。

保持传感器状态

大多数传感器在检测到的条件被移除时(例如,有人关闭门或从地毯上走开)会返回非报警或正常状态。你通常希望配置报警系统以保持这些传感器检测到的报警。保持状态的报警即使在检测到的条件被移除后也会保持报警状态,直到由用户手动清除。

然而,你可能不希望每个传感器都保持状态。例如,你可能希望在恒温器保护的房间温度恢复正常时自动移除报警。

当你在区域内设置报警时,考虑你使用的传感器类型和具体需求,以确定报警是保持状态还是非保持状态。

启用区域

如果区域内的传感器正在工作并且你想监控该区域,请将区域标记为启用。未使用的输入可以通过禁用区域来忽略。此外,当你想暂时关闭门或窗户时,你可能发现禁用区域很方便。

Laddie 的功能规范

Laddie 警报系统最多监控五个区域,当监控区域之一发生变更时,会触发警报。警报报告给 Laddie 的五个不同用户界面。除了能够查看 Laddie 监控的区域状态外,用户界面还允许您测试和清除警报、查看日志以及配置区域。配置参数包括以下内容:

  • 区域名称

  • 接触类型

  • 锁定或非锁定

  • 启用或禁用

Laddie 的功能规范分为两部分:一部分允许用户访问警报配置和状态,另一部分允许 Laddie 处理警报。

注意

提醒一下, Laddie 指的是整个设备,而 ladd 仅指监视并行端口上五个输入引脚的守护进程。由于它们的发音相同,很容易混淆这两个词。

ladd 的配置和状态

ladd 有一个配置和状态表,称为Zone,作为 RTA 表对所有用户界面可见。Zone 表有五行,每行由以下数据结构定义:

让我们逐一考虑这些字段。

id(配置)

区域由一个介于一到五之间的数字标识。id 字段在 ladd 启动时初始化,用户无法编辑它。您可以在用户界面程序中使用 id 字段来唯一标识特定区域。

name(配置)

此字段存储用户分配给区域的简短助记符或名称。

enabled(配置)

只有标记为启用的区域才会使系统进入警报状态。标记为禁用的区域不会生成日志消息或导致警报状态。由于 RTA 不支持布尔数据类型,此字段包含一个整数而不是布尔值。

边缘(配置)

对于下一节中描述的硬件,一个常闭传感器在输入引脚的零到一电平跳变上触发警报。Laddie 上的常开传感器在一到零电平跳变上触发警报。“第 68 页的 Laddie 硬件设计”更详细地描述了开和闭传感器。

latching(配置)

用户将此字段设置为 1,即使传感器引脚返回到正常状态,警报也会持续存在。用户必须手动清除锁定的警报。

input(状态)

此字段显示输入引脚的最新原始值。这是一个状态字段,用户无法编辑它。

alarm(状态)

每个区域要么处于警报状态,要么处于安全状态。此字段由 ladd 守护进程根据检测到的输入引脚上的跳变设置。用户可以通过将此字段写入 1 来测试区域。当用户将此字段设置为 0 时,警报被清除。

count(状态)

此字段包含导致警报的输入边沿的数量。仅在区域标记为 启用 时,此字段才会递增;用户对区域进行的测试不会递增。这是一个只读的统计字段,当 ladd 启动时设置为零。

你可能还记得,RTA 的优势在于它为所有用户界面提供了相同的 API 来配置、状态和统计信息。RTA 定义的 API 是 PostgreSQL 数据库。PostgreSQL 的优势在于 SQL 被广泛使用和理解,并且有大量的 PostgreSQL 绑定,包括 C、PHP、Java 和 Perl。图 5-4 展示了 Laddie 如何使用 RTA 允许五个不同的用户界面使用单个守护进程协议获取状态和设置配置。

图 5-4:一个守护进程与多个用户界面

让我们看看一些典型的 Laddie 配置更改和查询的 SQL。

要禁用区域 2,请输入:

UPDATE Zone SET enabled = 0 WHERE id = 2

要找出区域 4 进入警报的次数,请输入:

SELECT count FROM Zone WHERE id = 4

要清除系统中的所有警报,请输入:

UPDATE Zone SET alarm = 0

任何可以发出这些命令的程序都可以作为 Laddie 的用户界面。在本书后面的内容中,我们将通过五个当前可用的 Laddie 用户界面来关注这些命令。

ladd 的警报处理

ladd 通过使用 syslog() 发送日志消息来响应警报。日志消息的文本取决于警报是由硬件检测到的还是由用户发出的测试警报。文本还取决于警报是设置还是清除。对于 ID 为 n 且名称为 zone_name 的区域,四个日志消息如下:

  • 在区域 n, 区域名称 上设置警报

  • 区域 n, 区域名称 上的警报已清除

  • 用户在区域 n, 区域名称 上设置警报

  • 用户已清除区域 n, 区域名称 的警报

一些用户不关心 哪个 区域处于警报状态;他们只想知道是否有 任何 区域处于警报状态。为了满足这一需求,ladd 提供了另外两个日志消息:

  • 警报系统状态:警报

  • 警报系统状态:安全

这些消息在第一个区域进入警报和最后一个区域清除后发送。Laddie 还将并行端口上的所有四个控制引脚设置为 1(见表 5-1),以指示系统中的任何警报。当所有警报清除时,它将控制引脚设置为低。

我们为 Laddie 的整体架构提供的一个优点是,ladd 本身不需要向 UI 发送信号、发送电子邮件或发送 SNMP 陷阱。我们将所有这些留给一个单独的过程,极大地简化了 ladd 守护进程的设计和实现。(事件处理器将在下一章中描述。)Syslog-as-output 不仅简化了 ladd,还使调试和测试更容易,因为我们可以轻松地检查我们期望的消息的日志文件,并且我们可以使用 logger 命令为事件处理器生成测试事件。警报响应的数据流在图 5-5 中表示。

图 5-5:在 Laddie 中处理报警事件

Laddie 的硬件设计

本节介绍了使用 Laddie 作为实际报警系统所需的硬件。如果您不熟悉电子电路或对硬件的工作原理不感兴趣,可以跳过本节。

并行端口上的引脚分为三个主要组:数据线、控制线和状态线。每个组都使用一个寄存器进行控制,该寄存器在特定的 I/O 地址上可用。数据线位于并行端口的基址,状态线位于基址加一,控制线位于基址加二。表 5-1 显示了 25 针并行端口连接器上的引脚与打印机端口名称、端口寄存器和报警系统之间的关系。

表 5-1:Laddie 对 PC 并行端口的利用

报警守护进程使用数据线作为输出,状态线作为输入。图 5-6 显示了一个报警传感器的电路图。守护进程通过将输出引脚设置为 0xFF,使引脚 2 处于高电平来初始化并行端口。当传感器 S1 打开时,没有电流通过 2K 欧姆电阻 R1,引脚 15 的电压被拉高。当传感器关闭时,引脚 15 通过引脚 21 短路到地。

换句话说,当报警传感器打开时,引脚 15 被偏置为高电平,当传感器关闭时被拉低。通过读取包括引脚 15 在内的状态线,守护进程可以检测传感器是打开还是关闭。此描述适用于并行端口上的所有五个状态输入。

图 5-6:Laddie 的正常开路报警传感器

Laddie 的软件设计

我们使用第四章中介绍的空守护进程构建了 ladd 守护进程。但无论我们使用空守护进程、编写基于 select()的程序,还是编写基于线程的程序,都会有三个主要子例程:

appInit() 初始化硬件。启动定时器。将 Zone 表注册到 RTA。

poll_timeout() 读取状态线。记录相关更改。

user_update() 向报警状态发送用户更改的日志。

这些例程将在接下来的几节中详细描述。

appInit()回调子例程

appInit()子例程是空守护进程调用的第一个回调子例程。此回调子例程负责执行任何应用程序特定的初始化,设置任何定时器回调子例程,并注册任何 RTA 表。在 ladd 中,appInit()子例程初始化 ZONE 结构的 Zone 数组,调用 rta_add_table()将 Zone 表注册到 RTA,初始化并行端口,并使用 poll_timeout()作为其回调子例程启动一个周期为 100 毫秒的定时器。请注意,一旦 appInit()子例程返回,守护进程就准备好接受用户界面的连接。

虽然没有显示 Zone 数组的 COLDEFs 或 TBLDEF,但表 5-2 应该能让你了解它们包含的内容。

表 5-2: Laddie 区域表的列

图片

ladd 的所有初始化代码都在下面的 appInit() 例程中。

图片

图片

poll_timeout() 回调子例程

poll_timeout() 子例程执行警报守护进程的大部分功能。此子例程读取并行端口,处理输入引脚,并修改适当的 ZONE 数据结构的状态。此子例程每 100 毫秒被调用一次,如 add_timer() 调用所指定。注意 poll_timeout() 子例程的以下显著特征。

  • poll_timeout() 的主要责任是设置 Zone 表中每个区域的警报字段。如上所述,警报字段显示特定区域是否处于警报状态。

  • 该子例程独立于其他区域处理每个区域。也就是说,一个区域可以处于警报状态,而另一个区域处于安全状态。

  • 只有当启用字段非零时,才会修改特定区域的警报字段。此功能允许用户在没有任何传感器或用户希望忽略传感器的情况下禁用区域。

  • 当 poll_timeout() 检测到区域进入警报状态时,它将警报字段设置为 1 并发送一个 syslog 消息。在后面的章节中,我们将向您展示如何将 syslog 消息转换为电子邮件和 SNMP 陷阱。

  • 类似地,当此子例程检测到输入引脚返回到正常状态时,它清除警报变量,如果滞后字段设置为 0,则发送一个 syslog 消息。这种机制允许用户配置区域,一旦进入警报状态,就必须手动清除。

  • poll_alarm() 子例程还维护一个全局警报变量 GlobalAlarm,如果任何区域处于警报状态,则将其设置为 1,如果所有区域都安全,则将其设置为 0。该子例程跟踪 GlobalAlarm 变量状态的变化。当 GlobalAlarm 变量被设置时,并行端口的控制引脚被设置为高电平。当 GlobalAlarm 变量状态改变时,则发送一个适当的 syslog 消息。

所有新的警报轮询都在下面的 poll_timeout() 例程中完成。

图片

图片

图片

用户更新() 回调子例程

当用户手动修改 ZONE 数据结构中的警报字段时,将调用 user_update() 回调子例程。此回调子例程负责在用户手动清除(对于滞后区域)或手动设置警报状态时发送一个 syslog 消息。在第七章中,我们将向您展示如何使用 syslog 消息更新用户界面。

这个回调子例程包含在我们的警报守护进程的 RTA COLDEF 结构中。看看下面的源代码片段,你会看到 user_update 子例程包含在写入回调条目中。每当用户在区域数据结构中写入警报变量时,user_update 子例程就会被调用。

图片

下面的 user_update()子例程检查是否有用户设置了警报变量并导致其改变。如果警报变量已更改,user_update()子例程将写入 syslog 消息。

图片

在最后两个部分中,我们向您展示了警报守护进程的源代码,并解释了源代码是如何工作的。您注意到实现警报守护进程有多容易吗?下一节将向您展示如何构建和测试警报守护进程。

构建和测试 ladd

你不需要安装一套完整的警报传感器来运行这个守护进程——你只需要一个带有并行端口的普通 PC。在运行守护进程之前,你必须创建目录/opt/laddie/ladd/,因为警报守护进程会在这个目录中创建一个 PID 文件。使用以下命令以 root 用户创建此目录:

mkdir /opt/laddie/ladd

ladd 的源代码位于配套 CD 上的/Code/src/ladd 目录中。编译警报守护进程,然后以 root 用户身份运行守护进程,如下所示:

cd /Code/src/ladd

make

su

./ladd

为了确保警报守护进程正在运行并响应用户请求,调用以下 psql SQL 命令,并验证区域表是否显示。

图片

通常,你会在警报设备上添加硬件传感器,但你也可以在没有硬件传感器的情况下模拟警报。

考虑区域 1。我们的方法是使用以下命令调用警报写入回调:

UPDATE Zone SET name = "BackDoor", enabled=1, edge=0, WHERE id=1;

接下来,我们将使用以下命令在区域 1 的输入上模拟警报:

UPDATE Zone SET alarm=1 WHERE id=1;

验证 ladd 是否生成了一个日志消息,表明用户在区域 1 上设置了警报。然后手动清除警报,如下所示:

UPDATE Zone SET alarm=0 WHERE id=1;

再次验证 ladd 是否为 syslog 生成了消息。我们将在未来的章节中向您展示如何构建更易于访问的警报守护进程用户界面。

总结

本章通过向您展示如何使用 RTA 和空守护进程构建简单的警报守护进程 ladd,将前几章的内容串联起来。您看到了 ladd 的 RTA 表的设计,这是用户界面管理警报守护进程的控制点。您还看到了警报守护进程的源代码,包括空守护进程用来实现警报守护进程运行时行为的三个子例程。最后,您还看到了如何配置警报守护进程,以及如何从命令行手动设置和清除警报。

下一章将继续发展 Laddie 的设计,通过向您展示如何处理设备上的事件,包括例如 ladd 向 syslog 发送消息的事件。


¹ Laddie 是一种示例设备,用于说明本书中介绍的技术和软件。Laddie 并非一种商业可行的报警系统,并且绝不应该用来替代真正的报警系统。

日志记录

日志消息是对感兴趣事件的异步报告。本章讨论了日志记录的一般概念,然后详细探讨了 syslog,Linux 上的默认日志系统。我们还描述了如何在守护进程运行时控制日志阈值。

我们将本章组织成以下部分:

  • 你是否需要日志记录?

  • 日志系统架构

  • syslog

  • 按需日志记录

你是否需要日志记录?

在深入探讨日志记录的机制之前,让我们讨论一下为什么您可能想在您的设备上启用日志记录。

系统运行时间

日志记录的首要原因是提高您的设备系统的可用性。正确路由和显示如“CPU 风扇转速低于 1000 RPM”之类的日志消息可以帮助您的最终用户保持系统运行。对系统收集的日志消息进行回归或趋势分析可以帮助在服务中断之前识别问题。趋势分析在发现风扇、磁盘和电源供应的问题特别有用。

安全

如果您的设备连接到网络,它几乎肯定会在某个时候受到攻击。您可以使用日志消息来触发防火墙规则的变化,或者通知系统操作员或最终用户系统正在受到攻击。

调试

修复错误的第一个步骤是认识到存在错误。报告子程序输入或输出任何不一致性的日志消息对于查找错误非常有价值。您可以使用本章后面描述的按需日志记录来跟踪程序执行,并在检测到错误时记录子程序的输入和输出。

与应用程序集成

Laddie 是一个集成了日志记录和事件处理的应用的优秀例子。它简化了 ladd 守护进程的设计,使其仅通过日志消息报告所有警报转换。

您可能无法在一些具有有限连接性和有限磁盘空间的深度嵌入式 Linux 系统上使用日志记录。但对于大多数系统,日志记录将是您设备的一个真正资产。

日志系统架构

本节描述了一个“理想”日志系统的架构和特性。下一节将描述 syslog 并将其与下面提出的理想系统进行比较。

日志系统可以分为三个主要部分:一个用于收集日志消息,一个用于路由它们,一个用于交付它们(或启动其他操作)。图 6-1 展示了日志系统的架构。

让我们更详细地考虑这三个部分。

消息来源

理想的日志系统是来自设备任何位置的日志消息的汇总中心,并且它应该能够接受来自许多来源的消息,包括 Unix 套接字、UDP 和 TCP 套接字、命名管道,以及通过跟踪文件(tail -f 的输出)。

图 6-1:设备中的日志消息流程

日志系统的源代码应该有良好的文档记录和模块化,以便于添加新的消息类型源。系统的配置应该使得在系统运行时添加新源变得容易。

让我们更详细地讨论三种常见的消息源。

Unix 套接字

Syslog,Linux 上最受欢迎的日志系统,使用 Unix 套接字作为其消息收集点。面向流的通信通道,如 Unix 套接字,必须有分隔符来分隔消息。最常见的两个分隔符是一个空字符,syslog 使用它,以及一个回车符。

网络套接字

网络消息可能以 UDP 数据报或 TCP 连接的形式到达。一些应用程序接受 TCP 连接并将它们的日志消息广播到所有连接的套接字。日志系统应该能够接受 TCP 连接以及发起它们。如果日志消息将要穿越一个不安全的网络链路,系统应该使用 Stunnel 或 SSH 端口转发在传输过程中加密它们。

跟踪文件

许多应用程序直接将日志消息写入文件。如果你想捕获这些日志消息中报告的事件,你必须监视文件以获取新消息。tail -f 命令就是这样做。你通常看到这个命令字符串:

tail -f app_log_file | logger

只为了捕获应用程序的日志消息而创建两个进程似乎是一种浪费,一个好的日志系统应该将跟踪文件作为其核心功能的一部分来处理。

消息路由

路由部分确定每个消息的适当目标。路由标准因系统而异,但大多数系统包括基于事件重要性和源程序(如邮件、cron、内核和身份验证)进行路由的能力。一些系统包括识别并基于日志消息中的文本进行路由的过滤器。

在本章中,我们将过滤器定义为一系列路由规则及其各自关联的目标。路由规则及其关联的目标存储在配置文件中(或者在 Laddie 的情况下,存储在 RTA 表中)。如果系统支持(并且你使用)多个消息目标,则过滤器才有意义。

消息目标

一个日志系统通过将消息发送到目标来结束对消息的处理。以下将讨论一些常见的目标。虽然以下消息目标列表可能看起来相当长,但实际上还有许多未描述的可能目标。

文件

文件是日志消息最常用的目标。日志文件是接受的规范,可能是因为它们非常容易访问,可以进行定期的后处理分析。不幸的是,文件对许多没有硬盘的嵌入式系统来说是一个问题:RAM 是易失的,而闪存对于存档日志消息来说太昂贵了。对于无盘设备的选择是:大量过滤消息,只保存少量到闪存,将它们发送到网络上的服务器,或者根本不保存日志消息。

如果您将日志消息保存到文件中,可以使用 logrotate 定期删除最旧的文件,将最新的文件旋转到编号的存档中,并向消息来源的进程发送 SIGHUP 信号。SIGHUP 应该导致应用程序打开一个新的日志文件。

命名管道

命名管道是传递过滤后的日志消息到另一个程序的简单方法。辅助应用程序打开命名管道进行读取,然后等待日志消息到达时阻塞。当日志系统有消息要发送时,它会将消息写入命名管道,解除辅助应用程序的阻塞。确保您的辅助应用程序可以处理“损坏的管道”错误,因为如果日志系统重新启动,它们可能会发生。

命名管道和辅助应用程序对于太大或太复杂而无法包含在日志守护进程本身中的目标非常有用。命名管道是将日志系统绑定到特定于您的设备的自定义应用程序的绝佳方式。

命名管道的一个替代方案是扇出设备,这是一个内核模块和相关/dev 条目,充当一对一的多路复用器。与命名管道不同,扇出设备允许许多读者获取相同的信息(因此得名扇出)。本书的网站托管了扇出项目,包括源文件和更详细的文档。请访问www.linuxappliancedesign.com获取更多信息。

远程 UDP/syslog 主机

如果您的设备是设计用于大型数据中心网络的网络设备,请确保包括将日志消息转发到网络中另一个主机的功能。syslogd 日志守护进程可以使用 UDP 接收和/或转发日志消息到其他主机。

TCP 多路复用器

如果您想将一些报告路由到其他程序,可以定义一个监听 TCP 套接字以接受连接。当消息到达多路复用器时,它会复制并发送到套接字上每个打开的 TCP 连接。

例如,在我们的 Laddie 设备中,我们有一个命令行界面(CLI),可以显示 Laddie 警报消息。¹ 当 CLI 用户输入开启日志的命令时,CLI 打开到 logmuxd(Laddie 的日志守护进程)的 TCP 连接,并将日志消息发送到另一端的 CLI 接受的每个 TCP 连接。(logmuxd 将在下一章中描述。)

电子邮件

很高兴通过电子邮件收到重要事件的报告,因为电子邮件无处不在,即使不是及时的。此外,电子邮件通常被用作传呼机和手机的入口(这样真正重要的灾难无论你藏在哪里都能找到你)。

控制台

将输出发送到/dev/console 或串行端口是调试的必要条件。一些大型网络中心仍然更喜欢通过物理安全且非共享的通道(如 RS-232 电缆)收集日志消息。

数据库

有些消息需要立即响应,但大多数时候你更感兴趣的是系统事件模式的趋势或变化。关系数据库是日志消息的理想存储库,因为它具有广泛排序和计数日志消息的工具。由于数据库在排序和计数时可能会消耗大量 CPU 周期,你可能希望将数据库放在网络上的其他位置,而不是在你的设备上。

SNMP 陷阱

大多数大型网络都有一台或多台专用网络管理工作站,运行 SNMP 管理器。这些网络的运营商通常坚持要求所有网络设备使用 SNMP 进行状态、配置和错误报告。

system()

调用 system()运行实用程序是另一个常见的目的地。虽然简单灵活,但这种方法比其他目的地使用更多的内存和 CPU 周期,不适合处理大量日志消息。

使用 system()几乎总是被认为是一个安全风险。我们提到 system()是为了完整性,但劝阻其使用。如果你必须运行外部命令,尽量使用 popen()代替 system()。我们在 Laddie 上通过使用附录 D 中描述的 RTA-to-file 实用程序来解决这个问题。

我们没有足够的空间来描述所有可能的目的地。例如,我们没有讨论传呼机、语音邮件或即时消息。

syslog

日志系统需要一个标准的方式来报告事件,日志消息的通用语言。对我们大多数人来说,这个标准就是 syslog。syslog 有几个优点。它是所有遗留 Linux 应用程序使用的首选事件报告机制,并且它广为人知且易于理解。与内核日志守护进程 klogd 结合使用,syslog 可以捕获您可能希望对设备用户可见的内核和其他系统日志消息。

本节描述了 syslog 的工作原理,如何在应用程序中使用它,以及如何配置其消息过滤器。我们提供了足够详细的说明,你应该不会在使用 syslog 作为日志系统基础时遇到任何麻烦。

syslog 架构

系统日志消息是通过调用 glibc C 库例程 syslog()在您的程序中生成的。然后,glibc 格式化消息并尝试将其写入 /dev/log,这是一个在 syslogd 启动时打开的 Unix 套接字。syslogd 从 /dev/log 读取消息并根据 /etc/syslog.conf 中定义的过滤器进行处理。图 6-2 显示了 syslog 的整体架构和消息流。

图 6-2:使用 syslog 的消息流

使用 syslog

几乎所有的 Linux 编程语言都有一个发送 syslog 消息的例程。下面显示的 C 库原型是大多数语言的典型代表。

void syslog(int priority, const char *format, ...);

优先级是日志级别、事件的紧急程度或严重性以及设施的组合,即生成消息的程序类型。² 大多数程序员在使用 syslog 例程时只指定日志级别。有八个日志级别,从紧急到调试的重要性依次递减。以下是从 syslog.h 中摘录的八个可用级别。

syslog()例程使用 printf 样式格式字符串,可以具有可变数量的参数。格式字符串中的文本应形成一个清晰、无歧义的事件描述,并且任何传递给格式字符串的参数应提供事件的更多详细信息。

当我们构建设备时,我们交付的部分内容是文档,我们文档的一部分是所有设备日志消息及其含义的列表。使用 grep 在源代码中生成此列表非常容易。日志消息列表对您的客户来说将非常有价值,并且生成它只需要您的一点点自律。

注意

将您的设备中所有日志消息作为设备文档的一部分生成列表。

您对发送到 syslogd 的内容的控制不仅限于消息的优先级和文本。特别是,您还可以使用可选的 openlog()例程来控制 syslog 设施、消息前缀以及是否在日志消息中包含进程 ID。openlog()调用语法是:

void openlog(const char *ident, int option, int facility);

ident 是一个短字符串,syslog 将其添加到每个日志消息的开头。如果您没有指定,ident 默认为调用 syslog()的程序名称。option 参数允许您控制诸如如果 /dev/log 不可用时该做什么以及是否包含调用程序的 PID 等问题。选项是以下一个或多个选项的按位或:

  • LOG_CONS—在无法写入 /dev/log 时将日志写入控制台

  • LOG_NDELAY—立即打开到 /dev/log 的套接字

  • LOG_ODELAY—在将消息写入 /dev/log 之前等待第一个消息打开套接字

  • LOG_PERROR—将日志写入标准错误以及 /dev/log

  • LOG_PID—在每个消息中包含 PID

设施旨在对应于发送日志消息的程序类型。如果没有调用 openlog(),则默认为 LOG_USER。syslog.h 中定义了 24 个标准设施;以下摘录显示了最常见的定义。请注意,值向上移动了三位,以保留低三位位用于日志级别。

图片

虽然优先级和设施被 syslogd 用于路由,但它们的值不是保存文本的一部分;然而,您可以通过设置 syslogd 将不同优先级和设施值的消息保存到不同的文件中来推断保存的日志消息的优先级和设施。

syslog 协议

在介绍如何设置 syslogd 之前,让我们先考察用于发送 syslog 消息的协议。如前所述,syslogd 在/dev/log 上打开一个 Unix 数据报套接字,并在等待套接字上到达消息时阻塞。从应用程序传递给 syslogd 守护进程的信息包括一个设施、一个日志级别以及消息本身。守护进程使用设施和级别作为其唯一的过滤标准。

syslog 的原作者将优先级和设施组合成一个 32 位整数,其中优先级使用低三位位作为日志级别。组合的设施/级别是 ASCII 编码的,并在写入/dev/log 之前放在尖括号之间。

例如,假设您的程序将设施设置为 LOG_USER,并使用以下代码发送 INFO 日志消息。

图片

如果我们在 syslogd 从其 Unix 套接字读取消息后立即查看该消息,我们会看到:

<14>Aug 2 13:18:31 my_prog: abc=2

注意 LOG_USER(8)和 LOG_INFO(6)是如何组合成<14>的。不需要换行符或其他终止字符,因为 syslog()在将消息写入/dev/log 套接字之前会添加一个空字符。如果您不包括换行符,syslogd 将在将消息写入日志文件之前添加一个。

使用 syslogd 守护进程

syslog 守护进程从/dev/log Unix 套接字读取消息,并根据它们的设施和日志级别路由消息。syslog 消息的目的地被称为动作,包括文件、命名管道、系统控制台(或其他 TTY 端口)、网络上的其他 syslogd 系统以及用户。

syslogd 的过滤器和动作定义在/etc/syslog.conf 中。配置文件通常每行一个目的地,包含所需目的地的设施和级别的列表。动作中的设施由逗号分隔,然后是一个点和一个日志级别。可以使用星号来表示所有设施或级别,指定一个日志级别意味着包括该级别及其所有更严重的级别。例如:

图片

syslog 消息最常见的目的地包括文件、管道和网络上的其他日志守护进程。管道通过在目的地开始处给出管道符号 | 来指定。网络目的地以 at 符号 @ 开头。syslog.conf 的手册页提供了关于如何指定哪些设施和优先级被路由到哪些动作的更完整描述。将所有邮件日志路由到 /var/log/mail 以及将所有关键或更高优先级的打印假脱机程序和 FTP 日志路由到网络日志服务器的 syslog.conf 行如下:

图片

回想一下,设施是作为从 syslog() 传递到 syslogd 的优先级整数的一部分,并且你可以定义自己的设施。这让你可以在 syslog 之上构建一个私有日志系统。你可以在 syslog.h 中添加新的设施整数和名称,然后重新构建 glibc 和 syslogd。然而,使用新的设施显式整数可能更容易。有 24 个预定义的设施,所以选择一个很大的数字,比如 1,000。使用这个设施发送 INFO 日志的代码可能看起来像以下这样:

syslog((1000<<3) | LOG_INFO, "an event occurred");

我们已经明确展示了位移和 OR 操作,以说明正在发生的情况。我们建议你使用等效的 LOG_MAKEPRI(设施, 级别) 宏。

继续以这个例子为例,假设你有一个程序正在监听名为 /usr/local/private_pipe 的命名管道上的新日志消息。你可以通过在 syslog.conf 中添加以下行并重新启动 syslogd 来配置 syslogd 以通过新设施发送所有日志。

图片

桌面开发者可能对使用 syslog 进行事件处理的想法感到不适。但另一方面,Linux 桌面系统通常比设备有更多的 RAM 和 CPU 资源,因此它们可以承担 D-Bus 的(相对)高磁盘、内存和 CPU 负担。我们推荐使用 syslog,因为它简单、几乎所有编程语言都可用,并且内存和 CPU 负担小。

syslogd 的限制、优点和替代方案

默认的 syslogd 守护进程有一些限制。如前所述,它不保存消息级别或设施(尽管你可以通过基于它们的路由间接地获取它们)。Syslogd 不能基于正则表达式进行路由,它只接受来自 Unix 套接字的消息,并且它有一组相对有限的动作。一些程序员在设置调试和跟踪机制时发现级别的数量有限是一个问题。优点方面,syslogd 被普遍接受,并且经过彻底的调试、测试和安全验证。

日志记录器实用程序(我们在本章开头简要介绍过)允许您绕过 syslogd 的有限消息源。日志记录器将日志消息发送到 syslogd,从其命令行或其标准输入的每一行获取日志消息。如果您愿意,可以指定级别、设施和前缀字符串。有关更多详细信息,请参阅 logger 的 man 页面。您还可以将 netcat(一个用于从网络连接中读取和写入的简单实用程序)与日志记录器结合使用,以使用类似于以下命令的命令接受单个已接受的 TCP 连接的日志消息。

nc -l -p 2250 | logger

日志记录器实用程序允许您“监视”其他日志文件。例如,假设您希望将添加到 /usr/www/error.log 的每一行也发送到 syslog。以下命令行可以完成此操作。

tail -f /usr/www/error.log | logger

另一个值得提及的日志辅助程序是 klogd。由于 Linux 内核不使用 glibc,因此它不能使用 syslog() 发送内核日志消息。相反,内核日志消息可以通过系统调用 sys_syslog() 或从 /proc/kmsg 中可见的循环缓冲区获得。守护进程 klogd 将来自任一来源的内核日志消息转换为 syslog 消息。此外,klogd 将内核日志消息中的十六进制地址转换为它们的等效符号名称。要从十六进制地址获取符号,klogd 读取 System.map 文件中的内存映射。如果您在启动 klogd 之后加载或卸载内核模块,请确保使用命令 klogd -i 告诉 klogd 重新加载其符号表。

syslogd 的流行替代方案包括 nsyslog,它支持使用 SSL 的 TCP;minirsyslogd,这是一个可以处理极高流量量的最小化日志记录器;以及 syslog-ng,它可以基于正则表达式进行过滤,支持消息重写,并支持 TCP 源和目标。evlog 软件包在识别和响应日志消息方面表现最佳。有关这些替代方案的最新信息,可以通过在网络上搜索软件包名称来找到。

按需日志记录

如果您能动态控制程序中日志记录的详细程度,那岂不是很好?当然,您可以在启动程序时使用命令行上的 -v 开关,但这并不完全动态。此外,如果您能独立控制程序不同部分的日志级别,那也会很方便。这样,您就可以聚焦于研究特定的代码片段。本节描述了如何使用名为 Logit 的 RTA 表和 Laddie 设备的代码来独立控制程序不同部分的日志阈值,同时程序正在运行。图 6-3 阐述了为程序的不同部分提供不同日志阈值的理念。

这里是 Logit 表中行的定义:

图 6-3:每个程序部分的独立日志控制

理念是为代码的每个部分设置一个单独的日志阈值,并且只有当消息的级别在数值上低于该部分的日志阈值时才发送日志消息。我们 Logit 的实现有 12 行,前五行被 Laddie 原型守护进程内部使用。如果您愿意,可以轻松地更改 logit.c 中的 LOGIT_NROWS 以添加更多行。

让我们通过一个例子来操作。假设您想在代码的两个不同部分中添加按需日志控制,即图像处理(IM)和缓冲区管理(BM)。在初始化过程中,您的程序必须在 Logit 表中创建其条目。您可以直接这样做,或者可以使用包装函数 logitSetEntry()。下面的代码展示了这两种方法。

在以上初始化设置到位后,您现在可以添加可以通过在 Logit 表中提高或降低阈值来控制的日志消息。

在 Laddie 空守护进程头文件 empd.h 中定义的 LOG()宏,如果 LOG 调用中设置的阈值在数值上低于 Logit 中该代码部分的阈值,则会将消息发送到 syslog()或发送标准错误。例如,为了选择性地跟踪图像处理和缓冲区管理代码的操作,您可能有一些如下所示的几行代码。

LOG(LOG_DEBUG, IM, "深入图像处理");

LOG(DBG_2, BM, "释放缓冲区 ID=%d", buf_id);

文件 empd.h 定义了五个额外的日志级别(DBG_0 到 DBG_4),位于 LOG_DEBUG 之下,以便您能够更精确地控制调试消息的详细程度。

在以上所有设置到位后,您可以在程序的各个独立部分中启用和禁用日志消息。例如,禁用除 IM 部分之外所有日志记录的 SQL 命令可能如下所示:

UPDATE Logit SET thres = 0

UPDATE Logit SET thres = 10 WHERE sect = "IM"

总结

日志记录几乎可以添加到所有设备中,即使这些设备磁盘、内存和 CPU 功率有限。一个理想的日志系统具有许多日志消息的来源和目的地,并允许添加新的来源和目的地。

syslog 是 Linux 上的默认日志系统,有两个组件:一个发送日志消息的库例程和一个处理它们的守护进程。syslog()库例程在 Linux 上每个主要编程语言中都是可用的。syslog 守护进程 syslogd 根据消息的来源(设施)和报告事件的严重性(日志级别)来路由消息。在本章中,您学习了如何向 syslog 添加自己的设施,以便路由特定于您的设备的日志消息。

按需日志记录使我们能够动态控制应用程序不同部分的日志记录详细程度。虽然 RTA 使按需日志记录变得更容易,但它不是按需日志记录所必需的。

本章回顾了日志记录以及日志消息的收集和归档。下一章将描述一个能够识别日志消息中特定文本的日志系统,并根据具体情况重写和路由消息。


¹ 日志消息 提供了事件报告。警报 是系统故障或可用性降低的系统状态。日志消息用于报告进入和退出警报状态的变化,这两个术语有时会被混淆。

² 很遗憾,syslog 和 syslog.conf 的文档并不完全一致。一个将 优先级 定义为设施和日志级别的按位或运算,而另一个将 优先级 定义为我们所说的日志级别。虽然本书内部一致,但在阅读其他 syslog 文档时请谨慎。

LADDIE 事件处理

当警报或其他关键事件发生时,你的设备需要做出响应。无论是 CPU 温度、电池水平、磁盘空间不足还是纸张水平,都会发生需要设备采取行动的情况。

拥有一个通用事件处理系统的想法,在 Linux 中出人意料地并不常见。通常,事件处理的需求直到设备系统测试接近尾声时才变得明显,因此它通常被视为一个事后考虑——使用临时和整合不良的代码。

作者构建了足够的 Linux 设备,知道我们应该将事件处理集成到 Laddie 设计的核心中。作为 Laddie 项目的一部分,我们构建了自己的事件处理系统,该系统使用日志记录来捕获感兴趣的事件。我们的事件处理守护进程称为 logmuxd,本章解释了为什么我们构建它,描述了其特性,展示了其主要表格,并给出了其使用的完整示例。即使你选择不使用 logmuxd,本章也可能有价值,因为它展示了任何事件感知设备所需的处理类型。

本章讨论了在日志记录背景下的事件处理,但请记住,目标是事件处理,而日志记录只是达到该目标的一种机制。

我们将本章组织成以下部分:

  • 新事件处理系统的原因

  • logmuxd 的功能和特性

  • 配置 logmuxd

  • 使用 logmuxd 的示例

新事件处理系统的原因

我们发现,我们唯一按时且无错误交付的代码是我们没有编写的代码。也就是说,我们最成功的项目是我们能够最大限度地避免编写新代码的项目。新代码总是有错误的,新代码总是延迟的。那么,我们为什么决定编写一个用于事件处理的日志守护进程呢?答案实际上有两个部分:为什么我们选择使用日志作为机制,以及为什么我们选择不使用现有的日志系统。

第六章解释了为什么我们认为日志是事件报告的正确机制。对我们感兴趣的所有事件都已由 syslog 消息捕获,或者可以轻松地被捕获。几乎每种编程语言都有 syslog 库,syslog 被广泛理解,相当安全,并且 CPU 和内存效率都很高。

在事件处理方面,D-Bus(一个常用于分发桌面事件的开源软件包)排在 syslog 之后,是一个远程的第二选择。D-Bus 提供了库和 API,允许进程交换消息,前提是两个进程都支持 D-Bus。(正因为如此,使用 syslog 的旧应用程序必须重写以添加 D-Bus 支持。)然而,D-Bus 并不提供与 syslog 相同范围的语言支持,并且 D-Bus 通常需要两个运行中的守护进程,这使得它相对于 syslog 来说在 RAM 和 CPU 使用上更为密集。

注意

D-Bus 在大多数 Linux 桌面上是标准配置,但在大多数 Linux 设备上处理事件可能不太合适。

如果 syslog 是事件报告机制,那么为什么不使用 syslog 守护进程来处理事件呢?我们发现目前可用的日志系统缺少的主要功能是轻松复制日志消息并在接受的 TCP 连接上广播它们。

Laddie 警报系统需要将 Laddie 警报消息路由到多个正在运行的程序和用户界面。图 7-1 展示了一个典型情况。当发生警报时,ladd 发送一个日志消息来报告事件。我们需要将生成的日志消息的副本发送到每个启用了日志记录的 CLI 和每个查看系统状态的网页。问题是我们在事先不知道有多少个这样的接口是打开的。

我们的新日志守护进程 logmuxd 通过允许我们将消息路由到多个目的地来解决此问题,即使这些目的地是临时的。没有其他日志系统支持多个临时的目的地。当 ladd 检测到警报时,它使用 syslog() 发送日志;然后 logmuxd 捕获事件,如果需要则重写它,并将其多路复用到每个接受的 TCP 连接。

图 7-1:需要多路复用日志守护进程

我们决定投入时间构建一个新的日志系统用于事件处理,因为我们希望能够捕获、重写和路由来自设备上所有应用程序和守护进程的事件报告。作为一个设备设计师,您可能会发现您的设备需要从许多来源捕获事件报告并将消息路由到多个目的地。如果是这样,您应该考虑在您的设备中使用 logmuxd。

logmuxd 的功能和特性

我们希望 logmuxd 能够与现有的 syslogd 安装一起工作,或者作为它的替代品。也就是说,我们需要能够以整数周围的角度括号风格读取和写入消息。我们希望我们的日志守护进程能够支持许多类型的输入和许多类型的输出,能够根据正则表达式进行路由,并且能够在将其转发到目的地之前重写日志消息。

每个目的地都有自己的路由和重写规则集。这类似于 syslogd,这意味着您可能有其他完全相同的过滤器,每个过滤器的输出都发送到不同的目的地。

过滤器使用 regex() 库进行模式匹配和从日志消息中提取相关字段。消息可以选择使用从正则表达式模式中提取的字段进行重写。

图 7-2 展示了 logmuxd 的整体架构。在下一节中,我们将讨论此图中每个块。

图 7-2:logmuxd 的架构

如果我们从一个根据图 7-2 中显示的三个处理块分组其配置表的 logmuxd 配置表列表开始,可能会更容易理解 logmuxd 的使用。表 7-1 并没有详细描述所有表,因此您可能想使用 RTA 表编辑器更仔细地检查它们。

表 7-1: 根据处理块分组的配置表

logmuxd 有几个限制。它没有任何洪水过滤(例如,syslog 的“最后一条消息重复了 10 亿次”)。它使用正则表达式,这给它带来了很多功能和灵活性,但代价是 CPU 周期。最后,它是一个相对较新的日志守护进程,并且在某种程度上仍在变化,因为新功能被添加,以及发现和修复了错误。

您可以通过将 syslogd 与 logmuxd 配对来克服 logmuxd 的大部分限制。配置 syslogd 将所有消息输出到 FIFO,并配置 logmuxd 从 FIFO 读取,并仅过滤和重写您想要捕获以进行进一步处理的少量消息。

配置 logmuxd

RTA 表存储 logmuxd 的配置和统计信息。以下讨论了这些表,下一节的示例给出了使用它们的 SQL 语句。

logmuxd 源

您可以通过在 logmuxd 的 MuxIn 表中的四个可编辑字段中描述它们来告诉 logmuxd 关于您的事件源。这些字段是源、端口、类型和术语。

字段包含如果源是文件、管道或 Unix 套接字,则为文件名;如果源是 UDP 或 TCP 套接字,则为 IP 地址。

端口字段包含 UDP 和 TCP 套接字的端口号,对于来自文件系统的源则忽略。

类型字段指定了源可能的六种类型之一。表 7-2 描述了这六种源类型。

表 7-2: 六种可能的源类型

术语字段指定了源如何终止日志消息。零表示日志消息以空字符终止。syslog 的消息使用空字符终止。一表示每次从源读取()将接收到一个完整的消息。这种终止方式用于 UDP 套接字等。二表示换行符终止消息。换行符终止用于类似 tail -f 类型的源。

MuxIn 表也包含只读字段,用于存储使用统计信息、错误统计信息和源文件的文件描述符。有关这些字段的更多信息,请使用 RTA 表编辑器在运行的 Laddie 设备上检查它们。

与消息输入处理相关的其他两个 logmuxd 表。Rawlog 表充当 FIFO,用于保存最近的 10 条消息。这在调试过滤器或监控记录器的原始输入时很有用。Accpt 表保存由接受的 TCP 连接和打开的 Unix 套接字所需的数据文件描述符和其他信息。Rawlog 和 Accpt 中都没有可配置的字段。

logmuxd 过滤器和重写功能

使用 logmuxd 的主要理由之一是它可以重写消息并将它们转发到另一个进程。例如,当用户在区域中设置测试警报时,ladd 会发送日志消息“用户在区域 n, 区域名称”(其中 nzone_name 分别被区域编号和用户指定的名称替换)。我们希望这条日志消息出现在前面板的 LCD 显示屏上,但 LCD 显示屏只能显示 16 个字符,因此我们使用 logmuxd 的重写功能将消息重写以适应 LCD 显示屏。原始消息的重写如下:

Aug 12 22:28:31 ladd[3820]: 用户在区域 5 设置了警报,冰箱

到如下:

22:28 用户设置 5

识别和重写日志消息的所有配置数据都包含在 Filters 表中。此表包含目标类型和名称、匹配的正则表达式以及重写消息的 snprintf() 格式字符串。让我们依次查看这些字段。

用于指定目标的有两个字段:desttypedestname。每种目标类型都有一个单独的表。这是必要的,因为例如,电子邮件目标需要与 SNMP 钩子目标不同的配置信息。目标类型及其目标表由 desttype 字段设置。有九种有效的目标类型。类型 1 和 2 不包含在表 7-3 中,因为它们是严格的目标类型。

表 7-3:九种有效的目标类型

每个目标表中可以描述几个不同的目标。每个目标在其目标表中的一个唯一名称(destname)。例如,如果您有两个不同的 SNMP 目标,您可能将其中一个称为 allsnmp,另一个称为 laddiesnmp。通过给它们不同的名称,您可以定义不同的路由和重写规则。要将 Filters 表中的过滤器链接到特定目标,您需要指定目标类型和目标名称。

Logmuxd 根据消息的设施、日志级别和文本模式匹配来路由消息。Filters 表中的这三个对应字段是 facilitylevelregex。设施和日志级别与为 syslogd 定义的相同。regex 模式是一个用于模式匹配和子模式提取的正则表达式。

正则表达式库是进行模式匹配和提取的好选择,因为模式可以预先编译以提高模式匹配的速度,并且正则表达式让你能够轻松地从搜索模式中提取子模式。在我们使用 logmuxd 的过程中,我们发现我们实际上并不需要了解太多的正则表达式模式。以下示例说明了你需要了解的大部分内容。

假设你正在处理火车到达车站的事件,并且日志消息是从 San Jose 开来的火车到达轨道编号 15。要将此消息重写为San Jose : 15,你需要提取城市和轨道编号。捕获城市的正则表达式模式是[A-Za-z -]+。这个模式匹配至少一个大小写字母、一个空格或一个连字符的任意组合。轨道编号的模式只是[0-9]+。这里有个技巧:如果你在一个模式周围加上括号,正则表达式会使得该模式在正则表达式输出中作为单独的部分可用。以下是一个匹配消息并提取城市和火车编号的正则表达式模式。

Train from ([A-Za-z -]+) arriving on Track number ([0-9]+)

过滤表中的重写字段包含用于重写日志消息的 snprintf()格式字符串。格式字符串包含你选择的文本,并且可以包含从正则表达式模式中提取的字符串。正则表达式模式的匹配结果可以作为 snprintf()的显式参数使用。表 7-4 列出了可用的参数。

表 7-4: 可用于 snprintf()的参数

继续上面的例子,你可以使用重写格式字符串%1\(s : %2\)s来获取消息San Jose : 15

你可以通过在重写字符串中包含%11$s来向重写消息中添加日期和时间。日期和时间的格式由time_fmt字段设置,该字段传递给 strftime()进行转换。time_fmt 的常见示例包括%F %T,它给出日期和时间的显示为YYYY-MM-DD hh:mm:ss,以及%R,它只显示时间为hh:mm

新行的显式参数很方便,因为将换行符放入 RTA 表中可能很困难。记住,对于 PostgreSQL 来说,\n 是一个由反斜杠和字母n组成的两个字符字符串。

我们将在第 98 页的“使用 logmuxd 的示例”部分展示更多正则表达式模式匹配和消息重写的例子。

logmuxd 目标地址

每种类型的目标都有一个表来存储该类型独有的参数。你可以通过使用 RTA 表编辑器浏览它们来轻松地了解大多数表及其内容,但有三张目标表需要一些额外的说明。

MailDest 表格有一个包含要发送的电子邮件消息主题的 subject 字段。to_list 字段是分隔的收件人列表。出于安全原因,to_list 中只允许使用字母数字、点、下划线、at 符号(@)和空格。如果您打算使用电子邮件作为目标,请确保在您的设备上运行 Sendmail、Postfix 或其他邮件传输代理。

SnmpDest 表格包含目标名称、SNMP 陷阱守护进程的 IP 地址、SNMP 守护进程的社区字符串、端口号以及要发送的陷阱类型(版本 2 陷阱或版本 2 通知)。这些字段中的值作为参数传递给 snmptrap 命令,该命令实际上发送陷阱。

TblDest 表格包含 20 条日志消息,最新消息始终位于表格顶部。在 Laddie 中,我们使用此表格来存储我们向最终用户显示的日志消息。

使用 logmuxd 的示例

让我们通过几个示例来帮助阐明如何使用 logmuxd。

示例 1:logmuxd 演示

在前面的章节中,您可以看到 Laddie 警报系统的一个优点是,当发生警报时,所有用户界面都会更新以反映新的警报和新的系统状态。本演示展示了如何查看分发到所有用户界面的日志消息。

  1. 启动 Laddie CD。系统启动后,请验证您是否可以在另一台 PC 的网页浏览器中看到 Laddie 的网页界面。

  2. 在 Laddie 上,logmuxd 被配置为将警报系统事件广播到所有接受的 TCP 连接的 4444 端口。打开一个终端窗口并 telnet 到 Laddie PC 上的 4444 端口。例如:

    telnet 192.168.1.11 4444

  3. 使用网页界面测试几个区域,然后清除所有警报。您的 telnet 会话应显示类似于以下日志消息。

    2007-10-07 12:03:35 用户在区域 2,后门设置了警报。

    2007-10-07 12:03:35

    警报系统状态:警报 2007-10-07 12:03:37

    用户在区域 3,车库设置了警报。

    2007-10-07 12:03:38 用户在区域 2,后门清除了警报。

    2007-10-07 12:03:40 用户在区域 3,车库清除了警报。

    2007-10-07 12:03:40 警报系统状态:安全

尽管这个示例很简单,但它展示了 logmuxd 多路复用日志消息的能力。

示例 2:logmuxd 和接受的 TCP 连接

我们构建新 logger 的理由是我们想要能够打开到日志守护进程的 TCP 连接,并且通过该连接将日志消息发送给我们。最后一个示例展示了这一功能,在本例中我们看到如何配置 logmuxd 以接受 TCP 连接。我们使用 logmuxd 替换 syslogd,使用 logger 命令生成“火车到达”消息,并通过 telnet 连接到 logmuxd 来查看重写的日志消息。在本例中,我们将形式为“Train arriving from city_name on track track_number”的日志消息重写为“city_name : track_number”。

您可以从 CD 复制源代码并在您的开发系统上构建 logmuxd,或者您可以从 Laddie CD 启动并使用其运行的 logmuxd 版本。不用担心更改 Laddie 上的 logmuxd 表,重启将恢复到原始状态。我们将使用 psql 进行表更新,但您也可以使用表编辑器,如果您愿意的话。图 7-3 展示了本例中的数据流。

图 7-3:使用 telnet 的 logmuxd 示例

配置的基本步骤如下:

  1. 配置 logmuxd 以接受来自 /dev/log 的 syslog 消息。验证设置。

  2. 配置 logmuxd 以识别和重写“火车到达”消息。

  3. 配置 logmuxd 以在端口 3333 上接受 TCP 连接。

  4. 使用 logger 和 telnet 验证消息是否被分发到 TCP 端口 3333 的连接。

我们首先清除我们将要使用的所有表中的配置。使用控制台或 telnet 登录运行 logmuxd 的 PC(从本书的 CD 启动的 PC)。logmuxd 上的 RTA 接口监听端口 8887;您可以使用以下命令启动 SQL 会话并清除表:

MuxIn

在本例中,我们希望 logmuxd 替换 syslogd,因此我们需要配置它监听 Unix 套接字 /dev/log 并以 syslog 风格读取日志消息。我们指定源为 /dev/log,类型为 6(syslog 格式),日志消息终止符为 0(消息之间的空字符)。

UPDATE MuxIn SET source = "/dev/log", type = 6, term = 0 LIMIT 1;

如果一切正常,上述命令在 /dev/log 上打开了一个 Unix 套接字,并且 MuxIn 表的显示应该显示我们源的有效文件描述符。(netstat 命令也应该显示 /dev/log 套接字。)

SELECT source, fd FROM MuxIn;

如果我们现在正在监听 /dev/log,我们应该能够看到使用 logger 发送的日志消息。打开另一个终端窗口,并第二次 telnet 到运行 logmuxd 的 PC。在新窗口中输入以下命令:

logger "Hello, world!"

通过查看 Rawlog 表来验证 logmuxd 是否接收到了消息。

SELECT source, log FROM Rawlog;

Filters

继续以上示例,我们将使用过滤器中的第一行,但我们将逐列更新它,以便更好地解释这些列。

desttype

你可能还记得,desttype 是一个整数,它隐式地选择这个过滤器将使用哪个目的地表作为其目的地。desttype 为 3 用于已接受的 TCP 连接。

destname

在目的地表中可能存在多个独立的目的地。我们需要一种方法来区分一个目的地与另一个目的地,因此我们给每个目的地起一个名字。过滤器表中的 destype 用于选择使用哪个目的地表,而 destname 用于选择使用该表中的哪一行。在这个例子中,我们将分配一个名为example_2的名字。

UPDATE Filters SET desttype = 3, destname = "example_2" LIMIT 1;

regex

如果我们将我们在查看列车到达示例时构建的正则表达式模式与一些简单的 SQL 结合起来,我们就可以得到设置过滤器中正则表达式模式的命令。

UPDATE Filters SET regex =

"Train from ([A-Za-z -]+) arriving on Track number ([0-9]+)" LIMIT 1;

level and facility

The logmuxd 守护进程根据传入日志消息的级别和设施进行路由。在这个例子中,我们不在乎用于发送消息的级别和设施是哪个,所以我们设置级别为一个高值,并清除设施掩码。

UPDATE Filters SET level = 15, facility = 0 LIMIT 1;

在我们的例子中,此时我们可以测试我们的正则表达式模式的模式匹配能力。使用带有 bash 提示符的终端发出以下命令。

logger "凤凰城开往的列车到达轨道号码 22"

验证我们的过滤器中匹配的数量是否增加了一个。

SELECT * FROM Filters LIMIT 1;

重复上述两个步骤几次,使用不同的城市名称和跟踪号码。发出几个日志命令,其中模式不完全匹配,并验证计数没有增加。

重写

你可能还记得,正则表达式模式的魔力在于你可以通过在其周围放置括号来提取子模式。在这里,我们正在提取城市名称和轨道号码,并将它们重写为city_name : track_number. 正则表达式子模式作为%1\(s 到%9\)s可用。我们想要前两个模式,并且我们想要在输出中添加一个换行符,所以我们使用以下命令设置重写字符串:

UPDATE Filters SET rewrite = "%1\(s : %2\)s %12$s" LIMIT 1;

我们已经完成了 Filters 表的配置,现在可以通过编辑 NetDest 表来完成配置。

NetDest

我们希望设置一个监听端口 3333 的 TCP 套接字。让我们通过绑定到 0.0.0.0 来让网络上的每个人都能访问该端口。这个网络目的地的名字应该是 example_2,而这个网络目的地的类型应该是已接受的 TCP 连接,即类型 3:

UPDATE NetDest SET destname = "example_2", dest = "0.0.0.0", port = 3333, type = 3 LIMIT 1;

如果一切顺利,应该在端口 3333 上有一个监听套接字。使用 netstat -nat 来验证端口是否打开并绑定到正确的地址。使用以下 SQL 语句查看套接字的文件描述符。

SELECT * FROM NetDest LIMIT 1;

我们现在可以验证整个系统。打开第三个终端窗口并连接到端口号 3333。你的命令可能看起来像这样:

telnet 192.168.1.99 3333

你现在应该能够验证 logmuxd 已经接受你的 telnet 连接。在 psql 提示符下,输入以下内容:

SELECT * FROM AccptDest;

应该一切正常。在带有 bash 提示符的终端中输入以下内容:

logger "从凤凰城开来的火车到达 22 号轨道"

logger "从圣何塞开来的火车到达 15 号轨道"

logger "从旧金山开来的火车到达 9 号轨道"

验证城市和轨道号是否已从连接到端口号 3333 的连接中提取并显示。你的输出应该如下所示:

凤凰城:22

圣何塞:15

旧金山:9

这是一个很长的例子,但它已经说明了如何配置 logmuxd 以及如何调试该配置。

示例 3:logmuxd 和 SNMP 陷阱

简单网络管理协议 (SNMP) 是一个互联网标准,用于管理网络设备,如路由器。该协议具有读取和写入值(GET 和 SET)以及 traps 的命令,这是其日志消息的等效。网络设备通常需要在特定事件发生时发送 SNMP 陷阱。本例展示了如何使用 logmuxd 将 syslog 风格的日志消息转换为 SNMP 陷阱。(SNMP 和陷阱将在后面的章节中详细说明,你可能想在阅读这些章节之后再进行此示例。)

当系统进入或离开警报状态时,Laddie 警报系统会发送 SNMP 陷阱消息。为了发送 SNMP 陷阱,logmuxd 使用一个辅助应用程序,snmptrap。snmptrap 命令以与 logger 发送 syslog 消息相同的方式发送 SNMP 陷阱。

你可能还记得,当区域进入警报状态时,ladd 使用 syslog 发送类似以下的日志。

在区域 2,后门设置了警报

用户在区域 3,车库设置了警报

以下是对应上述两个日志消息的 snmptrap 命令。

在上述行中,public 是团体名称,它来自 SnmpDest 表,因为它是特定于目的地的。对于 snmp_mgr:162,它也是目的名称(或 IP 地址)和 snmptrapd 使用的端口号。如果 SnmpDest 的类型字段设置为 3,则命令中会添加一个-Ci,使其成为 SNMP 版本 2 inform。目的名称、端口、团体字符串和要发送的陷阱类型字段都应该出现在你的 UI 中,因为最终用户必须使用与最终用户安装匹配的值来配置这些。

如果您的 MIB 已安装并且可以通过 snmptrap 命令访问,您可以使用陷阱的名称。如果 MIB 未安装,您需要在命令行上放置陷阱的完整、数字对象 ID(OID)。命令行中的两个单引号告诉 snmptrap 命令在陷阱中发送当前运行时间。务必阅读 snmptrap 的手册页,以了解更多关于该命令及其选项的信息。本书中的 SNMP 章节将回答您关于 SNMP 及其事件通知系统(陷阱)的许多问题。

关于 SNMP 陷阱服务器的信息来自 SnmpDest 表中输入的用户信息。对于某些陷阱信息必须从日志消息中提取。例如,为了发送我们的 SNMP 陷阱,我们需要将这些翻译成:

在区域2,后门上设置警报

在区域3,Garage 上设置用户警报

转换为:

ladAlarm ladTrapZoneId i 2 ladTrapZoneName s "Back Door"

ladTestAlarm ladTrapZoneId i 3 ladTrapZoneName s "Garage"

这就是正则表达式模式匹配和重写发挥作用的地方。使用上面火车站示例中给出的正则表达式模式,您已经拥有了填充表格所需的一切。

目标类型 9 表示 SNMP 目标,我们给这个目标起的名字是 snmp_monitor。我们需要从 Filters 表中获取两行,一行用于用户测试区域时发送的“用户设置”消息,另一行用于由真实警报生成的“警报设置”消息。我们使用 Filters 表中的第 1 行和第 2 行,这样就不会覆盖之前例子中使用的第 0 行。我们在这里使用 SQL 展示配置,但表编辑器同样适用。

SnmpDest 表中的值针对配置为接收陷阱的网络计算机是特定的,因此您应该从您的 UI 之一或多个中提供对这些值的用户访问。(Laddie 网页界面允许您指定发送 Laddie 的 SNMP 陷阱的位置。)在这个例子中,我们使用 SQL 手动设置这些值。假设陷阱目的地在一个名为 snmp_host 的网络主机上。

您可以通过在您的网络主机之一上运行 snmptrapd 来测试此配置。(有关详细信息,请参阅第十三章。)

摘要

传统日志处理事件的方法是将事件的报告(日志消息)放入磁盘上的一个或多个文件中。更好的方法是单独检查每个事件,然后决定如何最好地处理它。使您的设备能够意识到事件并能够对这些事件做出响应,是您可以为您的客户做的最好的事情之一。

在前面的章节中,我们向您展示了如何使用 PostgreSQL 协议和 API 来控制和管理您的设备。但控制和状态只是解决方案的一半——在本章中,我们介绍了事件处理,这是成功设备设计的另一半。


其他编程书籍可能会将配置作为文件格式或一系列子程序调用给出。相反,我们以 logmuxd 的 RTA 表接口来展示其配置。你现在应该将所有状态和配置信息视为它们在 RTA 表中呈现的方式。

如果 SNMP 的 GET 和 SET 命令对应于 SQL 的 SELECT 和 UPDATE 命令,那么 SNMP 管理信息库(MIB)对应于数据库表。

设计网页界面

网络浏览器已成为配置网络设备的首选用户界面,尤其是来自 Linksys 和 Netgear 等公司的家庭路由器。推动网络界面普及的动力在于它们易于使用且不需要专门的客户端软件。客户现在期望能够访问其设备的网络界面,因此,领先的家用网络设备制造商提供这些界面并不令人惊讶。

这是关于用户界面(UI)设计的几章中的第一章。本章涵盖一般性的网络 UI,特别是 Laddie 网络 UI 的开发。在随后的章节中,我们将探讨 Laddie 的其他 UI:第九章的 CLI 界面,第十章的前面板 LCD 界面,第十一章的 framebuffer 界面,以及第十二章的红外遥控器界面。所有这些 UI 都通过 PostgreSQL 协议与后端守护进程进行通信。

本章涵盖以下主题:

  • 网络技术概述

  • 为您的设备网页界面建立需求

  • 选择 web 服务器

  • 设计网页界面的外观和感觉

  • 我们实现的重点

  • 经验总结和未来改进

网络基础

网络浏览器使用超文本传输协议(HTTP)与 web 服务器通信,这是一个客户端-服务器协议。当网络浏览器(客户端)通过特定的统一资源定位符(URL)请求网页时,例如www.google.com,通信便从浏览器(客户端)发起。当 web 服务器接收到这个请求后,它会检查请求的页面是否可用,如果可用,它就会将页面发送到浏览器。

由于 HTTP 协议是基于文本的,你可以使用 telnet 来模拟浏览器请求,如下所示:

telnet www.google.com 80

一旦 telnet 会话连接成功,输入以下内容:

GET / HTTP/1.0

然后按两次回车(第二次回车产生的空行会导致 web 服务器响应 GET 请求)。返回的页面使用 HTML 格式化;下面是一个示例页面。(显然,如果你在浏览器中打开它,页面看起来会不同,因为浏览器会解释 HTML 标记并以人类可读的方式呈现。)请注意,页面的中间部分已被省略号(...)替换,以减小其大小。

DNS 和 TCP

网络协议 DNS(域名系统)和 TCP(传输控制协议)使得这种客户端-服务器交换成为可能。给定 URL(例如,www.google.com),客户端使用 DNS 来确定服务器的 IP 地址。HTTP 使用 TCP 在客户端和服务器之间进行无错误的数据传输。

这些协议由互联网工程任务组(IETF)定义,它是权威的互联网标准机构。(有关 IETF 标准的更多信息,请参阅www.ietf.org; 有关 TCP/IP 的更多信息,请参阅查尔斯·M·科齐罗克的《TCP/IP 指南》,No Starch Press,2005 年。)

Web 服务器

Web 服务器查找并返回给定 URL 的网页。这个页面可能位于服务器的文件系统中,或在内存中,或者它可能在请求时动态生成。

CGI

通用网关接口(CGI)作为一种方式出现,允许 Web 服务器与专门设计的程序通信,该程序随后代表 Web 服务器生成网页。在 Unix 世界中,早期的 CGI 程序是用 bash、Perl 和 C 等语言编写的。今天,更常见的 Web 特定脚本语言如 PHP。此外,现代 Web 服务器如 Apache 可以配置为在 Apache Web 服务器相同的进程中运行 PHP 脚本,从而避免 CGI 通信机制。

JavaScript

JavaScript 已成为网页客户端编程语言的公认标准。JavaScript 代码嵌入在 HTML 页面中,网页中的标签告诉网页浏览器何时执行 JavaScript 函数。JavaScript 的主要优点之一是它提供了更响应的用户体验。JavaScript 的主要缺点之一是并非所有浏览器都支持它,而且那些支持它的也不一定按照标准方式支持。

技术演变

与客户端和服务器端的发展相结合,HTML 协议已经经历了许多修订,并扩展到包括 XHTML、CSS、XSL 和 XPath。

重要的是,网络技术仍在不断发展。这种发展给努力实现网页互操作性和持久性的开发者带来了挑战。因此,作为一名网络开发者,提前规划网络技术的变化是明智的。

建立需求

在为 Laddie 警报设备开发 Web 用户界面之前,我们确立了以下要求来指导其设计:

  • Web 界面应易于使用。

  • Web 界面应支持广泛的浏览器,包括基于文本的浏览器。

  • 网页应随着设备状态的改变自动更新。

  • 网页应遵守互联网标准,避免使用专有功能。

  • 实现应与各种 Web 服务器兼容,以便在更好的 Web 服务器可用时可以替换设备 Web 服务器。

  • 实现应该是简单的,以便可以轻松维护。

选择 Web 服务器

在构建您的设备时,您应该使用哪个 Web 服务器?在本节中,我们将回顾几个适合 Linux 设备的 Web 服务器。

选择

网络服务器有多种不同的类型。许多支持 CGI 接口,这使得网络服务器能够启动一个任意进程来代表网络服务器生成网页内容。

  • Apache 网络服务器可以与 PHP 解释器一起编译,这样 PHP 脚本就可以在 Apache 进程中解释。这种方法减少了进程间通信,并提高了响应时间。

  • lighttpd 网络服务器支持 FastCGI 接口。FastCGI 机制会启动多个 PHP 解释器,并在它们之间负载均衡 PHP 网页的请求。更多信息,请参阅 www.fastcgi.com.

  • GoAhead 网络服务器允许将网络服务器和所有网页打包成一个单一的可执行文件,这使得网络服务器可以在没有文件系统的情况下运行。

  • Linksys WRT54G 无线路由器中的网络服务器完全用 C 语言编写,并为每个网页包含手工编写的函数。

  • TUX 网络服务器在 Linux 内核中运行。

选择网络服务器时首先要考虑的可能就是许可证。如果你不想发布你的源代码修改,那么你应该避免使用带有 GPL 和 Apache 许可证的网络服务器。另一方面,如果你选择了一个成熟的网络服务器,如 Apache,那么你很可能不需要对其进行修改,因此你也不必担心需要发布源代码。

我们建议你抵制开发自己的网络服务器。选择现有的网络服务器并以服务器无关的方式开发网页更经济。这种方法的优点是,你不必花费开发资源来维护网络服务器,并且如果出现更好的网络服务器,你可以替换它。

使用 PHP

我们建议使用 PHP 作为生成动态网页的语言。虽然你可以用 C 语言编写较小的 CGI 程序,但如果使用编译型语言并且需要在设备部署后修改网页,你需要一个编译环境,这通常在部署的设备上不可用。

当你使用像 PHP 这样的解释型语言时,你可以轻松地修改和测试部署在设备上的网页。

PHP 是生成网页内容的良好语言,因为它受欢迎、成熟、拥有活跃的开发者社区,并且与 Apache、thttpd 和 lighttpd 等开源网络服务器很好地集成。即使对于不支持 PHP 的网络服务器,你仍然可以编写使用 PHP 的 CGI 程序。因此,你可以使用 PHP 与几乎任何网络服务器一起使用。正是出于这些原因,我们选择了 PHP 来开发 Laddie 网络用户界面。

案例研究:Linksys WRT54G 无线路由器

让我们考察 Linksys WRT54G 无线路由器所采用的方法。这个路由器中的 web 服务器是 micro_httpd 和 mini_httpd web 服务器的手工组合,并增强了生成网页动态内容的专用 C 函数。代码是 GPL 许可的,可以在 GPL 代码中心www.linksys.com下获取。(micro_httpd 和 mini_httpd 都是由 Jef Poskanzer 编写的,可在www.acme.com获取。)

专用 C 函数负责生成网页的动态内容。因为这些函数被编译到 web 服务器中,所以不需要像 PHP 这样的脚本解释器。

例如,C 函数 dump_route_table()通过在匹配的标签对之间放置函数名,从网页中调用,如下所示:

<% dump_route_table(""); %>

这种标签机制类似于 PHP 所采用的方法,但在这里,该功能是用 C 语言实现的,并编译到 web 服务器中。

这种方法的优点是内存需求较小。然而,如前所述,这种方法的问题在于开发周期被延长,因为对专用 C 函数的任何更改都需要重新编译。

案例研究:TUX Web 服务器

与大多数其他在用户空间运行的 web 服务器不同,TUX web 服务器在 Linux 内核中运行。在内核空间运行允许 TUX 避免内核空间与用户空间之间的通信;因此,TUX 提供的服务器响应时间比其他 web 服务器更好。

TUX 支持静态和动态网页内容,但要支持动态内容,必须在用户空间运行另一个 web 服务器。TUX 通过自己响应静态网页请求并转发动态内容请求到 Apache 等用户空间 web 服务器来操作。正如你可能想象的那样,TUX 在支持动态网页方面并不提供速度优势。因此,对于主要动态生成内容的网站,额外的 TUX 配置可能不值得麻烦。

Web 服务器的比较

在上一节中,我们列出了从 Apache 到 TUX 的一系列 web 服务器。在本节中,我们将我们的重点缩小到仅比较支持 PHP 作为脚本语言的 web 服务器。由于空间原因,我们将 web 服务器集合限制为 Apache、Boa、BusyBox 的 httpd、Cherokee、GoAhead、lighttpd 和 thttpd。这些 web 服务器被选中是因为它们要么用于商业产品,要么是为嵌入式应用量身定制的。

比较 web 服务器的可能标准包括:

  • 内存占用

  • 可执行文件大小

  • 性能

  • 安全支持

  • 持续维护和开发

  • 调试支持

  • 文档

  • 成本

无论您如何权衡不同的标准,选择网络服务器都将需要妥协。例如,对于某些设备,内存占用量可能是关键的,但对于其他设备则不然。

而不是为您的设备推荐一个特定的网络服务器,我们已经编制了表 8-1,该表显示了在我们有限的集合中,各种网络服务器在每个领域的比较情况。在选择网络服务器时,您可以将此表作为起点。

您可以使用不同的网络服务器来处理开发的各个阶段。例如,您可以在开发阶段使用一个具有良好调试支持的网络服务器,然后在测试和部署期间切换到具有较小内存占用量的另一个网络服务器。如果您选择使用不同的网络服务器,请提前规划以确保您使用的是所有网络服务器都支持的功能。

表 8-1:各种网络服务器的比较

图片

关于表格数据

让我们更详细地看看表 8-1。

测试版本 这是测试的网络服务器的软件版本。

虚拟内存 这是运行中的网络服务器消耗的虚拟内存(以千字节为单位)。虚拟内存是通过 Unix top 命令测量的,该命令在 SIZE 列下显示虚拟内存。对于产生多个进程的网络服务器,我们记录了最大值。在每个案例中,虚拟内存都是在性能测试期间记录的。(参见下一页上的“响应时间”。)

可执行文件大小 这是在使用大多数默认选项编译后,通过 strip 命令手动剥离的可执行文件的大小(以千字节为单位)。由于库有时是动态链接的,有时是静态链接的,因此这个指标并不像虚拟内存那样能很好地指示所需的内存量。

当程序是动态链接时,大部分代码可以在动态链接库中。当您查看可执行文件大小时,这些库中的代码将不会被考虑在内。因此,动态链接的可执行文件大小并不能很好地表明在可执行文件运行时(当所有库在加载时链接)所需的内存量。通常,重要的是程序运行所需的内存量,因为内存是宝贵的资源。

响应时间 这是指通过 httperf 工具(可在www.hpl.hp.com/research/linux/httperf获取)记录的访问 Laddie 的 status.php 页面的平均响应时间(以毫秒为单位)。进行此性能测试的动机是测量网络服务器对状态网页请求的响应速度。对于每个网络服务器,采取了以下步骤:

a. 网络服务器的软件是使用默认选项编译的,除了那些使它正确工作的选项之外。关于每个网络服务器如何配置的详细说明,可在本书配套 CD 上的/Code/src/web/INSTALL_WEB_SERVER.txt 中找到。我们使用了 PHP 版本 5.0.3。

b. 使用 strip 命令去除了生成的网络服务器可执行文件。

c. 运行了后端 Laddie 进程,ladd。

d. 以下命令被用来测量响应时间:

httperf --hog --server 192.168.1.11 --uri=/cgi-bin/status.php --num-conn 200 --rate 1

生成的动态状态页 status.php 的大小为 4546 字节。我们用于测试的服务器由一个运行 Linux Red Hat 9 的 2.4 GHz 英特尔赛扬处理器组成,其他进程处于空闲状态。客户端由一个运行 Linux Red Hat 9 的 1 GHz AMD Duron 处理器组成。服务器和客户端之间有 10 MHz 的 NIC,并通过 Linksys 交换机/路由器连接。

支持 CGI 这表示网络服务器是否支持通用网关接口(CGI)。

支持 FastCGI 这表示网络服务器是否支持 FastCGI,这是 CGI 的性能增强。有关 FastCGI 的文档可以在www.fastcgi.com找到。

支持进程内脚本 这表示网络服务器是否支持内置的 PHP 解释器(或某些其他脚本解释器)。这种功能提供了更快的性能,因为它避免了 CGI 接口中的进程间通信。

使用的服务器 API 这是在响应时间性能测试期间使用的服务器 API 接口。服务器 API 是网络服务器和脚本之间的通信机制,例如 Apache、CGI 和 FastCGI。从表中可以看出,一些网络服务器只支持一个服务器 API,而其他网络服务器则支持多个。

最后发布版本 这是在撰写本文时软件最后一次发布的日期。这个值是软件是否积极维护的一个指标。在大多数情况下,我们测试的版本是最后一个发布的版本。然而,有一个例外——截至本文撰写时,thttpd 的最后一个版本是 2.25b,但我们测试的是 2.21b 版本,因为 2.21b 是最后一个支持进程内脚本的版本。

调试 这表示是否可以使用网络服务器调试脚本。在 Apache 和 PHP 的情况下,有一个名为 Zend Studio 的商业开发环境,允许您使用 Internet Explorer 调试 PHP 脚本。使用 Zend,您可以逐行执行 PHP 脚本并查看 PHP 变量。

文档 这是对文档是否明确指定网络服务器支持哪些功能和是否提供如何使用每个功能的说明的粗略衡量。

成本 这是将网络服务器分发给设备的货币成本。请注意,我们没有包括任何有货币成本的网络服务器。

安全性 这些是防止用户访问他们不应能够访问的文件的安全功能。最安全的网络服务器是那些强制通过配置文件进行访问的网络服务器。

许可证 这是网络服务器所拥有的软件许可证类型。Apache、BSD 和 GPL 许可证是众所周知的。GoAhead 许可证要求你在产品发货前通知 GoAhead,并在你的初始网页上显示 GoAhead 标志。

考虑内存需求

如果你的设备中内存不是问题,Apache 网络服务器将是一个不错的选择。Apache 的优点是它成熟的功能集、良好的开发工具(如 Zend Studio)和活跃的开发社区。

如果内存很紧张,那么 BusyBox 网络服务器可能是一个不错的选择;它是我们在测试的网络服务器中虚拟内存需求最小的。GoAhead 网络服务器的内存需求次之;然而,GoAhead 的缺点是它使用 Active Server Pages,这是一种微软技术,而不是 PHP,这是一种开源技术。(你仍然可以使用 CGI 机制在 GoAhead 中运行 PHP 脚本,但这不如使用内置 PHP 解释器的网络服务器那样无缝。)

考虑响应时间

在响应时间方面,排名前三的网络服务器是 thttpd、Apache 和 lighttpd。thttpd 和 Apache 都通过在同一个进程中运行 PHP 脚本来获得速度,这避免了其他网络服务器使用的进程间通信。thttpd 网络服务器的缺点是它一次只能服务一个请求,因此它将在之前的请求完成之前阻塞后续请求。这种行为对于某些网页来说可能没问题,但如果网页被编写为在一定时间内阻塞请求,或者直到状态改变才解除阻塞,那么这将成为一个问题。Laddie 中的一个网页就会因为状态改变而阻塞,因此这种网页行为排除了在 Laddie 设备中使用 thttpd 网络服务器。(我们将在第 125 页的“使用 Ajax 进行异步更新”部分讨论这个特定的网页。)

我们的选择

在我们开发 Laddie 的过程中,我们使用了 Apache 网络服务器,因为它提供了调试支持;而对于生产设备,我们选择了 lighttpd,因为它对内存的要求较小且速度快。我们在开发周期的后期才决定使用哪个网络服务器。我们之所以能够晚些时候做出这个决定,是因为我们已经将 PHP 脚本编写为可以在 Apache、CGI 和 FastCGI 下运行。

在他的关于嵌入式 Linux 的书中,Yaghmour 建议不要使用 Apache,因为它难以交叉编译(《嵌入式 Linux 系统构建》,Karim Yaghmour 著,O’Reilly,2003)。对于我们的设备来说,没有必要进行交叉编译,但如果你设备的 CPU 与开发机的 CPU 不同,你应该记住这一点。

UI 设计

在本节中,我们将回顾设计 UI 外观和感觉的各种方法,以及它们所需的权衡。在“实现”部分的第 118 页,我们将权衡这些权衡以做出实现决策。

菜单系统

菜单系统最重要的功能之一是它允许用户快速了解系统的功能。拥有许多顶层选项的菜单可能会让用户难以选择操作,因为选择太多。另一方面,拥有许多嵌套的菜单,虽然减少了顶层菜单的拥挤,但往往会增加找到操作所需的时间。

菜单系统可以分为那些顶层菜单沿着窗口左侧垂直运行的(见图 8-1),以及那些顶层菜单在窗口顶部附近水平运行的(见图 8-2)。虽然垂直菜单可能很有用,但随着菜单项数量的增加,它可能很快变得难以导航(注意图 8-1 中的滚动条)。水平菜单通常更优越,因为它可以更紧凑,因为二级菜单共享相同的区域。

图 8-1:垂直菜单

图 8-2:水平菜单

图 8-2 中垂直 MyFaces 菜单的一个缺点是,第二级菜单(例如,TomahawkDocumentationComponents)不可选的事实在视觉上并不明显;它们看起来像第三级菜单,但行为不同。MyFaces 菜单可以通过使不可选择菜单项更明显来得到改进。例如,参见图 8-3 中显示的菜单。

图 8-3:具有明显不可选择项的菜单

对话框

我们关于对话框的建议很简单:避免使用它们。对话框会中断进程,因为用户必须点击按钮关闭对话框才能继续操作。艾伦·库珀反对使用对话框,因为它们会打断用户体验的流程,并且不会让用户更接近他们的目标(艾伦·库珀和罗伯特·莱曼著,《About Face 2.0:用户交互设计要素》, Wiley,2003 年)。

对话框的替代方案是将信息性消息放入网页本身。我们将在下一节中演示这一点。

错误信息

良好的错误信息可以极大地提高您网页用户界面的可用性。专家们普遍同意以下指导原则:

  • 如果可能,使程序更智能,以避免特定的错误条件或从中恢复。

  • 如果检测到不可恢复的错误,应提供明确的错误信息——也就是说,不要抑制错误。

  • 错误信息应该是人类可读的。

  • 错误信息应该详细。

  • 错误信息应该建议如何解决问题。

  • 错误信息应该接近有错误的字段。

  • 有错误的字段应清楚地标识。

一些错误条件是由用户引起的(当用户在网页表单中输入错误值时),而另一些是由外部事件引起的(当设备磁盘变满时)。在设计你的设备网页时,考虑这些不同错误将如何被处理。

展示错误信息的一种方式是使用对话框(见图 8-4),但正如我们之前提到的,我们不建议这种方法。第二种方法是将在刷新的网页中插入错误信息(见图 8-5)。这种方法的一个显著特点是错误信息以表单字段的形式显示,这样用户可以立即重新输入他们的数据。

图片

图 8-4:错误对话框

图片

图 8-5:行内错误信息

第三种方法是在发生错误的地方注释标签,如图 8-6 所示。在这个例子中,通过显示标签为另一种颜色来显示错误。在这个图中,所有的字段标签都是黑色,除了Lan IPControl IP,它们是红色的(它们在这里被圈出,因为它们看起来是灰色的);这告诉你这些字段有问题。这种方法的一个问题是它未能提供详细的错误信息。虽然一些系统提供了带有信息的工具提示,但这样的机制通常不够明确,用户必须将鼠标悬停在标签上才能看到更多细节,这会让用户做不必要的操作。

图片

图 8-6:带注释的错误信息

使用 Ajax 提高响应性

Ajax (异步 JavaScript 和 XML) 是一套技术,它使得网页的部分更新成为可能。由于只有网页的一部分被刷新,更新速度比整个网页刷新要快得多。此外,部分更新可能由用户事件(如鼠标点击和按键)触发。这种行为使得用户界面比传统网页更加响应。

例如,Gmail,谷歌的电子邮件服务,使用了 Ajax。当你撰写电子邮件并开始输入联系人的名字时,浏览器会对每个按键做出响应,随着你输入,匹配的列表会减少。这种响应性令人印象深刻。

图 8-7 展示了 Ajax 通信机制的工作示例。图中的交换是在用户在网页上的活动元素上鼠标悬停时(事件图片)开始的。当事件图片发生时,会触发 onmouseover 动作,并在浏览器中执行 JavaScript 代码。JavaScript 代码创建了一个带有服务器端脚本 URL 和 JavaScript 回调函数的 XMLHttpRequest 对象,然后浏览器在事件图片中将 XMLHttpRequest 对象发送到服务器。在服务器端,由 URL 指定的特定脚本在事件图片中以 XML 数据的形式做出响应。

图片

图 8-7:典型的 Ajax 序列。

注意

XML 的格式由客户端和服务器所熟知,因此当服务器发送 XML 数据时,客户端能够理解其格式。通常,XML 数据将包含要在浏览器网页的某些部分显示的更新信息。

在客户端,网页浏览器接收 XML 数据并调用 JavaScript 回调函数。此回调函数从 XML 消息中提取数据,并使用 XML 文档对象模型(DOM)API 在事件../images/5a.jpg中修改网页的某些部分。

鼠标移动并不是 Ajax 所支持的唯一事件,但它们是最受欢迎的,包括鼠标按钮点击、按键、文本选择和可编辑字段上的键盘焦点——并且随着每个浏览器的升级,还有更多事件被提供。

实现

在本节中,我们将讨论 Laddie 网页用户界面的实现。我们将展示一些网页用户界面的截图并讨论其工作原理。

网页用户界面至少支持以下网页浏览器:Internet Explorer(版本 5.0 及以上)、Netscape Navigator(版本 4.72 及以上)、Firefox(版本 1.0 及以上)、Safari(版本 1.0 及以上)、Opera(版本 5.0 及以上)和 Lynx(版本 2.8.2 及以上)。这些版本是通过与从browsers.evolt.org获取的存档浏览器直接测试确定的。

注意

与其他图形浏览器不同,Lynx 浏览器是基于文本的。

与守护进程接口

Laddie 网页用户界面展示了几个运行中的守护进程的信息。正如你所知,这些守护进程使用 PostgreSQL 协议进行通信。在本节中,我们将讨论网页用户界面如何与 Laddie 警报守护进程 ladd 交互。一旦你理解了这种交互,你也会明白网页用户界面如何与其他守护进程交互。图 8-8 展示了用户请求网页并基于守护进程的状态动态生成网页的典型序列。

图表显示了运行在 Linux 设备上的带有 web 服务器和 ladd 警报守护进程的 Linux 设备。为了简单起见,我们展示了与 Apache 相同的方式,PHP 解释器在同一个进程中运行,但如果你使用 CGI 脚本,它可能作为不同的进程运行。

如前所述,该图显示了网页的典型请求-响应序列。首先,用户在事件 中请求特定的页面。web 服务器从文件系统中定位网页,因为 web 服务器在页面中发现了 PHP 标签,所以它调用了 PHP 解释器,该解释器解释 PHP 代码。在我们的例子中,特定的 PHP 代码包括 PHP 函数 pg_connect() 和 pg_exec(),这些函数在事件 中由 PHP 解释器调用。PHP 代码在事件 中生成网页,然后在新的事件 中将此新页面发送回浏览器。

图 8-8:与守护进程接口

连接到守护进程

如插图所示,在您能够读取和写入 ladd 守护进程之前,您必须使用 pg_connect() 函数建立连接,该函数在您使用 --with-pgsql 选项配置 PHP 时内置到 PHP 解释器中。pg_connect() 函数接受一个字符串参数,指定服务器的主机名(或 IP 地址)和端口号。在我们的例子中,服务器位于与 web 服务器相同的机器上,ladd 守护进程监听端口 8888。有关 pg_connect() 的更多信息,可以在 http://us2.php.net/pgsql 的 PHP 手册中通过搜索 PostgreSQL Functions 获取。

以下代码片段显示了如何打开到守护进程的连接:

从守护进程读取

一旦建立了连接,您就可以从 ladd 守护进程读取和写入。我们使用 pg_exec() 函数来完成这项操作。此函数请求执行给定的语句,在我们的例子中是一个 SELECT 语句。有关 pg_exec() 的更多详细信息,请参阅 PostgreSQL 函数文档 us2.php.net/pgsql

以下 PHP 代码片段显示了如何读取警报状态:

在此示例中,SELECT 命令中的名称 id、name、enabled 和 alarm 是 ladd 守护进程中的 Zone RTA 表的列名。通常,不同的守护进程的 SELECT 命令将具有相同的形式,但列数和名称可能不同。pg_exec() 函数返回的对象句柄,然后用于使用 pg_NumRows() 提取行数和 pg_result() 提取每行的内容。

注意

所有以 pg 开头的函数名都是 PostgreSQL PHP 库的一部分,并不特定于我们的守护进程。

一旦从守护进程读取了信息,您就可以使用这些信息生成一个 HTML 页面。例如,我们会使用 \(id、\)name、$enabled 和 $alarm 的结果来生成一个 HTML 表格。

写入守护进程

要写入 ladd 守护进程并将警报区域 3 设置为警报状态,可以使用以下代码:

注意,你使用与从守护进程读取信息时相同的 pg_exec() 函数调用;区别在于 SQL 命令是 UPDATE 而不是 SELECT。前面的代码片段中的 SQL 命令指定更新 Zone 表中的报警列到 $value 值,但仅当 id 列与 $id 匹配时。

在前面的代码片段中,我们已将 $value 和 $id 变量设置为任意值,但通常 $id 和 $value 变量会从 HTML 表单中提取。

网页用户界面和 ladd 守护进程之间的交互足够简单。网页用户界面可以从 ladd 守护进程读取信息,也可以向 ladd 守护进程写入信息。网页用户界面以相同的方式与其他守护进程交互,因此关于这些交互没有新的东西需要学习。(交互之所以简单,是因为我们使用了一个已建立的协议 PostgreSQL,并且该协议的函数绑定对 PHP 程序来说是现成的。)

报警状态页面

图 8-9 显示了 Laddie 的报警状态页面。此页面允许你查看每个报警区域的状态,清除报警状态,以及设置报警状态(用于测试目的)。

图片

图 8-9:Laddie 状态页面

报警状态可以显示为一个灰色水平条,或者在“状态”列下带有标签 报警。如果你实际使用这个界面,你可能会发现水平条比标签更容易阅读,因为它提供了一个快速的可视提示。在设计你的网页界面时,考虑一下你如何通过类似的视觉提示来快速传达信息。

与需要用户刷新页面来更新状态的传统网页不同,此状态页面在报警状态改变时自动更新。要观察这种自动更新行为,请启动两个浏览器并将它们指向报警状态页面。在一个浏览器中,通过点击清除和设置按钮来修改报警状态。如果你的两个浏览器都启用了 JavaScript,你应该在两个浏览器上看到页面更新。

你可以在本书的配套 CD 中的文件 /opt/laddie/htdocs/web/cgi-bin/status.php 中找到生成此网页的 PHP 代码。

报警设置页面

报警设置页面,如图 8-10 所示,允许你配置报警区域的名字。在设计这个页面时,我们考虑了两种用户界面设计方法:每个区域一个更新按钮和所有区域一个更新按钮。我们选择了单个按钮,因为它减少了配置所有区域所需的导航;你只需修改几个区域的参数并点击更新。

图片

图 8-10:Laddie 报警设置页面

此网页允许用户输入每个警报区域的名称。现在我们将描述网页的工作原理,特别是如何处理显示的表格数据。在浏览器端,网页包含一个 HTML 表单,这是一种从用户那里接受输入并在用户按下提交按钮时将其发送到 web 服务器的机制。如果您查看网页的 HTML 源代码,您将看到以下行:

<td> <input type=text name=Name_1 value="Garage Door" /> </td>

输入标签告诉浏览器显示一个用于文本输入的字段。名称标签告诉浏览器如何命名该字段,而值标签告诉浏览器如何为该字段填充初始值。当用户点击提交按钮时,所有表单字段的名称和值都会发送到服务器。当服务器接收到此请求时,我们的 PHP 代码将提取区域名称并更新 Laddie 守护进程。

PHP 提供了一种简单的机制来提取字段值。例如,要提取名为 Name_1 的字段的值,您将使用以下 PHP 代码:

$name = $_REQUEST["Name_1"];

_REQUEST 变量是一个由 PHP 解释器填充的全局变量,而 *Name_1* 字符串对应于 HTML 表单中字段的名称。一旦在服务器上执行此语句,$name 变量将包含用户在浏览器中输入的文本。

对于表格表单,我们需要注意字段命名,因为 HTML 规范要求表单中的所有字段都必须具有唯一的名称。在 HTML 中命名此类表单字段的一种常见方法是在列名称后附加行号。例如,我们将行号 1 附加到 Name(使用下划线作为分隔符)以获得区域 1 的名称列的 Name_1

生成此网页的 PHP 代码可在本书配套 CD 上的文件 /opt/laddie/htdocs/web/cgi-bin/setup_alarm.php 中找到。查看 displayZoneForm 函数。处理表单更新的 PHP 代码位于同一文件中。

页面布局和菜单系统

在本节中,我们将描述 Laddie 的网页布局和菜单系统。这个简单的方案由两个 PHP 文件处理。第一个文件,layout.php,将 Laddie 的两层菜单系统定义为二维数组(请参阅本书配套 CD 上的全局变量 $menu_system,位于 /opt/laddie/htdocs/web/cgi-bin/layout.php),并定义了 display_page() 函数。此函数在用户导航菜单时刷新页面。第二个文件,alarmstyle.css,控制颜色、字体和缩进(请参阅 CD 上的 /opt/laddie/htdocs/web/alarmstyle.css)。图 8-11 显示了一个示例网页;生成此网页的 PHP 代码如下。

图 8-11:Laddie 的 “Hello, world!” 示例

在调用 display_page()时,前两个参数是菜单结构(由 layout.php 中的全局变量$menu_system 定义)中的索引。在这个例子中,第一个参数"Setup"是顶级索引,而第二个参数是二级索引。第三个参数是一个 HTML 格式化的字符串,它将在主窗口中显示。在这个例子中,主窗口由标题 Hello, world!和两行组成。它是每个网页都不同的主窗口,通常这个内容是动态生成的,取决于系统的状态。

总结来说,页面布局的展示逻辑被封装在函数 display_page()中。关于如何使用 display_page()的另一个示例,请参阅 CD 上的/opt/laddie/htdocs/web/cgi-bin/help_contact_us.php。

Webserver Independence

PHP 与许多不同的 web 服务器协同工作,每个服务器与 PHP 解释器的交互方式略有不同。PHP 与 web 服务器交互的 API 称为 Server API。PHP 使用的 Server API 是在编译 PHP 时确定的,因此作为开发者,你可能在编写 PHP 脚本之前就已经知道了这一点。但如果你决定使用另一个 web 服务器呢?如果你没有提前规划,你可能需要修改大量代码才能使其与新 web 服务器兼容。

作为旁注,PHP 提供了 php_sapi_name()函数,可以编程地确定当前正在使用哪个 API。此函数返回许多可能的字符串之一,其中三个是apache, cgi,cgi-fcgi, 分别对应 Apache、CGI 和 FastCGI。关于 Server API 的文档并不多,但尝试在 Google 上搜索它。

在设计阶段初期,我们决定编写我们的 PHP 脚本,以便它们可以与这三个 Server API 一起工作,因为我们调查的 web 服务器至少支持其中一个。这意味着我们的 PHP 脚本可以在支持这些 API 的任何 web 服务器上无需修改即可运行。这种服务器无关的方法提供了两个优点:它避免了将你锁定在特定的 web 服务器上(如果出现更好的服务器),并且允许你使用与你的设备中部署的 web 服务器不同的 web 服务器来开发脚本。

脚本输入参数由名称-值字符串定义。例如,一个输入参数可能具有名称disp_id和值为 51。脚本输入参数由调用脚本的 HTTP 请求提供;例如,以下请求将为脚本 wait_for_status.php 设置输入参数disp_id

127.0.0.1/wait_for_status.php?disp_id=51

支持 Apache、CGI 和 FastCGI 的技巧在于,无论脚本在哪个环境中运行,都以相同的方式处理脚本输入参数。(输出没有问题,因为这三个 Server API 以相同的方式处理输出。)

对于 CGI 脚本,脚本输入参数是从 STDIN 中提取的,而对于 Apache 和 FastCGI 脚本,则是从 PHP 全局变量中提取的。实际上,Apache 和 FastCGI 的情况是相同的,所以只有两种情况,CGI 和 Apache。我们选择使用名为 read_params()的函数来抽象这两种情况。read_params()函数的实现处理了这两种情况的细节,但从调用者的角度来看,它提供了一种统一的方式来提取输入参数。

下面的 PHP 代码片段显示了如何使用该函数:

read_params()函数返回一个包含所有脚本输入参数的数组。调用脚本然后可以使用参数的名称(在设计时已知)检索特定的参数值。请注意,函数 array_key_exists 是 PHP 的一个内置函数,用于确定给定的索引是否存在于给定的数组中。

read_params()函数的实现可以在 CD 上的文件/opt/laddie/htdocs/web/cgi-bin/php_params.php 中找到。你还可以在/opt/laddie/htdocs/web/cgi-bin/setup_snmp.php 中找到其使用的另一个示例。

使用 Ajax 进行异步更新

考虑到图 8-9 所示的网页状态。网页应该如何响应警报状态的变化?

最好,网页应该自动更新,而不是要求用户反复点击浏览器的刷新按钮。一种方法是在固定频率下轮询服务器,例如,使用以下 Refresh HTML 元标签:

<META HTTP-EQUIV="Refresh" CONTENT="5;URL=refreshed-page.html">

另一种方法是使用 Ajax,这样网页只会在服务器状态发生变化时更新。Ajax 的缺点是它需要在网络浏览器中启用 JavaScript;如果用户禁用了 JavaScript,更新机制就会失效。另一方面,当使用 Ajax 时,网页会快速响应服务器状态的变化。

在第 117 页的“使用 Ajax 提高响应性”部分,我们描述了典型的 Ajax 交换是如何工作的。然而,请注意,这种典型的交换是由客户端发起的,而不是服务器。我们需要一种方法来修改 Ajax,以便浏览器能够响应服务器上的状态变化。

结果表明,我们可以修改 Ajax 交换,使系统表现得就像是由 web 服务器发起交换。这个技巧有两个方面。首先,将 onmouseover 事件替换为 onload 事件,这样一旦网页加载,就会立即发送 XMLHttpRequest。其次,编写 web 服务器脚本,使其在等待事件时阻塞。通过实现这种修改后的 Ajax 交换,每当服务器上发生特定事件时,网页都会更新。有经验的 Ajax 程序员会注意到还有另一种机制可以达到类似的效果,那就是在www.ajaxpatterns.org(以及相关的书籍《Ajax 设计模式》,作者 Michael Mahemoff,O'Reilly,2006)中记录的HTTP 流模式。我们的方法和 HTTP 流模式都有使用长连接的缺点,这可能会成为只允许有限并发连接的 web 服务器的难题。然而,对于我们的方法,我们可以控制请求等待服务器事件的时间,从而限制并发连接的数量。

在我们描述这个修改后的 Ajax 交换的细节之前,让我们先回顾一下整体情况。图 8-12 显示了用户请求新网页的序列,包括第一次完整页面更新和随后的部分页面更新。从时间上看,事件在用户请求特定网页后迅速连续发生。此时,网页加载了最新的警报状态。当在事件发生警报状态变化时,会迅速触发事件,此时网页会刷新为新的警报状态。这个后续序列会一直重复,直到用户离开网页。

浏览器发送第一个 HTTP 请求

从事件的序列是标准的 HTML 请求响应交换。在步骤中,用户请求一个网页,服务器通过发送网页进行响应,在步骤中,浏览器显示页面。这些步骤对所有网页请求都会执行,无论网页是否包含 JavaScript。从事件的剩余序列更有趣,我们将更详细地描述这个序列。

图 8-12:外部事件的 Ajax 序列

浏览器发送第二个 HTTP 请求

在客户端,当网页首次在事件加载时,会触发事件。特别是,会调用 GetCurrentStatus()函数。请通过启动本书的配套 CD 并使用浏览器访问 192.168.1.11 来查看 Zone Status 网页的 HTML 源代码。

Laddie 警报设备的默认 IP 地址是 192.168.1.11(默认子网掩码是 255.255.0.0)。当您将本书的 CD 插入计算机并重新启动时,您就可以通过在另一台计算机上的任何 Web 浏览器中键入 URL 192.168.1.11 来连接到 Web UI。如果默认 IP 地址与您的网络上的现有节点冲突,您可以使用以下步骤更改 Laddie 的 IP 地址。退出帧缓冲区界面(按 Q 退出),然后在 shell 提示符下,输入root作为用户并按 ENTER 键输入密码(没有密码)。然后输入lynx到命令提示符。在 lynx 中,您可以导航到网络设置页面并修改网络接口的 IP 地址。一旦您使用 lynx 更改了 IP 地址,您就可以在另一台计算机上的浏览器中重新输入 URL(使用您的新 IP 地址)。

如果您查看此网页的源代码,您将看到以下行:

GetCurrentStatus()函数随后使用 URL wait_for_status.php 发起一个 XMLHttpRequest(在事件 上)。您将在本书配套 CD 上的文件/opt/laddie/web/cgi-bin/status.php 中看到以下代码:

这段代码指示浏览器发送一个带有 URL wait_for_status.php 的 HTTP GET 请求。它还指示浏览器在收到来自服务器的响应时调用回调函数 GotStatus()。

服务器阻塞等待警报状态变化

在服务器端,脚本 wait_for_status.php 被调用。此脚本在事件 上对端口 4444 进行阻塞读取。每当在事件 上的 ladd 守护进程中的警报状态发生变化时,logmuxd 守护进程会将一条消息写入端口 4444,在事件 上。写入端口的实际内容并不重要;重要的是,这条消息会在事件 上解除 PHP 线程的阻塞。

在上面的代码中注意,curr_id 与 URL 一起发送。这个变量防止浏览器在事件来得太快时丢失日志事件。这个变量像令牌一样在服务器和浏览器之间传递,并且它与日志事件的数量同步增加。如果浏览器中的 curr_id 值与服务器上的日志事件数量不匹配,PHP 线程将跳过在端口 4444 上的阻塞。这样,如果在 PHP 线程不在端口 4444 上阻塞的时间间隔内有新的日志事件,PHP 线程将继续执行。

服务器以 XML 格式发送警报状态

一旦阻塞读取返回,PHP 脚本从 ladd 警报守护进程(在事件 上)读取警报状态,并将数据组合成一个 XML 文档(在事件 上)。然后,web 服务器将此 XML 文档传递给浏览器(在事件 上)。一个示例 XML 文档如下所示:

浏览器更新网页部分

在客户端,浏览器接收 XML 文档,并从中生成一个 HTML 片段;它使用这个片段来更新网页(事件图片)。status.php中的GotStatus()函数有以下代码。

图片

第一行从 XML 响应中提取区域数据,第二行生成专门用于 Laddie 状态页面的 HTML 片段,最后一行将 HTML 片段插入到显示的页面中。

浏览器通过发送另一个 HTTP 请求重复操作

然后,浏览器调用另一个 XMLHttpRequest,过程重复(在事件图片)。经过一小段时间延迟后,GotStatus()函数通过以下行调用GetCurrentStatus()

setTimeout("GetCurrentStatus()", 2000);

你可以通过打开两个网络浏览器到状态页192.168.1.11来查看这种行为是如何工作的。如果你在一个浏览器中改变一个报警区域的状态,你应该在另一个网络浏览器上看到这个状态的变化。

总结来说,我们展示了一种使用 Ajax 更新网页的技术,这里的更新是由服务器上的事件触发的,而不是客户端的事件。

没有 JavaScript 的优雅降级

当设计一个基于 Web 的设备时,你必须决定你将支持哪些网络浏览器。你是否支持像 Lynx 这样的非图形浏览器,还是你只支持功能齐全的浏览器?通过降低所需浏览器功能级别,你可以支持各种浏览器,但这是以增加开发工作量为代价的。在另一个极端,你可以规定使用特定的浏览器,这有利于使用专有功能,但存在一些客户可能不喜欢你的浏览器选择的风险。客户的反馈将非常有价值,可以帮助你做出这个决定。

我们选择支持广泛的浏览器,然后通过避免浏览器特定的代码来减少开发工作量。也就是说,我们避免了在所有主要浏览器上工作方式不同的代码。

特别值得关注的是支持没有 JavaScript 的浏览器的能力。JavaScript 的一个困难之处在于它可以被用户禁用,更糟糕的是,用户可能不知道它已经被禁用了。

支持有和没有 JavaScript 的浏览器的一种方法是将网站结构为两个“宇宙”——一个使用 JavaScript 的宇宙,另一个不使用。主页被编写成检测浏览器上是否启用了 JavaScript,然后重定向浏览器到相应的宇宙。不幸的是,如果用户禁用了 JavaScript 然后重新加载特定的页面,这个解决方案就不起作用了。解决方案是用户打开 JavaScript 然后重新访问主页。

我们采取了另一种方法,这种方法允许用户启用或禁用 JavaScript,然后简单地重新加载特定页面。这意味着每个网页都必须支持 JavaScript 版本和非 JavaScript 版本。在过去,这个问题可能很难解决,因为不支持 JavaScript 的浏览器会被 JavaScript 代码弄糊涂。但今天这个问题很容易解决,因为大多数浏览器(甚至那些不支持 JavaScript 的浏览器,如 Lynx)都理解 HTML 标签之间的所有 HTML 代码。

隐藏 JavaScript

隐藏 JavaScript 内容从浏览器中已知的工作模式适用于 Internet Explorer(版本 5.0 及以后)、Netscape Navigator(版本 4.72 及以后)、Firefox(版本 1.0 及以后)、Safari(版本 1.0 及以后)、Opera(版本 5.0 及以后)和 Lynx(版本 2.8.2 及以后)。

图片描述

HTML 注释被包括作为一个针对那些不理解

posted @ 2025-11-26 09:19  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报