65.9K
CodeProject 正在变化。 阅读更多。
Home

IP Watchdog: 用 C# 编写的简单 Windows 服务

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.71/5 (50投票s)

2012年7月11日

CPOL

9分钟阅读

viewsIcon

120896

downloadIcon

5311

自安装的 Windows 服务,用于监视计算机的 WAN 地址并在其更改时发送电子邮件。

背景

曾几何时,我需要编写一个 Windows 服务来监控我电脑的 WAN IP 并通知我其变化。我发现这是一个“正确地完成”这项任务的绝佳机会:这项任务的复杂性足以实用,但又足够简单,因此我不仅可以专注于问题本身,还可以专注于日志记录和安装等周边样板问题。IP Watchdog 服务就是这样诞生的。

具有实用价值的示例

这项服务解决的任务非常真实。它在我的家用服务器上运行,并在其外部 IP 每次更改时给我发送电子邮件。除了这个主要任务之外,我还设定了以下附加目标:

  • 服务必须自安装,无需运行 installutil
  • 服务必须能够在控制台模式下运行,将输出记录到控制台窗口。
  • 在 Windows 服务模式下,服务必须写入 Windows 事件日志。
  • 必须能够使用服务可执行文件本身启动和停止服务。
  • 服务代码必须作为良好编码实践的示例,并作为我编写的其他服务的“模板”。
  • 开发时我将使用 Git 源代码管理。

源代码

源代码(15K ZIP 存档)可在此处获取:IpWatchdog.zip (15K)。

包含代码的 Git 仓库:https://github.com/ikriv/IpWatchDog

为什么选择 IP Watchdog?

我的家庭网络通过有线连接到互联网,我的有线调制解调器具有“几乎静态”的 IP,每年可能只更改两三次。当然,我希望这个 IP 映射到一个友好的名称,比如 home.ikriv.com。我过去尝试使用 dyndns.org 服务,当时它是免费的,但在 30 天没有 IP 更改通知后,它会把我踢出去。由于 IP 更改如此罕见,我认为没有必要为静态 IP 支付额外的费用。但是,它确实会不时更改,然后我就无法访问我的家,直到我亲自到那里,检查新的 IP 是什么,并更改我的域的 DNS 设置。

Typical home network

又一次中断后,我决定受够了。我需要一个代理来监控我的家庭 IP,并在发生更改时给我发送电子邮件通知。我可以在我的手机上接收电子邮件,然后从我当时所在的地方访问我的域 DNS。

当然,我可以尝试找到一个现有的服务,但这项任务听起来很有趣,所以我花了一天时间编写它。我还想刷新我的服务编写技能,并正确地完成它,包括安装程序代码、事件日志通知、控制台模式等。所以,从某种意义上说,这项服务充当了“如何不使用 InstallUtil 安装服务”等样板任务的备忘单。如果我需要编写另一个服务,我就不必重新发明轮子了。

主循环

服务的核心任务非常简单:

  1. 通过从 checkip.dyndns.org 读取来检查当前外部 IP。
  2. 与 IP 的先前值进行比较。
  3. 如果不同,则发送通知电子邮件。
  4. 等待一段时间并重复。

遵循“意图编程”范式的这个循环的代码非常短:

void CheckIp()
{
    var newIp = _retriever.GetIp();

    if (newIp == null) return;
    if (newIp == _currentIp) return;
 
    if (_currentIp == null)
    {
        _log.Write(LogLevel.Info, "Currrent IP is {0}", newIp);
    }
    else
    {
        _notifier.OnIpChanged(_currentIp, newIp);
    }

    _currentIp = newIp;
    _persistor.SaveIp(_currentIp);
}

此代码使用一些依赖项:_retriever 是负责从网络读取 IP 的类,_notifier 发送电子邮件通知,_persistor 在服务调用之间保存 IP 值。

运行循环

上面的代码会定期在计时器上运行。启动计时器很简单:

_timer = new Timer(CheckIp, null, 0, _config.PollingTimeoutSeconds*1000);

停止服务意味着停止计时器。如果 IP 检查正在进行中,它会获取 _isBusy 对象的锁。停止代码尝试使用 .NET 监视器获取此锁:这(据称)比全面的互斥锁更有效。如果无法在 5 秒内获取锁,我们假定 IP 检查过程卡住并退出,不再等待。

void Stop()
{
    _log.Write(LogLevel.Info, "IP Watchdog service is stopping");
    _timer.Dispose();
    _timer = null;
    _stopRequested = true;
    if (!Monitor.TryEnter(_isBusy, 5000))
    {
        _log.Write(LogLevel.Warning, "IP checking process is still running and will be forcefully terminated");
    }
 
    _stopRequested = false;
}

void CheckIp(object unused)
{
    lock (_isBusy)
    {
        if (_stopRequested) return;
        CheckIp();
    }
}

检查 WAN IP 地址

除非您的服务器直接连接到互联网,否则其 WAN IP 地址与其本地 IP 地址不同。下面是家庭网络的典型结构。对于在服务器上运行的软件,没有直接的方法来查明其 WAN IP 地址(上图中的 99.11.22.33)。它所能知道的只是本地 IP 地址(192.168.1.3)。查明 WAN 地址的唯一可靠方法是向外部某人发送请求并询问它来自哪里。

幸运的是,像 checkip.dyndns.org 这样的网站提供了这样的服务,.NET 使发送和接收 HTTP 请求变得非常容易。负责检索我们 IP 地址的类称为 WebIpRetriever。我们向 checkip.dyndns.org 发送 HTTP GET 请求,它会回复一个非常小的 HTML 页面:

<html><head><title>Current IP Check</title></head><body>Current IP Address: 99.11.22.33</body></html>

从那里我们可以通过简单的字符串操作提取 IP 地址。Web IP 检索的当前实现是同步的,即它会阻塞计时器回调,直到答案到来。更好地实现它是异步的,但那样的话,答案读取和服务停止逻辑会变得有些复杂,所以我放弃了。

public string GetIp()
{
    try
    {
        var request = HttpWebRequest.Create("http://checkip.dyndns.org/");
        request.Method = "GET";
                
        var response = request.GetResponse();
 
        using (var reader = new StreamReader(response.GetResponseStream()))
        {
            var answer = reader.ReadToEnd(); // should have better handling here for very long responses
            return ExtractIp(answer);
        }
    }
    catch (Exception ex)
    {
        _log.Write(LogLevel.Warning, "Could not retrieve current IP from web. {0}", ex);
        return null;
    }
}

发送通知电子邮件

发送电子邮件是 MailIpNotifier 类的职责。.NET 提供了发送电子邮件的优秀功能,所以代码很简单:

public void OnIpChanged(string oldIp, string newIp)
{
    string msg = GetMessage(oldIp, newIp);
    _log.Write(LogLevel.Warning, msg);
 
    try
    {
        var smtpClient = new SmtpClient(_config.SmtpHost);
        smtpClient.Send(
          _config.MailFrom,
          _config.MailTo,
          "IP change",
          msg);
    }
    catch (Exception ex)
    {
        _log.Write(LogLevel.Error, "Error sending e-mail. {0}", ex);
    }
}
 
private static string GetMessage(string oldIp, string newIp)
{
    return String.Format("IP changed from {0} to {1}", oldIp, newIp);
}

控制台模式与服务模式

直接调试 Windows 服务很困难,因为它们通常由系统调用并在特殊的系统帐户下运行。因此,我们需要一种方法让我们的服务作为常规应用程序运行。我们通过将代码分为三个部分来实现这一点:

  • 实现 IService 接口且与运行方式无关的服务逻辑。
  • 作为 Windows 服务运行逻辑的 ServiceRunner 类。
  • 作为控制台应用程序运行逻辑的 ConsoleRunner 类。

服务模式默认调用,控制台模式通过 -c 命令行开关调用。如果控制台模式是默认的会更好,但这需要向服务传递命令行参数。这是可能的,但如果您使用框架提供的标准服务安装程序,这将很麻烦。

ServiceRunner and ConsoleRunner class diagram

控制台运行器运行服务并等待按下 Ctrl+C 组合键。服务运行器继承自 ServiceBase,并通过分别调用服务的 Start()Stop() 方法来实现 OnStart()OnStop() 方法。

我们还需要针对控制台和服务模式的不同日志记录机制。我们使用 ILog 接口抽象日志记录,并且该接口有两个实现。ConsoleLog 将输出直接写入控制台,而 SystemLog 写入“事件查看器”应用程序显示的“应用程序”事件日志。

ConsoleLog and SystemLog

安装服务

当您创建一个服务项目时,Visual Studio 会扔进一个“服务”组件,然后通过右键单击它,您可以添加一个安装程序类。我不喜欢这些生成的类,原因有几个:

  • 我不太喜欢用于服务这种代码导向的图形设计器。
  • 我不喜欢像 ProjectInstaller1 这样的名称,而且重命名它们很麻烦。
  • 您应该使用 installutil.exe 来运行安装程序。这对用户来说很难看且很困难。

鉴于所有这些,我编写了自己的 InstallUtil 实现,基于 这个示例。它主要处理调用 AssemblyInstaller 类并围绕它提供错误处理。

我还创建了一个类似于向导为您创建的 ProjectInstaller 类,它调用 ServiceProcessInstallerServiceInstaller。请注意,我的服务在 NetworkService 帐户下运行,因为它只关心网络。此外,看起来您不需要为事件日志事件源提供特殊的安装程序,ServiceInstaller 会为您创建事件源。

服务在通过 -i 命令行开关调用时自行安装,在通过 -u 开关调用时自行卸载。

启动和停止服务

在 Windows 中有许多方法可以启动和停止服务,例如“net start”命令,但如果您可以使用服务可执行文件本身启动和停止服务,那就太好了。幸运的是,在 .NET 中实现这一点只需要几行代码:

_log.Write(LogLevel.Info, "Starting service...");
const int timeout = 10000;
_controller.Start();

var targetStatus = ServiceControllerStatus.Running;
_controller.WaitForStatus(targetStatus, TimeSpan.FromMilliseconds(timeout));

应用程序参数

由于轮询间隔、通知电子邮件和 SMTP 服务器地址等应用程序参数不经常更改,我将它们放在 app.config 文件中。您需要在第一次运行服务之前修改该文件。AppConfig 类封装了对文件的访问。

依赖注入和配置器类

正如您已经看到的,我们的应用程序需要适应不同的环境。特别是,它可能使用控制台运行器或服务运行器,同时写入控制台日志或系统日志。我们通过遵循以下原则来实现这种灵活性:

  • 单一职责原则。
  • 面向接口编程。
  • 依赖注入。

单一职责意味着每个类只负责一件事。如果您在描述类职责时不得不使用“和”这个词,这是一个坏兆头。单一职责会导致小而精的类,这些类易于重用并且可以以各种方式组合。这与非常成功的 UNIX 工具范式背后的原则相同:每个工具只做一件事,但做得很好。

每当我们有同一概念的多个实现时,例如在日志的情况下,我们必须有一个接口。有时即使只有一个实现,拥有一个接口也很有益,例如为了使契约明确并摆脱不需要的依赖项。例如,我可以让 ConsoleRunner 直接依赖于 IpWatchDogService,但这没有多大意义,因为运行器并不真正关心它运行的是哪种服务。

依赖注入是这样一种原则:一个类不创建自己的依赖项,而是从外部接收它们。因此,程序分为两个不均等的部分:代码和决定不同部分如何组合的“装配线”。在一个更大的项目中,这种装配线功能通常由 Spring.Net 或 Unity 等特殊库实现。在这个小型项目中,装配线职责赋予了 Configurator 类:

private IService CreateWatchDogService()
{
    var config = new AppConfig();

    return new IpWatchDogService(
        _log, 
        config,
        new IpPersistor(_log), 
        new WebIpRetriever(_log), 
        new MailIpNotifier(_log, config));
}

依赖注入是一种强大的机制,它尤其能确保类的灵活性和可重用性。例如,如果我们想通过 Twitter 发送 IP 更改通知,我们所需要做的就是编写 Twitter 通知程序类并在创建时将其提供给 IpWatchDogService。因此,唯一需要修改的类是 Configurator。这是所有 IpWatchDogService 依赖项的图表:

IP Watchdog service dependencies

命令行开关摘要

ipwatchdog -? 打印所有可用开关的摘要。目前支持的开关有:

短格式长格式含义
-c-console在控制台模式下运行
-i-install安装服务
-p-stop停止运行服务
-s-start启动已安装的服务
-u-uninstall卸载服务

结论

我希望您喜欢阅读 IP Watchdog,就像我喜欢编写它一样。我也希望它能让您摆脱编写样板代码的烦恼,让您专注于手头的任务。请随意借用代码并根据您的需要使用它(有关详细信息,请参阅“许可”部分)。

© . All rights reserved.