简体   繁体   English

ServiceControlHandler 用于 USB 设备通知,OnStop() 不可实现

[英]ServiceControlHandler used for usb device notifications, OnStop() not implementable

I'd like to write a service that listens for device notifications (USB media plugged in, removed).我想编写一个服务来监听设备通知(USB 媒体插入、移除)。 The problem of listening for device notifications in a C# service is, that System.Windows.Forms.Control.WndProc<\/code> isn't available because a windows service doesn't have any windows.在 C# 服务中侦听设备通知的问题是System.Windows.Forms.Control.WndProc<\/code>不可用,因为 Windows 服务没有任何窗口。
I found this HowTo on how to write such a service<\/a> .在如何编写这样的服务上找到了这个 HowTo<\/a> 。 The author of that article found a workaround which lets the service listen for device notifications instead of service control messages and therefore, the service doesn't support OnStop() anymore.那篇文章的作者找到了一种解决方法,它可以让服务侦听设备通知而不是服务控制消息,因此,该服务不再支持 OnStop()。

(Update 26.01.13:)<\/em> (更新 26.01.13:)<\/em>
Sadly, I don't really understand the service control manager and the windows API.可悲的是,我并不真正了解服务控制管理器和 Windows API。 I'm wondering if it is possible to register to both, service control messages AND usb device notifications or if this is really the only option for a service to listen to device notifications.我想知道是否可以同时注册服务控制消息和 USB 设备通知,或者这是否真的是服务收听设备通知的唯一选择。 I haven't yet found any (understandable for me) information which solves my problem.我还没有找到任何(我可以理解的)信息来解决我的问题。
Might it be possible to use the System.Windows.Forms.Control.WndProc<\/code> without generating windows (I'd just have to add the System.Windows.Forms assembly, right?).是否可以在不生成窗口的情况下使用System.Windows.Forms.Control.WndProc<\/code> (我只需要添加 System.Windows.Forms 程序集,对吗?)。

(Update 27.01.13:)<\/em> (更新 27.01.13:)<\/em>
I just found this question:
Cannot start desktop application from Windows service on Windows 7<\/a>我刚刚发现了这个问题: Cannot start desktop application from Windows service on Windows 7<\/a>
The second
answer<\/a> there says, that Windows services received a security-centric makeover in Windows Vista and GUI elements are now created in Session 0 even if the "Allow service to interact with desktop" is checked.那里的第二个答案<\/a>说,Windows 服务在 Windows Vista 中接受了以安全为中心的改造,并且现在在会话 0 中创建 GUI 元素,即使选中“允许服务与桌面交互”也是如此。 Does that mean, that I CAN create a Windows Form which then receives the USB device events (and therefore, I don't need to mess with the ServiceControlHandler<\/code> ? Are there any caveats or problems doing this?这是否意味着,我可以创建一个 Windows 窗体,然后接收 USB 设备事件(因此,我不需要弄乱ServiceControlHandler<\/code> ?这样做有什么警告或问题吗?

In short, I need a solution that does one of the following:<\/strong>简而言之,我需要一个执行以下操作之一的解决方案:<\/strong>

  1. Make OnStop available again, or<\/strong>使 OnStop 再次可用,或<\/strong><\/li>
  2. provide another method of listening for usb device notifications in a Windows C# service<\/strong>在 Windows C# 服务中提供另一种侦听 USB 设备通知的方法<\/strong><\/li><\/ol>

    My source code is currently the following.我的源代码目前如下。 It is almost identical to the source code offered by the HowTo I linked in the first paragraph.它与我在第一段中链接的 HowTo 提供的源代码几乎相同。 The only difference I made is removing the FileSystemWatcher private field and all usages of the same because I don't need the FileSystemWatcher.我所做的唯一区别是删除 FileSystemWatcher 私有字段及其所有用法,因为我不需要 FileSystemWatcher。

    USBBackup.cs (the service itself - using statements excluded but complete in my source code): USBBackup.cs(服务本身 - 使用排除在我的源代码中但完整的语句):

     namespace USBBackup { public partial class USBBackup : ServiceBase { private IntPtr deviceNotifyHandle; private IntPtr deviceEventHandle; private IntPtr directoryHandle; private Win32.ServiceControlHandlerEx myCallback; private int ServiceControlHandler(int control, int eventType, IntPtr eventData, IntPtr context) { if (control == Win32.SERVICE_CONTROL_STOP || control == Win32.SERVICE_CONTROL_SHUTDOWN) { UnregisterHandles(); Win32.UnregisterDeviceNotification(deviceEventHandle); base.Stop(); } else if (control == Win32.SERVICE_CONTROL_DEVICEEVENT) { switch (eventType) { case Win32.DBT_DEVICEARRIVAL: Win32.DEV_BROADCAST_HDR hdr; hdr = (Win32.DEV_BROADCAST_HDR)Marshal.PtrToStructure(eventData, typeof(Win32.DEV_BROADCAST_HDR)); if (hdr.dbcc_devicetype == Win32.DBT_DEVTYP_DEVICEINTERFACE) { Win32.DEV_BROADCAST_DEVICEINTERFACE deviceInterface; deviceInterface = (Win32.DEV_BROADCAST_DEVICEINTERFACE)Marshal.PtrToStructure(eventData, typeof(Win32.DEV_BROADCAST_DEVICEINTERFACE)); string name = new string(deviceInterface.dbcc_name); name = name.Substring(0, name.IndexOf('\\0')) + "\\\\"; StringBuilder stringBuilder = new StringBuilder(); Win32.GetVolumeNameForVolumeMountPoint(name, stringBuilder, 100); uint stringReturnLength = 0; string driveLetter = ""; Win32.GetVolumePathNamesForVolumeNameW(stringBuilder.ToString(), driveLetter, (uint)driveLetter.Length, ref stringReturnLength); if (stringReturnLength == 0) { \/\/ TODO handle error } driveLetter = new string(new char[stringReturnLength]); if (!Win32.GetVolumePathNamesForVolumeNameW(stringBuilder.ToString(), driveLetter, stringReturnLength, ref stringReturnLength)) { \/\/ TODO handle error } RegisterForHandle(driveLetter[0]); } break; case Win32.DBT_DEVICEQUERYREMOVE: UnregisterHandles(); break; } } return 0; } private void UnregisterHandles() { if (directoryHandle != IntPtr.Zero) { Win32.CloseHandle(directoryHandle); directoryHandle = IntPtr.Zero; } if (deviceNotifyHandle != IntPtr.Zero) { Win32.UnregisterDeviceNotification(deviceNotifyHandle); deviceNotifyHandle = IntPtr.Zero; } } private void RegisterForHandle(char c) { Win32.DEV_BROADCAST_HANDLE deviceHandle = new Win32.DEV_BROADCAST_HANDLE(); int size = Marshal.SizeOf(deviceHandle); deviceHandle.dbch_size = size; deviceHandle.dbch_devicetype = Win32.DBT_DEVTYP_HANDLE; directoryHandle = CreateFileHandle(c + ":\\\\"); deviceHandle.dbch_handle = directoryHandle; IntPtr buffer = Marshal.AllocHGlobal(size); Marshal.StructureToPtr(deviceHandle, buffer, true); deviceNotifyHandle = Win32.RegisterDeviceNotification(this.ServiceHandle, buffer, Win32.DEVICE_NOTIFY_SERVICE_HANDLE); if (deviceNotifyHandle == IntPtr.Zero) { \/\/ TODO handle error } } private void RegisterDeviceNotification() { myCallback = new Win32.ServiceControlHandlerEx(ServiceControlHandler); Win32.RegisterServiceCtrlHandlerEx(this.ServiceName, myCallback, IntPtr.Zero); if (this.ServiceHandle == IntPtr.Zero) { \/\/ TODO handle error } Win32.DEV_BROADCAST_DEVICEINTERFACE deviceInterface = new Win32.DEV_BROADCAST_DEVICEINTERFACE(); int size = Marshal.SizeOf(deviceInterface); deviceInterface.dbcc_size = size; deviceInterface.dbcc_devicetype = Win32.DBT_DEVTYP_DEVICEINTERFACE; IntPtr buffer = default(IntPtr); buffer = Marshal.AllocHGlobal(size); Marshal.StructureToPtr(deviceInterface, buffer, true); deviceEventHandle = Win32.RegisterDeviceNotification(this.ServiceHandle, buffer, Win32.DEVICE_NOTIFY_SERVICE_HANDLE | Win32.DEVICE_NOTIFY_ALL_INTERFACE_CLASSES); if (deviceEventHandle == IntPtr.Zero) { \/\/ TODO handle error } } public USBBackup() { InitializeComponent(); } public static IntPtr CreateFileHandle(string driveLetter) { \/\/ open the existing file for reading IntPtr handle = Win32.CreateFile( driveLetter, Win32.GENERIC_READ, Win32.FILE_SHARE_READ | Win32.FILE_SHARE_WRITE, 0, Win32.OPEN_EXISTING, Win32.FILE_FLAG_BACKUP_SEMANTICS | Win32.FILE_ATTRIBUTE_NORMAL, 0); if (handle == Win32.INVALID_HANDLE_VALUE) { return IntPtr.Zero; } else { return handle; } } protected override void OnStart(string[] args) { base.OnStart(args); RegisterDeviceNotification(); } } }<\/code><\/pre>

    Win32.cs: Win32.cs:

     namespace USBBackup { public class Win32 { public const int DEVICE_NOTIFY_SERVICE_HANDLE = 1; public const int DEVICE_NOTIFY_ALL_INTERFACE_CLASSES = 4; public const int SERVICE_CONTROL_STOP = 1; public const int SERVICE_CONTROL_DEVICEEVENT = 11; public const int SERVICE_CONTROL_SHUTDOWN = 5; public const uint GENERIC_READ = 0x80000000; public const uint OPEN_EXISTING = 3; public const uint FILE_SHARE_READ = 1; public const uint FILE_SHARE_WRITE = 2; public const uint FILE_SHARE_DELETE = 4; public const uint FILE_ATTRIBUTE_NORMAL = 128; public const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; public static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1); public const int DBT_DEVTYP_DEVICEINTERFACE = 5; public const int DBT_DEVTYP_HANDLE = 6; public const int DBT_DEVICEARRIVAL = 0x8000; public const int DBT_DEVICEQUERYREMOVE = 0x8001; public const int DBT_DEVICEREMOVECOMPLETE = 0x8004; public const int WM_DEVICECHANGE = 0x219; public delegate int ServiceControlHandlerEx(int control, int eventType, IntPtr eventData, IntPtr context); [DllImport("advapi32.dll", SetLastError = true)] public static extern IntPtr RegisterServiceCtrlHandlerEx(string lpServiceName, ServiceControlHandlerEx cbex, IntPtr context); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetVolumePathNamesForVolumeNameW( [MarshalAs(UnmanagedType.LPWStr)] string lpszVolumeName, [MarshalAs(UnmanagedType.LPWStr)] string lpszVolumePathNames, uint cchBuferLength, ref UInt32 lpcchReturnLength); [DllImport("kernel32.dll")] public static extern bool GetVolumeNameForVolumeMountPoint(string lpszVolumeMountPoint, [Out] StringBuilder lpszVolumeName, uint cchBufferLength); [DllImport("user32.dll", SetLastError = true)] public static extern IntPtr RegisterDeviceNotification(IntPtr IntPtr, IntPtr NotificationFilter, Int32 Flags); [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern uint UnregisterDeviceNotification(IntPtr hHandle); [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr CreateFile( string FileName, \/\/ file name uint DesiredAccess, \/\/ access mode uint ShareMode, \/\/ share mode uint SecurityAttributes, \/\/ Security Attributes uint CreationDisposition, \/\/ how to create uint FlagsAndAttributes, \/\/ file attributes int hTemplateFile \/\/ handle to template file ); [DllImport("kernel32.dll", SetLastError = true)] public static extern bool CloseHandle(IntPtr hObject); [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct DEV_BROADCAST_DEVICEINTERFACE { public int dbcc_size; public int dbcc_devicetype; public int dbcc_reserved; [MarshalAs(UnmanagedType.ByValArray, ArraySubType = UnmanagedType.U1, SizeConst = 16)] public byte[] dbcc_classguid; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 128)] public char[] dbcc_name; } [StructLayout(LayoutKind.Sequential)] public struct DEV_BROADCAST_HDR { public int dbcc_size; public int dbcc_devicetype; public int dbcc_reserved; } [StructLayout(LayoutKind.Sequential)] public struct DEV_BROADCAST_HANDLE { public int dbch_size; public int dbch_devicetype; public int dbch_reserved; public IntPtr dbch_handle; public IntPtr dbch_hdevnotify; public Guid dbch_eventguid; public long dbch_nameoffset; public byte dbch_data; public byte dbch_data1; } } }<\/code><\/pre>"

I have previously worked on the same subject and the route I ultimately went with was to simply construct a Window and forward the messages. 我以前曾在同一主题上工作过,而最终选择的路线只是构造一个Window并转发消息。 I'm sure I got the relevant code from some third party, as I commented on this question in 2013 referencing a link that is now dead. 我确定我是从某第三方获得相关代码的,因为我在2013年对此问题进行了评论时引用了现已失效的链接。

So let's look at the code. 因此,让我们看一下代码。

First of all, this is the complete MessageWindow implementation: 首先,这是完整的MessageWindow实现:

using System;
using System.Threading;
using System.Windows.Forms;
using System.ComponentModel;
using System.Collections.Generic;

namespace Foo.Windows {
  public class MessageReceivedEventArgs : EventArgs {
    private readonly Message _message;

    public MessageReceivedEventArgs( Message message ) {
      _message = message;
    }

    public Message Message {
      get { return _message; }
    }
  }

  public static class MessageEvents {
    private static object _lock = new object();
    private static MessageWindow _window;
    private static IntPtr _windowHandle;
    private static SynchronizationContext _context;

    public static event EventHandler<MessageReceivedEventArgs> MessageReceived;

    public static void WatchMessage( int message ) {
      EnsureInitialized();
      _window.RegisterEventForMessage( message );
    }

    public static IntPtr WindowHandle {
      get {
        EnsureInitialized();
        return _windowHandle;
      }
    }

    private static void EnsureInitialized() {
      lock( _lock ) {
        if( _window == null ) {
          _context = AsyncOperationManager.SynchronizationContext;
          using( ManualResetEvent mre = new ManualResetEvent( false ) ) {
            Thread t = new Thread( (ThreadStart) delegate {
                                                   _window = new MessageWindow();
                                                   _windowHandle = _window.Handle;
                                                   mre.Set();
                                                   Application.Run();
                                                 } );
            t.Name = "MessageEvents message loop";
            t.IsBackground = true;
            t.Start();

            mre.WaitOne();
          }
        }
      }
    }

    private class MessageWindow : Form {
      private ReaderWriterLock _lock = new ReaderWriterLock();
      private Dictionary<int, bool> _messageSet = new Dictionary<int, bool>();

      public void RegisterEventForMessage( int messageID ) {
        _lock.AcquireWriterLock( Timeout.Infinite );
        _messageSet[ messageID ] = true;
        _lock.ReleaseWriterLock();
      }

      protected override void WndProc( ref Message m ) {
        _lock.AcquireReaderLock( Timeout.Infinite );
        bool handleMessage = _messageSet.ContainsKey( m.Msg );
        _lock.ReleaseReaderLock();

        if( handleMessage ) {
          MessageEvents._context.Send( delegate( object state ) {
            EventHandler<MessageReceivedEventArgs> handler = MessageEvents.MessageReceived;
            if( handler != null )
              handler( null, new MessageReceivedEventArgs( (Message)state ) );
          }, m );
        }

        base.WndProc( ref m );
      }
    }
  }
}

For completeness, these are the constants relevant to the device change detection process: 为了完整起见,这些是与设备更改检测过程相关的常数:

using System;
using System.Runtime.InteropServices;

namespace Foo.Windows {
  internal class NativeMethods {
    /// <summary>
    /// Notifies an application of a change to the hardware configuration of a device or the computer.
    /// </summary>
    public static Int32 WM_DEVICECHANGE = 0x0219;

    /// <summary>
    /// The system broadcasts the DBT_DEVICEARRIVAL device event when a device or piece of media has been inserted and becomes available.
    /// </summary>
    public static Int32 DBT_DEVICEARRIVAL = 0x8000;

    /// <summary>
    /// Serves as a standard header for information related to a device event reported through the WM_DEVICECHANGE message.
    /// </summary>
    [StructLayout( LayoutKind.Sequential )]
    public struct DEV_BROADCAST_HDR {
      public Int32 dbch_size;
      public Int32 dbch_devicetype;
      public Int32 dbch_reserved;
    }

    public enum DBT_DEVTYP : uint {
      /// <summary>
      /// OEM- or IHV-defined device type.
      /// </summary>
      DBT_DEVTYP_OEM = 0x0000,

      /// <summary>
      /// Logical volume.
      /// </summary>
      DBT_DEVTYP_VOLUME = 0x0002,

      /// <summary>
      /// Port device (serial or parallel).
      /// </summary>
      DBT_DEVTYP_PORT = 0x0003,

      /// <summary>
      /// Class of devices.
      /// </summary>
      DBT_DEVTYP_DEVICEINTERFACE = 0x0005,

      /// <summary>
      /// File system handle.
      /// </summary>
      DBT_DEVTYP_HANDLE = 0x0006
    }

    /// <summary>
    /// Contains information about a OEM-defined device type.
    /// </summary>
    [StructLayout( LayoutKind.Sequential )]
    public struct DEV_BROADCAST_VOLUME {
      public Int32 dbcv_size;
      public Int32 dbcv_devicetype;
      public Int32 dbcv_reserved;
      public Int32 dbcv_unitmask;
      public Int16 dbcv_flags;
    }
  }
}

Now all you have to do is to register the message you're interested in and handle the event when it happens. 现在,您要做的就是注册您感兴趣的消息,并在事件发生时进行处理。 These should be the relevant parts for that process: 这些应该是该过程的相关部分:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
using System.Threading;
using Foo.Windows;

namespace Foo.Core {
  class Daemon {

    private static void InternalRun() {
      MessageEvents.WatchMessage( NativeMethods.WM_DEVICECHANGE );
      MessageEvents.MessageReceived += MessageEventsMessageReceived;
    }

    private static void MessageEventsMessageReceived( object sender, MessageReceivedEventArgs e ) {
      // Check if this is a notification regarding a new device.);
      if( e.Message.WParam == (IntPtr)NativeMethods.DBT_DEVICEARRIVAL ) {
        Log.Info( "New device has arrived" );

        // Retrieve the device broadcast header
        NativeMethods.DEV_BROADCAST_HDR deviceBroadcastHeader =
          (NativeMethods.DEV_BROADCAST_HDR)
          Marshal.PtrToStructure( e.Message.LParam, typeof( NativeMethods.DEV_BROADCAST_HDR ) );

        if( (int)NativeMethods.DBT_DEVTYP.DBT_DEVTYP_VOLUME == deviceBroadcastHeader.dbch_devicetype ) {
          Log.Info( "Device type is a volume (good)." );

          NativeMethods.DEV_BROADCAST_VOLUME volumeBroadcast =
            (NativeMethods.DEV_BROADCAST_VOLUME)
            Marshal.PtrToStructure( e.Message.LParam, typeof( NativeMethods.DEV_BROADCAST_VOLUME ) );

          Log.InfoFormat( "Unit masked for new device is: {0}", volumeBroadcast.dbcv_unitmask );

          int driveIndex = 1;
          int bitCount = 1;
          while( bitCount <= 0x2000000 ) {
            driveIndex++;
            bitCount *= 2;

            if( ( bitCount & volumeBroadcast.dbcv_unitmask ) != 0 ) {
              Log.InfoFormat( "Drive index {0} is set in unit mask.", driveIndex );
              Log.InfoFormat( "Device provides drive: {0}:", (char)( driveIndex + 64 ) );

              int index = driveIndex;

              Thread spawnProcessThread = new Thread( () => SpawnDeviceProcess( string.Format( "{0}", (char)( index + 64 ) ) ) );
              spawnProcessThread.Start();
            }
          }

        } else {
          Log.InfoFormat( "Device type is {0} (ignored).", Enum.GetName( typeof( NativeMethods.DBT_DEVTYP ), deviceBroadcastHeader.dbch_devicetype ) );
        }
      }
    }
  }
}

In my project, I was only interested in retrieving the drive letter for inserted USB keys. 在我的项目中,我只对检索插入的USB密钥的驱动器号感兴趣。 This code retrieves that drive letter and would then spawn a dedicated handler process for the device. 此代码检索该驱动器号,然后为该设备生成一个专用的处理程序进程。

This was implemented in a C# service. 这是在C#服务中实现的。 System.Windows.Forms has to be referenced. System.Windows.Forms必须被引用。 Should work just fine. 应该工作正常。

I might be able to get the entire project onto GitHub, but it appears to be very time consuming to properly clean it up. 我也许可以将整个项目放到GitHub上,但是正确清理它似乎非常耗时。 I hope this is sufficient information to be able to replicate the result. 我希望这是足够的信息,能够复制结果。

The problem is that, due to the "ingenious" .Net Framework API design done by the "brilliant" Microsoft software engineers, OnCustomCommand()<\/code> method from the ServiceBase<\/code> class (which you could in theory override in your code to handle any service control code) only passes down the dwControl<\/code> parameter -- it doesn't pass down dwEventType<\/code> and lpEventData<\/code> parameters from the native ServiceControlHandlerEx()<\/code> callback, which are both required for proper handling of SERVICE_CONTROL_DEVICEEVENT<\/code> , SERVICE_CONTROL_POWEREVENT<\/code> , SERVICE_CONTROL_SESSIONCHANGE<\/code> , and SERVICE_CONTROL_TIMECHANGE<\/code> service control codes.问题在于,由于“出色的”微软软件工程师完成了“巧妙的”.Net Framework API 设计, ServiceBase<\/code>类中的OnCustomCommand()<\/code>方法(理论上您可以在代码中覆盖该方法以处理任何服务控制代码) 只传递dwControl<\/code>参数——它不传递来自本机ServiceControlHandlerEx()<\/code>回调的dwEventType<\/code>和lpEventData<\/code>参数,这两个参数都是正确处理SERVICE_CONTROL_DEVICEEVENT<\/code> 、 SERVICE_CONTROL_POWEREVENT<\/code> 、 SERVICE_CONTROL_SESSIONCHANGE<\/code>和SERVICE_CONTROL_TIMECHANGE<\/code>服务控制代码所必需的。

A workaround as already demonstrated in other answers is to create an invisible window, register it for specific notifications, and then forward them to your service code.正如其他答案中已经展示的一种解决方法是创建一个不可见的窗口,将其注册以获取特定通知,然后将它们转发到您的服务代码。 However, that's a horrible cludge, and it adds a ton of totally unnecessary complexity and additional points of failure.然而,这是一个可怕的杂物,它增加了大量完全不必要的复杂性和额外的故障点。

Better option would be to just forget ServiceBase<\/code> and implement your own service class using Winodws API, P\/Invoke, and marshaling, but then you are probably rightfully asking yourself "What's the point of having a framework to begin with?"更好的选择是忘记ServiceBase<\/code>并使用 Winodws API、P\/Invoke 和封送处理实现您自己的服务类,但是您可能会问自己“拥有一个框架有什么意义?”

How did we get to .Net Framework version 4.8, and then to .Net Core \/ Standard all the way up to version 6.0 without correcting this blatant API design oversight is something to reflect upon while you are considering changing your career to something less retarded and less stressful than software development.我们是如何到达 .Net Framework 4.8 版,然后一直到 .Net Core\/Standard 一直到 6.0 版而没有纠正这种公然的 API 设计疏忽,当您考虑将您的职业转变为不那么迟钝和比软件开发压力小。

Glaring omissions in API design like this one (and this one is far from being the only one) are what makes .Net Framework \/ Core \/ Standard a toy API compared to native Windows API.与本机 Windows API 相比,像这样的 API 设计中明显的遗漏(而且远非唯一)是 .Net Framework\/Core\/Standard 成为玩具 API 的原因。

"

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM