在后台使用 Windows 服务和 .NET Remoting 管理服务器远程启动/关机






4.89/5 (5投票s)
通过多个客户端自动远程控制服务器的启动和关闭,以确保服务器仅在客户端活动时运行。使用 Wake On Lan、Windows 服务和 .NET Remoting。
目录
- 引言
- 架构
- 应用程序
- 主题
- 应用程序示意图
- 类 / 方法
- MessageService
- RemotelyDelegatableObject
- TextMessageEventArgs
- ServerRemoteControl
- MyServerCallbackClass
- ServerRemoteConfig
- ServerRemoteClientService
- RemoteClient
- ServerRemoteClientForm
- 安装
- 最后
引言
在家里,我使用服务器进行存储和媒体流传输,但设备 24/7 的功耗让我很烦恼。Wake On Lan 有点帮助,但我经常忘记关闭设备。我也尝试过电源管理,但效果不佳。
我需要为一个软件项目研究 .NET Remoting 架构,所以我开始了 LDM 这个小演示项目。目标是让服务器仅在客户端活动时运行,而无需用户交互——它必须通过“妻子测试” :-)
架构
每个客户端上都安装了 Windows 服务,在启动时唤醒服务器,然后发送心跳信号,直到客户端关闭。一个服务器应用程序也作为 Windows 服务运行,监听所有客户端心跳,并在特定时间内没有心跳时关闭服务器。
由于客户端服务甚至在用户登录客户端机器之前就启动(并唤醒服务器),因此服务器可以很快地使用。
为了轻松处理这一切,还有两个应用程序:一个客户端服务的用户界面可视化心跳并允许一些手动交互;一个单独的配置应用程序配置运行在机器上的客户端服务,如果已连接,还可以远程配置服务器。
应用程序
- ServerRemoteControl:运行在服务器机器上的 Windows 服务。
- ServerRemoteClientService:运行在客户端机器上的 Windows 服务。
- ServerRemoteClientForm:ServerRemoteClientService 的用户界面。
- ServerRemoteConfig:客户端和服务器应用程序的配置工具。
主题
- Remoting:不同应用程序之间的所有通信都使用可远程调用的对象解决。这既是客户端/服务器通信,也是 Windows 服务/UI 通信。
- Windows 服务:客户端和服务器应用程序作为 Windows 服务运行,无需登录机器即可运行它们。
- 应用程序主机 / 应用程序边界:使用 Remoting 技术在不同应用程序主机之间进行通信。
- Wake On LAN:客户端应用程序发送一个 WOL “魔术包”来唤醒服务器。感谢 maxburov 的代码示例。
- 注册表访问:配置参数和窗口位置存储在 Windows 注册表(HKEY_LOCAL_MACHINE\SOFTWARE\Torkelware\ServerRemote)中,Windows 服务在 NetworkService 账户下运行也能访问。
- 多线程 Windows 窗体:由于有单独的应用程序充当用户界面,我实现了一种基于消息的应用程序逻辑和用户界面之间的通信。
- 任务栏图标:客户端服务的用户界面最小化到系统托盘,仅在需要时才打开窗口。
应用程序示意图
类 / 方法
MessageService
消息服务是一个单例对象,提供可远程调用的方法,用于向其他应用程序发送消息和修改本地配置。可以通过 Remoting 订阅相应的事件来接收消息。甚至托管该对象的应用程序也通过 Remoting 访问其方法。
通过调用静态方法 CreateMessageServiceServer()
,MessageService
创建通信通道,将自身注册为单例对象,并返回通过 Remoting 使用 Activator.GetObject()
创建的实例。
public static MessageService CreateMessageServiceServer(MessageProtocol MsgProtocol,
int ServerPort,
string ServerUri,
string ApplicationName) {
MessageService Result;
// channels registrieren:
BinaryServerFormatterSinkProvider ServChSinkProvider =
new BinaryServerFormatterSinkProvider();
ServChSinkProvider.TypeFilterLevel = TypeFilterLevel.Full;
BinaryClientFormatterSinkProvider ClientChSinkProvider =
new BinaryClientFormatterSinkProvider();
if (MsgProtocol==MessageProtocol.http) {
HttpServerChannel ServCh = new HttpServerChannel(
"MessageServiceServerChannel", ServerPort, ServChSinkProvider);
ChannelServices.RegisterChannel(ServCh);
HttpClientChannel channel = new HttpClientChannel(
"MessageServiceClientChannel", ClientChSinkProvider);
ChannelServices.RegisterChannel(channel);
} else if ((MsgProtocol==MessageProtocol.tcp)) {
TcpServerChannel ServCh = new TcpServerChannel(
"MessageServiceServerChannel", ServerPort, ServChSinkProvider);
ChannelServices.RegisterChannel(ServCh);
TcpClientChannel channel = new TcpClientChannel(
"MessageServiceClientChannel", ClientChSinkProvider);
ChannelServices.RegisterChannel(channel);
} else {
throw new ApplicationException(
"Netzwerkprotokoll nicht implementiert: " +
MsgProtocol.ToString());
}
// Register remoting object:
Debug.WriteLine("Registriere Remote-Objekt: " +
MsgProtocol.ToString() + ":" +
ServerPort + "/" + ServerUri);
WellKnownServiceTypeEntry myservice = new WellKnownServiceTypeEntry(
typeof(MessageService),
ServerUri,
WellKnownObjectMode.Singleton);
RemotingConfiguration.RegisterWellKnownServiceType(myservice);
// Server bezieht das selbst gehostete messageobjekt via remoting:
string HostUrl = string.Format(@"{0}://{1}:{2}/{3}",
MsgProtocol.ToString(),"localhost",ServerPort.ToString(), ServerUri);
Debug.WriteLine("Verbinde Server zu MessageObject: " + HostUrl);
Result = (MessageService)Activator.GetObject(
typeof(MessageService),
HostUrl);
// testen, ob es sich bei dem messageobjekt um ein remotingobjekt handelt:
if (RemotingServices.IsTransparentProxy(Result)) {
Result.ApplicationName = ApplicationName;
Result.ApplicationHost = System.Environment.MachineName;
} else {
throw new ApplicationException(
"Fehler beim erstellen des Remoteobjekts.");
}
return Result;
}
方法 PublishCommand()
向 MessageService.TextMessageEventHandler
的所有订阅者发送类型为“Command
”的文本消息
public void PublishCommand(string Command, string SenderName) {
TextMessageEventArgs e = new TextMessageEventArgs(Command);
e.MessageType = TextMessageType.Command;
e.SenderName = SenderName;
this.MsgArrSync.Add(e);
if (TextMessageEventHandler != null) {
TextMessageEventHandler(this,e);
}
}
RemotelyDelegatableObject
为了能够从另一个应用程序,或者可能从不同的机器订阅 MessageService
事件,使用一个抽象类来派生回调类。通过让发送方和接收方基于相同的(抽象)类,我们消除了两个应用程序使用完全相同版本回调类的需要。
public abstract class RemotelyDelegatableObject : MarshalByRefObject {
public void TextMessageReceiver (object sender, TextMessageEventArgs e) {
InternalTextMessageReceiver (sender, e) ;
}
protected abstract void InternalTextMessageReceiver (
object sender, TextMessageEventArgs e) ;
}
TextMessageEventArgs
可以发送两种类型的消息:“Message
”和“Command
”,定义在 public enum TextMessageType {Message, Command}
中。这些消息由“TextMessageEventArgs
”类封装,该类标记为 [Serializable]
,并简单地包含一些带有适当公共属性的私有变量来访问它们。
ServerRemoteControl
服务器应用程序托管一个 MessageService
并订阅传入文本消息触发的事件。一个单独的线程运行一个倒计时,如果倒计时归零,则关闭服务器。倒计时可以通过通过 MessageService
传入的“心跳”命令重置为其初始值,其初始值可以通过“updateconfiguration”命令更改,例如。此外,它还提供了附加监视应用程序以监视倒计时和传入心跳的功能,就像 ServerRemoteConfig
所做的那样。
StartServerApp()
方法创建单例 MessageService
远程对象并订阅消息事件。
private void StartServerApp() {
// Remoteobjekte und Channels registrieren:
MyMessageService = MessageService.CreateMessageServiceServer(
MessageProtocol.http,
this.ServerPort,
"RemoteControl",
"Remote Control Server Application");
ServerCallback = new MyServerCallbackClass (this) ;
MyMessageService.TextMessageEventHandler +=
new TextMessageHandler(ServerCallback.TextMessageReceiver);
}
PerfLoop()
方法在单独的线程中运行,执行倒计时,如果达到零,则关闭服务器。
private void PerfLoop() {
while (true) {
lock (this) {
if (this.CountDown--==0) {
AppendToTextBox(System.DateTime.Now.ToString() + ": Shutdown!");
PerformServerShutdown();
}
}
UIMessage("SetControlValue", "lblCountDown" +
":" + CountDown.ToString());
PublishMonitorCommand(
"CountDown:" + CountDown.ToString(), Environment.MachineName);
Thread.Sleep(1000);
}
}
PerformServerShutdown()
只是创建一个新进程并执行配置的关闭命令。
public void PerformServerShutdown() {
Process myProcess = new Process();
myProcess.StartInfo.FileName = this.ShutDownCommand;
myProcess.StartInfo.Arguments = this.ShutDownParams;
myProcess.Start();
}
Server_ReceiveTextMessage()
方法执行命令,或者在连接用户界面时简单地显示文本消息。
public void Server_ReceiveTextMessage(object sender, TextMessageEventArgs e) {
if (e.MessageType==TextMessageType.Command) {
string[] MsgArr = e.Message.Split(":"[0]);
if (MsgArr[0].ToLower().Equals("heartbeat")) {
lock (this) {
this.CountDown = this.CountDownStartValue;
}
UIMessage("ShowHeartBeat", e.SenderName);
UIMessage("SetControlValue",
"lblCountDown:" + this.CountDown.ToString());
PublishMonitorCommand("HeartBeat", e.SenderName);
// ... scan for other commands...
}
} else {
Debug.WriteLine("Server: " +
e.MessageDate.ToString("yyMMdd-HH:mm:ss") + ": " + e.Message);
AppendToTextBox(e.MessageDate.ToString("yyMMdd-HH:mm:ss") + " " +
e.MessageType.ToString() + " von " +
e.SenderName + ": " + e.Message);
}
}
MyServerCallbackClass
如前所述,对于 RemotelyDelegatableObject
,在 ServerRemoteControl.StartServerApp()
中使用的回调类派生自抽象类 RemotelyDelegatableObject
。InternalTextMessageReceiver()
的实现现在包含此应用程序特有的代码。
class MyServerCallbackClass : RemotelyDelegatableObject {
private RemoteControlServer _RCServer;
private MyServerCallbackClass() {}
public MyServerCallbackClass (RemoteControlServer RCServer) {
this._RCServer = RCServer;
}
protected override void InternalTextMessageReceiver (
object sender, TextMessageEventArgs e) {
this._RCServer.Server_ReceiveTextMessage(sender, e);
}
public override object InitializeLifetimeService() {
return null;
}
}
ServerRemoteConfig
配置应用程序提供对本地机器注册表中配置参数的直接访问。配置好参数后,它能够连接到服务器,可视化传入的心跳及其倒计时状态,以及一个用于修改服务器配置(关机命令,倒计时起始值)的表单。为此,它还托管一个 MessageService
对象用于监视值。
在启动时调用 CreateServerMonitorMessageService()
,它创建一个 MessageService
用于监视值,并使用派生自 RemotelyDelegatableObject
的 MyMonitorCallbackClass
订阅其消息事件,如前所述。
private void CreateServerMonitorMessageService() {
string PortStr = MyConfig.GetValue(
ConfigKeyNames.Client_ServerPort.ToString());
int port = int.Parse(PortStr);
MyMonitorMessageService = MessageService.CreateMessageServiceServer(
MessageProtocol.http,
port,
"RemoteServerMonitor",
"Remote Control Server Monitor");
MonitorCallback = new MyMonitorCallbackClass (this);
MyMonitorMessageService.TextMessageEventHandler +=
new TextMessageHandler(MonitorCallback.TextMessageReceiver);
}
按下“连接”按钮后,将调用 btnConnectToServer_Click()
,它创建一个代理对象来调用服务器的 MessageService
并从服务器收集一些配置数据。然后,调用 StartServerMonitor()
会使服务器向此应用程序发送监视值。
private void btnConnectToServer_Click(object sender, System.EventArgs e) {
string Host = MyConfig.GetValue(ConfigKeyNames.Client_ServerHostName.ToString());
string Port = MyConfig.GetValue(ConfigKeyNames.Client_ServerPort.ToString());
string HostUrl = string.Format(
@"{0}://{1}:{2}/{3}",
MessageProtocol.http,
Host,
Port,
"RemoteControl");
Debug.WriteLine("Client verbindet zu Server: " + HostUrl);
try {
MyServerMessageService = (MessageService)Activator.GetObject(
typeof(MessageService),
HostUrl);
MyServerMessageService.PublishMessage("Config Client Verbunden",
System.Environment.MachineName);
btnConnectToServer.Enabled = false;
lblServerConnectionState.Text = "Verbunden mit " + Host + ":" + Port;
} catch (System.Net.WebException) {
btnConnectToServer.Enabled = true;
lblServerConnectionState.Text = "Verbindung zu " + Host + ":" +
Port + " konnte nicht hergestellt werden.";
return;
}
this.ServerConnected = true;
this.tbServerCountDownSeconds.Text = MyServerMessageService.GetConfigValue(
ConfigKeyNames.Server_CountDownSeconds.ToString());
this.tbServerCountDownSeconds.Enabled = true;
this.tbServerShutDownCommand.Text = MyServerMessageService.GetConfigValue(
ConfigKeyNames.Server_ShutDownCommand.ToString());
this.tbServerShutDownCommand.Enabled = true;
this.tbServerShutDownParams.Text = MyServerMessageService.GetConfigValue(
ConfigKeyNames.Server_ShutDownParams.ToString());
this.tbServerShutDownParams.Enabled = true;
StartServerMonitor();
}
StartServerMonitor()
方法向服务器发送一个“AttachMonitor”命令,包括机器名和端口,以便将监视值发送到该位置。
private void StartServerMonitor() {
string PortStr = MyConfig.GetValue(ConfigKeyNames.Client_ServerPort.ToString());
int port = int.Parse(PortStr);
MyServerMessageService.PublishCommand("AttachMonitor:" +
port.ToString(), Environment.MachineName);
}
ServerRemoteClientService
客户端应用程序托管一个单例 RemoteClient
对象,该对象提供唤醒服务器和向服务器发送心跳消息的所有功能。
在 InitRemoteClient()
方法中,创建客户端和服务器通道,RemoteClient
被注册为单例对象,并使用带有本地 URI 的 Activator.GetObject()
创建一个用于对 RemoteClient
进行远程调用的代理。
private void InitRemoteClient() {
string ErrorPos = "";
try {
ErrorPos = "Config-objekt erstellen.";
Config MyConfig = new Config(false);
ErrorPos = "Config-objekt erstellt, registry auslesen.";
string ClientServerPort_str = MyConfig.GetValue(
ConfigKeyNames.Client_ClientServerPort.ToString());
int ClientServerPort;
try {
ClientServerPort = int.Parse(ClientServerPort_str);
} catch {
throw new ApplicationException("Registryschlüssel [" +
ConfigKeyNames.Client_ClientServerPort.ToString() +
"] hat das falsche Format.");
}
string ClientServerUri = "RemoteControlClient";
ErrorPos = "Clientchannel registrieren.";
// client channel:
BinaryClientFormatterSinkProvider ClientChSinkProvider =
new BinaryClientFormatterSinkProvider();
HttpClientChannel channel = new HttpClientChannel(
"SRCS_ClientChannel", ClientChSinkProvider);
ChannelServices.RegisterChannel(channel);
ErrorPos = "Serverchannel registrieren.";
// server channel:
BinaryServerFormatterSinkProvider ServChSinkProvider =
new BinaryServerFormatterSinkProvider();
ServChSinkProvider.TypeFilterLevel = TypeFilterLevel.Full;
HttpServerChannel ServCh = new HttpServerChannel(
"SRCS_ServerChannel", ClientServerPort, ServChSinkProvider);
ChannelServices.RegisterChannel(ServCh);
ErrorPos = "Client-Remoteobjekt registrieren.";
// Clientobjekt registrieren
Debug.WriteLine("Registriere Remote-Objekt: http:" +
ClientServerPort + "/" + ClientServerUri);
WellKnownServiceTypeEntry myservice = new WellKnownServiceTypeEntry(
typeof(RemoteClient),
ClientServerUri,
WellKnownObjectMode.Singleton);
RemotingConfiguration.RegisterWellKnownServiceType(myservice);
ErrorPos = "Clientobjekt initialisiert Verbindung zu RemoteClient.";
// zu Client-Instanz connecten:
string HostUrl = string.Format(@"{0}://{1}:{2}/{3}", "http",
"localhost", ClientServerPort.ToString(), ClientServerUri);
Debug.WriteLine("Verbinde AppHost zu RemoteClient: " + HostUrl);
this._RCClient = (RemoteClient)Activator.GetObject(
typeof(RemoteClient),
HostUrl);
if (! RemotingServices.IsTransparentProxy(this._RCClient)) {
throw new ApplicationException("Fehler beim erstellen des Remoteobjekts.");
}
ErrorPos = "Clientobjekt verbindet zu RemoteClient.";
this._RCClient.TouchMe();
} catch (Exception ex) {
throw new ApplicationException(
"Fehler beim initialisieren der Clientumgebung:\r\n" +
ErrorPos + "\r\n" + ex.Message);
}
}
RemoteClient
RemoteClient
对象提供唤醒服务器和向服务器发送心跳消息的所有功能。要唤醒服务器,如果有唤醒命令,则会调用一个单独的应用程序;如果只提供服务器的 MAC 地址而唤醒命令为空,则会使用实现的 Wake On Lan 方法。
PerformServerWakeup()
方法检查是否应该使用外部应用程序,如果不是,则向服务器 MAC 地址发送一个 Wake On Lan “魔术包”。
public void PerformServerWakeup() {
if (this._ClientWakeupCommand.Length > 0) {
Process myProcess = new Process();
myProcess.StartInfo.FileName = this._ClientWakeupCommand;
myProcess.StartInfo.Arguments = this._ClientWakeupParams;
myProcess.StartInfo.CreateNoWindow = false;
myProcess.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
//myProcess.StartInfo.UseShellExecute = false;
myProcess.Start();
} else if (this._ClientWakeupParams.Length > 0) {
// code from maxburov (https://codeproject.org.cn/KB/IP/cswol.aspx)
string MAC_ADDRESS = this._ClientWakeupParams;
MAC_ADDRESS = MAC_ADDRESS.Replace("-","");
MAC_ADDRESS = MAC_ADDRESS.Replace(".","");
MAC_ADDRESS = MAC_ADDRESS.Replace(":","");
WOLClass client=new WOLClass();
client.Connect(new
IPAddress(0xffffffff), //255.255.255.255 i.e broadcast
0x2fff); // port=12287 let's use this one
client.SetClientToBrodcastMode();
int counter=0;
//buffer to be send
byte[] bytes=new byte[1024]; // more than enough :-)
//first 6 bytes should be 0xFF
for(int y=0;y<6;y++)
bytes[counter++]=0xFF;
//now repeate MAC 16 times
for(int y=0;y<16;y++) {
int i=0;
for(int z=0;z<6;z++) {
bytes[counter++]=
byte.Parse(MAC_ADDRESS.Substring(i,2),
NumberStyles.HexNumber);
i+=2;
}
}
//now send wake up packet
int reterned_value=client.Send(bytes,1024);
} else {
throw new ApplicationException("Server-Wakeup nicht möglich, " +
"Wakeup-Parameter nicht konfiguriert.");
}
}
HeartBeatLoop()
在一个单独的线程中运行,向服务器发送心跳消息。如果心跳失败,它还会通过调用 GetServerObject()
尝试重新连接。根据布尔值 _WakeupServer
和 _WakeupServerWhenLost
,它会在第一次心跳失败时或每次心跳失败后尝试唤醒服务器。这两个布尔值是通过读取注册表项 Client_WakeupMode
和 Client_WakeupServerWhenLost
初始化的,但当前版本中的配置工具无法更改它们。
private void HeartBeatLoop() {
while (true) {
if (!this._Pause) {
SendUIMessage("ShowHeartBeat", Environment.MachineName);
if (this.HeartBeat!=null) {
this.HeartBeat(this, new EventArgs());
}
if (this.ServerMessageService!=null) {
try {
this.ServerMessageService.PublishCommand(
"HeartBeat", System.Environment.MachineName);
// Kein Fehler: Verbindung ist hergestellt.
if (!this.Connected) { // Server war vorher nicht verbunden
this.Connected = true;
this._ServerConnectedCount++;
if (this.ConnectionStateChanged!=null) {
this.ConnectionStateChanged(this, new EventArgs());
}
SendUIMessage("EnableControl","btnShutDownServer:true");
SendUIMessage("EnableControl","btnWakeupServer:false");
this._WakeupServer = false;
}
} catch {
// Fehler: Nicht verbunden
if (this.Connected) { // Server war vorher verbunden
this.Connected = false;
if (this.ConnectionStateChanged!=null) {
this.ConnectionStateChanged(this, new EventArgs());
}
SendUIMessage("EnableControl","btnShutDownServer:false");
SendUIMessage("EnableControl","btnWakeupServer:true");
}
if (this._WakeupServer) { // Wakeup-modus
if (this._WakeupServerWhenLost |
(this._ServerConnectedCount==0)) {
PerformServerWakeup();
}
}
Debug.WriteLine("ReConnect...");
GetServerObject();
}
}
}
// ConnectionState in UI anzeigen:
ShowConnectionState();
CheckForChanges(); // Konfiguration checken
int SleepInterval = this._HeartBeatInterval*1000;
Thread.Sleep(SleepInterval);
}
}
ServerRemoteClientForm
这个 Windows 窗体连接到 ServerRemoteClientService
托管的 RemoteClient
,可视化心跳和连接状态,并提供按钮来手动启动/关闭服务器,停止发送心跳等等。最棘手的部分是 RemoteClientApplicationContext
类,它是应用程序的入口点。它只显示一个系统托盘图标,双击会打开主窗口。
安装
这是最无趣的部分。由于我还没有构建安装程序,所以第一个任务是运行 *ServerRemoteConfig.exe*。配置工具将在系统注册表中创建 *HKEY_LOCAL_MACHINE\SOFTWARE\Torkelware\ServerRemote* 并设置一些默认值。将“服务器名称”更改为您的服务器主机名。接下来,必须使用框架的 *installutil.exe* 工具安装服务器和客户端的相应 Windows 服务
- 服务器:*installutil /i ServerRemoteControl.exe*
- 客户端:*installutil /i ServerRemoteClientService.exe*
安装的服务现在应该在计算机管理/服务中列出,但尚未启动。服务需要访问注册表,因此 NetworkService 账户必须有此权限,或者服务必须在特权用户账户下运行。
- 设置 NetworkService 的权限
- 选择其他用户账户
使用 regedit,转到 *HKEY_LOCAL_MACHINE\SOFTWARE*。在“编辑”菜单上,单击“权限”,并为 *NetworkService* 账户分配读取权限。然后,转到 *\Torkelware* 子项,并为 *NetworkService* 账户分配完全访问权限。
在计算机管理/服务中,转到服务的“属性”对话框,并将账户从 *NetworkService* 更改为特权账户。
现在,您可以使用计算机管理/服务启动服务。如果服务立即停止,请查看事件日志。*ServerRemoteClientForm.exe* 和 *ServerRemoteConfig.exe* 可以直接启动。启动 ServerRemoteClientForm
后别忘了托盘图标 :-) 。如果您在安装客户端时遇到问题,请停止服务器上的服务,否则它将在 10 分钟后(或您配置的倒计时时间)关闭。或者,您可以使用 *ServerRemoteConfig* 来重置倒计时值。如果一切正常,返回计算机管理/服务以配置服务自动启动。在客户端计算机上,您可以将 *ServerRemoteClientForm.exe* 的引用放入自动启动文件夹。
最后
ServerRemoteClientService 和 ServerRemoteControl 都设计为作为 Windows 服务运行,但它们都有第二个入口点,允许它们作为普通可执行文件运行。这在开发过程中很有用,只需更改项目首选项中的入口点即可。错误消息和一些注释仍然是德语,对此很抱歉。关于我的英语,总的来说:请不要太苛刻 :-) 目前,只支持 Windows 系统。我曾考虑从服务器发送 ping,如果没有人响应就关闭,但有些流媒体客户端即使在关闭时也会响应 ping……如果对这个项目有兴趣,我会考虑的。欢迎提出建议。
现在,我因摩托车事故后遗症而度过的闲暇时光结束了,所以这篇文章也将结束。祝您玩我的代码愉快 :-)
霍尔格!