zoukankan      html  css  js  c++  java
  • 一周一话题之三(Windows服务、批处理项目实战)

    一、 Windows服务

    1. windows service介绍

    Windows服务应用程序是一种需要长期运行的应用程序,它对于服务器环境特别适合。它没有用户界面,并且也不会产生任何可视输出。任何用户消息都会被写进Windows事件日志。计算机启动时,服务会自动开始运行。它们不要用户一定登录才运行,它们能在包括这个系统内的任何用户环境下运行。通过服务控制管理器,Windows服务是可控的,可以终止、暂停及当需要时启动。

    Windows 服务,以前的NT服务,都是被作为Windows NT操作系统的一部分引进来的。它们在Windows 9x及Windows Me下没有。你需要使用NT级别的操作系统来运行Windows服务,诸如:Windows NT、Windows 2000 Professional或Windows 2000 Server。举例而言,以Windows服务形式的产品有:Microsoft Exchange、SQL Server,还有别的如设置计算机时钟的Windows Time服务。

    回到导航

    2. 使用步骤

    (1) 编写windows service

    ① 添加windows service项目

    ② OnStart()方法作为windows service的启动方法,OnStop()方法作为结束方法

    ③ 通常在windows service中,我们都加入Timer,让服务定时做某些事

    ④ 写好服务后,在视图页面右键,为服务添加安装程序;

    ⑤ serviceInstaller1“StartType”属性设置为Automatic,serviceProcessInstaller1“Account”属性设置为LocalSystem

    例子:该服务实现,服务启动、结束时记下日志,服务在运行时每隔一秒记下一条日志

    public partial class Service1 : ServiceBase
        {
            public Service1()
            {
                InitializeComponent();
            }
    
            private string _rootPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
    
            private string _fileName = DateTime.Now.ToString("yyyyMMdd") + ".txt";
    
            static readonly object padlock = new object();
    
            protected override void OnStart(string[] args)
            {
                if (!Directory.Exists(_rootPath)) Directory.CreateDirectory(_rootPath);
                string path = Path.Combine(_rootPath, _fileName);
                using (StreamWriter sw = new StreamWriter(path))
                {
                    sw.WriteLine("Windows Services is Started at {0}!", DateTime.Now);
                }
                var smsTimer = new Timer { Interval = 1000D, Enabled = true };
                smsTimer.Elapsed += smsTimer_Elapsed;
                smsTimer.Start();
            }
    
            protected override void OnStop()
            {
                string path = Path.Combine(_rootPath, _fileName);
                using (StreamWriter sw = new StreamWriter(path, true))
                {
                    sw.WriteLine("Windows Services is Stopped at {0}!", DateTime.Now);
                }
            }
    
            private void smsTimer_Elapsed(object sender, ElapsedEventArgs e)
            {
                lock (padlock)
                {
                    string path = Path.Combine(_rootPath, _fileName);
                    using (StreamWriter sw = new StreamWriter(path, true))
                    {
                        sw.WriteLine("Windows Services is Running at {0}!", DateTime.Now);
                    }
                }
            }
        }

    (2) 安装、启动、停止、卸载

    ① 微软自带安装、卸载

    从命令行进入服务编译后的路径,cd F:studycode1WindowsServiceDemoinRelease

    安装:"%SystemRoot%Microsoft.NETFrameworkv4.0.30319InstallUtil.exe"  01WindowsServiceDemo.exe

    启动:net start "MyTestService"

    停止:net stop "MyTestService"

    卸载:"%SystemRoot%Microsoft.NETFrameworkv4.0.30319InstallUtil.exe" /u 01WindowsServiceDemo.exe

    ② sc命令(批处理中有介绍《sc索引链接》)

    安装:

    set str=%~f0

    set str=%str:~0,-26%01WindowsServiceDemo.exe

    sc create "MyTestService" binPath= "%str%"

    sc config "MyTestService"  type= own start= auto tag= no

    :set str=%str:~0,-26%01WindowsServiceDemo.exe 为获取安装文件执行路径

    启动:sc start "MyTestService"

    停止:sc stop "MyTestService"

    卸载:sc delete "MyTestService"

    (3) 调试

    ① 安装启动服务

    ② 设置断点

    ③ 附加进程

    回到导航

    3. 项目实例--数据上传下载服务

    (1) 问题引入

    cs程序与bs程序数据的交互,我们采用的是上传下载机制;bs系统维护管理数据,cs系统无论是需要从bs系统下载数据,还是对其上传数据,首先都会生成xml文档,xml文档中包含了上传下载类型、要执行的sql命令等等。cs系统以socket通信的方式把xml发送给bs系统,在bs系统中解析xml文档,如果接到了下载的要求,就根据需要再生成xml以同样方式发给cs系统,如果接到上传要求,就更新数据库。

    image

    (2)准备工作

    BS系统中写好windows服务来进行处理数据上传下载

    CS系统中写好通信机制,CS系统的特殊性,本身就是一个进程,故不需要windows服务

    (3) 代码片段

    public partial class DataExchageService : ServiceBase
        {
            public DataExchageService()
            {
                InitializeComponent();
            }
    
            protected override void OnStart(string[] args)
            {
                DataExchageTimer.Interval = GetIntervalTimes();
                //开启线程进行监听
                Thread t = new Thread(AsyncListening) { IsBackground = true };
                t.Start();
            }
    
            protected override void OnStop()
            {
                DataExchageTimer.Enabled = false;
            }
    
            /// <summary>
            /// Timer轮询事件
            /// </summary>
            /// <param name="sender"></param>
            /// <param name="e"></param>
            void DataExchageTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
            {
                //定时解析上传来的xml数据并保存数据库
                //......
            }
    
            #region 异步方法
    
            // 线程事件标记,初始状态为非终止状态
            public static ManualResetEvent MrDone = new ManualResetEvent(false);
    
            /// <summary>
            /// 监听方法
            /// </summary>
            public void AsyncListening()
            {
                //建立Socket通信套接字
                Socket listenerSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                try
                {
                    //通信端口
                    IPEndPoint endPoint = new IPEndPoint(IPAddress.Parse(SystemConfig.Ip), int.Parse(SystemConfig.Port));
                    listenerSocket.Bind(endPoint);
                    listenerSocket.Listen(5000);
    
                    //轮询
                    while (true)
                    {
                        //置为非终止状态
                        MrDone.Reset();
    
                        //Socket开启异步线程进行接收请求
                        listenerSocket.BeginAccept(AcceptCallback, listenerSocket);
    
                        //阻塞主监听线程
                        MrDone.WaitOne();
                    }
                }
                catch (Exception)
                {
                    throw;
                }
            }
    
            /// <summary>
            /// 异步接收请求回调函数
            /// </summary>
            /// <param name="result"></param>
            public void AcceptCallback(IAsyncResult result)
            {
                //复位主监听线程,让其不再阻塞
                MrDone.Set();
    
                //得到接收到Socket
                Socket handler = (Socket)result.AsyncState;
                //完成接收请求
                Socket acceptSocket = handler.EndAccept(result);
    
                //创建状态对象(目的是为了封装多个参数到一个对象中)
                DataExchangeState state = new DataExchangeState() { WorkSocket = acceptSocket, Ms = new MemoryStream() };
    
                //异步线程接收数据
                acceptSocket.BeginReceive(state.Buffer, 0, DataExchangeState.BufferSize, 0, ReceiveCallback, state);
            }
    
            /// <summary>
            /// 异步接收请求回调函数
            /// </summary>
            /// <param name="result"></param>
            public void ReceiveCallback(IAsyncResult result)
            {
                //异步接收状态对象
                DataExchangeState state = (DataExchangeState) result.AsyncState;
                Socket handler = state.WorkSocket;
                //完成接收数据大小
                int byteSizeRec = handler.EndReceive(result);
                if (byteSizeRec > 0)
                {
                    state.Ms.Write(state.Buffer, 0, byteSizeRec);
                    state.ReceiveSize += byteSizeRec;
                    
                    //根据请求得到返回数据,此处代码省略
                    string sendMsg = "";
                    state.Ms.Close();
                    state.Ms.Dispose();
                    AsyncSend(handler, sendMsg);
                }
            }
    
            /// <summary>
            /// 发送数据方法
            /// </summary>
            /// <param name="handler">socket对象</param>
            /// <param name="data">数据</param>
            private void AsyncSend(Socket handler, string data)
            {
                //压缩数据
                byte[] byteData = DataExchangeUtility.ZipString(data);
                //发送包头
                byte[] sendSize = new byte[4];
                sendSize = BitConverter.GetBytes(byteData.Length);
                handler.Send(sendSize);
    
                //异步调用发送方法
                handler.BeginSend(byteData, 0, byteData.Length, 0, SendCallback, handler);
            }
    
            /// <summary>
            /// 发送数据回调方法
            /// </summary>
            /// <param name="result">异步操作状态</param>
            private void SendCallback(IAsyncResult result)
            {
                try
                {
                    //从状态对象得到socket
                    Socket handler = (Socket)result.AsyncState;
                    handler.EndSend(result);
    
                    //关闭socket连接
                    handler.Shutdown(SocketShutdown.Both);
                    handler.Close();
                }
                catch (Exception ex)
                {
                    LogHelper.WriteLog(string.Format("log{0}", "SendCallback" + ex.Message));
                }
            }
    
            #endregion
    
            #region 通用方法
    
            /// <summary>
            /// 获得Timer的Interval
            /// </summary>
            /// <returns></returns>
            private int GetIntervalTimes()
            {
                int intervalTimes = 10000;
                switch (SystemConfig.TimesType)
                {
                    case "1":
                        intervalTimes = int.Parse(SystemConfig.Times) * SystemConfig.Hour;
                        break;
                    case "2":
                        intervalTimes = int.Parse(SystemConfig.Times) * SystemConfig.Minute;
                        break;
                    case "3":
                        intervalTimes = int.Parse(SystemConfig.Times) * SystemConfig.Second;
                        break;
                    case "4":
                        intervalTimes = int.Parse(SystemConfig.Times) * SystemConfig.Milliseconds;
                        break;
                }
                return intervalTimes;
            } 
    
            #endregion
    
            
        }
    

      

    二、 批处理运用

    1. 批处理介绍

    批处理文件(Batch File,简称 BAT文件)是一种在DOS 下最常用的可执行文件。它具有灵活的操纵性,可适应各种复杂的计算机操作。所谓的批处理,就是按规定的顺序自动执行若干个指定的DOS命令或程序。即是把原来一个一个执行的命令汇总起来,成批的执行,而程序文件可以移植到其它电脑中运行,因此可以大大节省命令反复输入的繁琐。同时批处理文件还有一些编程的特点,可以通过扩展参数来灵活的控制程序的执行,所以在日常工作中非常实用。

    回到导航

    2. 基本语法

    -->常用批处理命令

    (1) rem::

    它们都用来注释,rem能回显;而::即时用echo on也不能进行回显,因为:后跟非数字字母的字符,命令解释行都认为他不是有效命令。

    (2) pause

    暂停系统命令,一般显示:按任意键继续…;

    (3) goto:

    : XX来设置标识位,用goto XX进行跳转到刚才用:标识的XX位置

    (4) title

    设置cmd窗口标题

    (5) color

    color [attr],attr属性值

    0 = 黑色       8 = 灰色

    1 = 蓝色       9 = 淡蓝色

    2 = 绿色       A = 淡绿色

    3 = 湖蓝色     B = 淡浅绿色

    4 = 红色       C = 淡红色

    5 = 紫色       D = 淡紫色

    6 = 黄色       E = 淡黄色

    7 = 白色       F = 亮白色

    (6) call

    CALL命令可以在批处理执行过程中调用另一个批处理,当另一个批处理执行完后,再继续执行原来的批处理

    在批处理编程中,可以根据一定条件生成命令字符串,用call可以执行该字符串,见例子:

    call [drive:][path]filename [batch-parameters]

    (7) shift

    shift [/n]:更改批处理文件中可替换参数的位置。n 介于零和八之间。

    例如:shift /2  会将 %3 移位到 %2,将 %4 移位到 %3,等等;并且不影响 %0 和 %1。

    (8) Setlocal Enabledelayedexpansion

    开启变量延迟,通常在“复合语句”(凡是()里的所有命令)中才会用到;

    cmd在处理“复合语句”的时候,如果“复合语句”中用到了变量,会把变量的值当作复合语句之前变量的值来引用。如果在此之前变量没有被赋值,就把它当成空值。

    (9) set

    设置变量,注:在为变量赋值时,等号一定要紧随其后 ex: set isServicesStart=%1%

    (10) echo

    ① 打开回显或关闭回显:echo [{on|off}]

    ② 显示当前echo设置状态:echo

    ③ 输出提示信息:echo 信息内容

    Echo 删除引号: %~1

    Echo 扩充到路径: %~f1

    Echo 扩充到一个驱动器号: %~d1

    Echo 扩充到一个路径: %~p1

    Echo 扩充到一个文件名: %~n1

    Echo 扩充到一个文件扩展名: %~x1

    Echo 扩充的路径指含有短名: %~s1

    Echo 扩充到文件属性: %~a1

    Echo 扩充到文件的日期/时间: %~t1

    Echo 扩充到文件的大小: %~z1

    Echo 扩展到驱动器号和路径:%~dp1

    Echo 扩展到文件名和扩展名:%~nx1

    Echo 扩展到类似 DIR 的输出行:%~ftza1

    Echo.:换空行

    -->组合

    %~dp1       - 只将 %1 扩展到驱动器号和路径
            %~nx1       - 只将 %1 扩展到文件名和扩展名

    -->常用Dos命令

    (1)rmdirrd

    删除非空目录:rmdir [/s] [/q] [drive:]path

    删除空目录:rd [/s] [/q] [drive:]path

    /s      除目录本身外,还将删除指定目录下的所有 文件。用于删除目录树。

    /q      安静模式,带 /s 删除目录树时不要求确认

    (2) md

    创建文件夹:md

    (3) xcopy

    XCOPY是COPY的扩展,可以把指定的目录连文件和目录结构一并拷贝,但不能拷贝隐藏文件系统文件;使用时源盘符、源目标路径名、源文件名至少指定一个;选用/s时对源目录下及其子目录下的所有文件进行拷贝。除非指定/e参数,否则/S不会拷贝空目录,若不指定/S参数,则XCOPY只拷贝源目录本身的文件,而不涉及其下的子目录;

    《xcopy参数参考》

    (4) ren

    键入ren(空格)旧文件名(空格)新文件名

    (5) del

    DEL [/P] [/F] [/S] [/Q] [/A[[:]attributes]] names

    names 指定一个或数个文件或目录列表。通配符可被用来 删除多个文件。如果指定了一个目录,目录中的所 有文件都会被删除。

    /P 删除每一个文件之前提示确认。

    /F 强制删除只读文件。

    /S 从所有子目录删除指定文件。

    /Q 安静模式。删除全局通配符时,不要求确认。

    /A 根据属性选择要删除的文件。

    attributes R 只读文件 S 系统文件 :H 隐藏文件 A 存档文件

    (6) cacls

    语法:cacls FileName [/t] [/e] [/c] [/g User:permission] [/r User [...]] [/p User:permission [...]] [/d User [...]]

    参数介绍

    FileName:必需。显示指定文件的 ACL。/t:更改当前目录和所有子目录中指定文件的 ACL。/e:编辑 ACL,而不是替换它。/c:忽略错误,继续修改 ACL。/g User:permission:将访问权限授予指定用户。(/r user:取消指定用户的访问权限。/p User:permission:替代指定用户的访问权限。

    permission参数:n 无、r 阅读顺序、w 写入、c 更改(写入)、F 完全控制

    -->sqlcmd命令

    sqlcmd -S 服务器名 -U 用户名 -P 密码 -d 数据库 -i 脚本文件路径

    -Q sql执行命令

    -->sc命令

    功能:

    ① SC可以 检索和设置有关服务的控制信息。可以使用 SC.exe 来测试和调试服务程序。

    ② 可以设置存储在注册表中的服务属性,以控制如何在启动时启动服务应用程序,以及如何将其作为后台程序运行。即更改服务的启动状态。

    ③ SC 命令还可以用来删除系统中的无用的服务。(除非对自己电脑中的软硬件所需的服务比较清楚,否则不建议删除任何系统服务,尤其是基础服务)

    ④ SC命令 的参数可以配置指定的服务,检索当前服务的状态,也可以停止和启动服务(功能上类似NET STOP/START命令,但SC速度更快且能停止更多的服务)。

    ⑤ 可以创建批处理文件来调用不同的 SC 命令,以自动启动或关闭服务序列。

    语法:《相关参考》

    SC [Servername] command Servicename [Optionname= Optionvalue]

    query-----------查询服务的状态,或枚举服务类型的状态。

    queryex---------查询服务的扩展状态,或枚举服务类型的状态。

    start-----------启动服务。

    pause-----------发送 PAUSE 控制请求到服务。

    interrogate-----发送 INTERROGATE 控制请求到服务。

    continue--------发送 CONTINUE 控制请求到服务。

    stop------------发送 STOP 请求到服务。

    config----------(永久地)更改服务的配置。

    description-----更改服务的描述。

    failure---------更改服务失败时所进行的操作。

    qc--------------查询服务的配置信息。

    qdescription----查询服务的描述。

    qfailure--------查询失败服务所进行的操作。

    delete----------(从注册表)删除服务。

    create----------创建服务(将其添加到注册表)。

    control---------发送控制到服务。

    sdshow----------显示服务的安全描述符。

    sdset-----------设置服务的安全描述符。

    GetDisplayName--获取服务的 DisplayName。

    GetKeyName------获取服务的 ServiceKeyName。

    EnumDepend------枚举服务的依存关

    -->常用特殊符号

    (1)@  命令行回显屏蔽符

    @echo off 达到所有命令均不回显的要求

    (2)%  批处理变量引导符

    引用变量用%var%,调用程序外部参数用%1至%9等等

    %0  %1  %2  %3  %4  %5  %6  %7  %8  %9  %*为命令行传递给批处理的参数

    回到导航

    3. 项目实例--项目部署

    (1) 问题的引入

    (2) 准备工作

    ① 获得通过持久化集成生成release版本的项目文件

    ② 拥有修改config的exe处理程序(本文中并未设计,根据自己项目的需要写一个处理程序即可),因为release版本的config配置文件的参数与真实部署环境是有差别的,通过运行这个程序就可达到将release的config文件修改为真实环境的配置。这个程序可随release版本一起发布出来

    ③ winform制作的批处理参数生成工具

    ④ 数据库的执行脚本,这些脚本可以跟随release版本一起发布出来

    (3) 批处理上阵

    ① 批处理参数生成工具,让系统部署人员,在页面中配置好要部署的数据库信息,以及发布的服务端口,网站部署路径等等,最终生成批处理的参数列表,它们以空格分隔

    ex:installTool.cmd F:worktestProject 192.168.1.18 1433 sa sa DBName "192.168.1.28@50001@192.168.1.18@10009@192.168.1.18@8021@192.168.1.18@4099@192.168.1.18@10008@127.0.0.1@10020" true true false

    第1个参数“installTool.cmd”:要执行的批处理文件

    第2个参数“F:worktestProject ”:IIS部署路径

    第3个参数 “192.168.1.18”:服务器数据库IP地址

    第4个参数 “1433 ”:数据库端口号

    第5、6个参数 “sa”:数据库登陆用户名和密码

    第7个参数“DBName”:数据库名称

    第8个参数:各种服务端口信息

    第9-11个参数:批处理控制信息,包括是否启动服务,是否初始化数据库等等

    ② 批处理代码,具体解释见注释

    :: 开启变量延迟
    @echo on
    setlocal enabledelayedexpansion
    echo 开始进行配置...
    :: -----------------------------------------通过参数设置变量开始------------------------------------------------
    :: 通过第1个参数(%n%参数n默认从0开始)获得当前批处理文件所在路径(%~dp0将第一个参数扩展到驱动器号和路径) 
    set filePath=%~dp0
    :: 通过第2个参数获得网站部署根目录
    set rootPath=%1%
    :: 通过第3、4、5、6、7个参数获得数据库相关信息(包括数据库IP,数据库端口,数据库登陆用户名,密码,数据库名称)
    set serverDbIp=%2%
    set serverDbPort=%3%
    set dbUser=%4%
    set dbPwd=%5%
    set dbName=%6%
    
    :: 通过第8个参数,获得服务相关配置的字符串
    set serviceConfig=%7%
    :: 通过第9个参数,获得是否自动启动服务
    set isStartService=%8%
    :: 通过第10个参数,获得是否执行数据库脚本
    set isExecuteDbScripts=%9%
    :: shift /n,如果传入参数过多,可以在用参数给变量赋值后,使用shift使参数向前移动
    shift /1
    :: 通过第11个参数,获得是否显示执行过程
    set isShowProcess=%9% 
    :: -----------------------------------------通过参数设置变量结束------------------------------------------------
    
    :: -----------------------------------------文件相关操作开始----------------------------------------------------
    :: 创建配置中指定的部署路径文件夹
    rmdir /s /q %FilePath%
    md  %FilePath%
    :: 拷贝release版本文件到配置中指定的部署路径
    xcopy %str%*.*  %FilePath%  /E /R /Y 
    :: 修改自动生成的文件夹名称
    ren %FilePath%PlatWebService_deploy PlatWebService
    ren %FilePath%WebSite_deploy WebSite
    :: 把指定IP、服务等相关参数传入,执行替换config文件的程序
    %FilePath%ReplaceXmlValues.exe  %FilePath% "%ServiceIp%@%ServicePort%@%DBUser%@%DBPwd%@%DBName%@%ServicePath%"
    :: -----------------------------------------文件相关操作结束----------------------------------------------------
    
    :: -----------------------------------------windows服务启动开始-------------------------------------------------
    if "%isStartService%"=="false"  goto BeginExecuteDbScripts
    @echo Windows服务开始启动
    :: 停止并删除之前的服务
    @sc stop "DataExchangeService" 
    @sc delete "DataExchangeService"
    
    @sc stop "QueueService" 
    @sc delete "QueueService"
    
    :: 调用 写好的批处理来安装启动相应的服务
    call %FilePath%DataExchangeInstallerinstallDataExchangeService.bat
    call %FilePath%WebSiteQueueInstallerinstallQueueService.bat
    :: -----------------------------------------windows服务启动结束-------------------------------------------------
    
    :: -----------------------------------------数据库脚本执行开始--------------------------------------------------
    :BeginExecuteDbScripts
    if "%isExecuteDbScripts%"=="false"  goto End
    @echo 数据库脚本开始执行
    :: 设置脚本所在路径
    set scriptPath=%filePath%Scripts
    :: 创建数据库(按照默认方式创建),也可按照下方进行对数据库创建的详细参数 
    :: create database bbsDB on(name='bbsDB_data',filename='D:projectbsDB_data.mdf',size=10,filegrowth=20%) log on(name='bbsDB_log',filename='D:projectbsDB_log.ldf',size=3,maxsize=20,filegrowth=10%)
    sqlcmd -S %serverDbIp%,%serverDbPort% -U %dbUser% -P %dbPwd% -Q "if DB_ID('%dbName%') is not null drop database %dbName% create database %dbName%"
    :: 开始执行数据库脚本
    sqlcmd -S %serverDbIp%,%serverDbPort% -U %dbUser% -P %dbPwd% -d %DBName% -i %scriptPath%DDL.sql 
    sqlcmd -S %serverDbIp%,%serverDbPort% -U %dbUser% -P %dbPwd% -d %DBName% -i %scriptPath%insert-ModelDictionary.sql
    sqlcmd -S %serverDbIp%,%serverDbPort% -U %dbUser% -P %dbPwd% -d %DBName% -i %scriptPath%insert-baseData.sql
    sqlcmd -S %serverDbIp%,%serverDbPort% -U %dbUser% -P %dbPwd% -d %DBName% -i %scriptPath%insert-exchange-appParams.sql
    sqlcmd -S %serverDbIp%,%serverDbPort% -U %dbUser% -P %dbPwd% -d %DBName% -i %scriptPath%insert-exchange-appParams.sql
    :: -----------------------------------------数据库脚本执行结束--------------------------------------------------
    :End
    pause
    

      注:各位园友,如果你在系统部署方面还有什么好方法,不妨讨论一下,大家共同学习;如果觉得本文对你有些帮助的话,就帮我右下角推荐一下!

  • 相关阅读:
    我国教育技术期刊主要栏目的内容分析
    是互动还是告状 “家校通”通往何处?(转)
    美国高中的班级管理制度
    什么是决策支持系统?
    2009 AECT International Convention
    AECT94定义和AECT2005定义
    感受美国小学生的幸福校园生活! (转)
    教育管理信息系统的研究
    教学评价的新发展
    抽象方法与虚方法(转)
  • 原文地址:https://www.cnblogs.com/SpringDays/p/3530018.html
Copyright © 2011-2022 走看看