学习笔记 --- Windows服务、NUnit

一、Windows服务
Windows服务可以被理解成是一种没有界面的后台程序, 在我们操作系统的"服务管理器(运行->services.msc)"可以找到很多这样的程序, 如: 著名的MSSqlServer. 这种没有界面的后台程序, 并且可以随着计算机启动而自动执行的行为, 非常适合用来做服务器端, 像我们的Remoting、WCF都可以采用Windows服务做服务器端.

制作Windows服务:
1.在VisualStudio中, 创建Windows服务项目(需要选中VisualC#下面的Windows项才会出现Windows服务项目). 查看Program.cs可以看到, Windows服务的启动时通过ServiceBase.Run()静态方法来进行的, 该方法的参数是ServiceBase[]数组, 说明我们可以同时启动运行多个Windows服务程序.
之后在设计界面中, 右键点击属性, 将会出现如下的属性:

    Name    //Name的值实际上就是Windows服务类的类名
    Autolog    //是否自动写入系统日志
    CanHandlePowerEvent    //能否处理电源事件
    CanPauseAndContinue    //能否暂停和继续
    CanShutdown    //是否接收系统关机消息,以便在关机事件发生时调用OnShutDown方法(事件处理器)
    CanStop    //是否允许停止
    ServiceName    //Window服务的名字, 如果不在ServiceInstaller中设置"显示名称", 那么在服务管理器中最后将显示Windows服务名称

2.编写Windows服务的代码.
可以看出Windows服务是从ServiceBase类中派生出来的, 里边的两个虚方法OnStart和OnStop分别对应服务管理器中的启动和停止按钮.我们再来看微软给出的模板, 其实这个模板并不是很恰当, 因为OnStart()方法实际上是由主线程在调用的, 也就是说我们在服务管理器中, 右键点击服务并选择"启动"后, 便会由主线程执行这个OnStart()方法. 假设我们的服务是MSSqlServer服务, 我们的需求时要不断的监听客户的请求, 那么我们可能写一个死循环不断的执行检测请求的方法, 那么这个时候就会造成CPU利用率居高不下的情况. 所以, 我们希望主线程执行的OnStart()方法尽可能快的执行完, 因此我们自然而然的想到我们需要开辟子线程, 主线程的OnStart()方法只负责启动一个子线程, 具体的任务交由子线程去做. 于是, 我们可以修改一下微软提供的这个模板:

//Service.cs

代码
 
   
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Linq;
using System.ServiceProcess;
using System.Text;

namespace WindowsService
{
public partial class ServiceTitle : ServiceBase
{
System.Threading.Thread thread;
public ServiceTitle()
{
InitializeComponent();
thread
= new System.Threading.Thread( new System.Threading.ThreadStart(threadProcess));
}

private void threadProcess()
{
// 这里书写你要执行的操作
}

protected override void OnStart( string [] args)
{
thread.Start();
// 主线程里只有一个启动子线程的操作
}

protected override void OnStop()
{
if (thread.IsAlive)
{
thread.Abort();
// 主线程执行关闭子线程的操作
}
}
}
}

 

根据这个模板, 我们来做个关机的小示例(服务一旦开启就无法停止, 无法修改启动类型), 并同时把WCF服务端改成Windows服务的版本
//program.cs  //注意构造函数中添加了2个服务

代码
 
   
using System;
using System.Collections.Generic;
using System.Linq;
using System.ServiceProcess;
using System.Text;

namespace WindowsService
{
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
static void Main()
{
ServiceBase[] ServicesToRun;
ServicesToRun
= new ServiceBase[]
{
new WCF() ,
new ServiceShutPC()
};
ServiceBase.Run(ServicesToRun);
}
}
}

 

//ServiceShutPC.cs

 

代码
 
   
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Linq;
using System.ServiceProcess;
using System.Text;

namespace WindowsService
{
partial class ServiceShutPC : ServiceBase
{
System.Threading.Thread thread;
// ServiceController sc; // ServiceController类可以用来控制Windows服务的状态, 但好像无法修改服务的启动类型Startup Type.
// 网上有种解决方法是通过cmd下的sc工具来做, 具体命令是: sc 服务名 config start= auto

// 我这里通过采用事件处理机制来做, 每1秒检查一次默认启动类型, 如果发生改变则触发StartupTypeChangedEvent事件(不用事件也可以, 这里纯为了练习)
public delegate void StartupTypeChangedEventHandler( string startupType);
public event StartupTypeChangedEventHandler StartupTypeChangedEvent;

string defaultStartupType = null ;
System.Timers.Timer timer;
public ServiceShutPC()
{
InitializeComponent();
thread
= new System.Threading.Thread( new System.Threading.ThreadStart(threadProcess));

this .CanStop = false ; // 将会关闭停止选项
defaultStartupType = getStartupType(); // 获得初始的启动类型状态
timer = new System.Timers.Timer();
timer.Interval
= 1000 ;

timer.Elapsed
+= new System.Timers.ElapsedEventHandler(timer_Elapsed);
StartupTypeChangedEvent
+= new StartupTypeChangedEventHandler(ServiceShutPC_StartupTypeChangedEvent); // 注册事件
}

void timer_Elapsed( object sender, System.Timers.ElapsedEventArgs e)
{
timer.Stop();
string svcName = getStartupType();
if (svcName != defaultStartupType)
{
OnStartupTypeChangedEvent(defaultStartupType);
}
timer.Start();
}

// 触发事件的方法
public void OnStartupTypeChangedEvent( string startupType)
{
StartupTypeChangedEvent(startupType);
// StartupTypeChangedEvent是事件名
}

// 事件处理器上的方法
void ServiceShutPC_StartupTypeChangedEvent( string startupType)
{
changeStartupType(startupType);
}

private void threadProcess()
{
// 这里书写你要执行的操作
ProcessStartInfo psi = new ProcessStartInfo();
psi.FileName
= System.Environment.SystemDirectory + " \\cmd.exe " ;
string shutdownpath = System.Environment.SystemDirectory + " \\shutdown.exe " ;
psi.Arguments
= " /c " + shutdownpath + " -s -t 600 " ; // Win2k3关机最多能为600秒
psi.WindowStyle = ProcessWindowStyle.Minimized;
Process p
= Process.Start(psi); // 静态方法, 返回一个Process类型
p.WaitForExit(); // 当前线程上的方法, 使当前线程等待子线程的结束, 类似于子线程中的join()方法
timer.Start();
}

protected override void OnStart( string [] args)
{
thread.Start();
// 主线程里只有一个启动子线程的操作
}

protected override void OnStop()
{
if (thread.IsAlive)
{
thread.Abort();
// 主线程执行关闭子线程的操作
}
}

// 读取服务启动类型的方法
private string getStartupType()
{
if ( this .ServiceName != null )
{
// 构造ManagementPath
string path = " Win32_Service.Name=' " + this .ServiceName + " ' " ; // Win32_Service是WMI中的类
System.Management.ManagementPath mp = new System.Management.ManagementPath(path);
// 构造ManagementObject
System.Management.ManagementObject mo = new System.Management.ManagementObject(mp);

return mo[ " StartMode " ].ToString();
}
else
{
return null ;
}
}

// 读取服务启动类型的方法
private void changeStartupType( string startupType)
{
if (startupType != " Automatic " && startupType != " Manual " && startupType != " Disabled " )
{
throw new Exception( " 传递的参数必须是Automatic、Manual或Disabled! " );
}
if ( this .ServiceName != null )
{
// 构造ManagementPath
string path = " Win32_Service.Name=' " + this .ServiceName + " ' " ; // Win32_Service是WMI中的类
System.Management.ManagementPath mp = new System.Management.ManagementPath(path);
// 构造ManagementObject
System.Management.ManagementObject mo = new System.Management.ManagementObject(mp);
// 使用InvokeMethod调用ManagementObject类上的方法
object [] parameter = new object [ 1 ];
parameter[
0 ] = startupType;
mo.InvokeMethod(
" ChangeStartMode " , parameter); // ChangeStartMode是WMI中的方法
}
}
}
}

 

 

 

//ServiceWCF.cs

代码
 
   
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Linq;
using System.ServiceProcess;
using System.Text;

using System.Runtime.Serialization;
using System.ServiceModel;

namespace WindowsService
{
public partial class WCF : ServiceBase
{
System.Threading.Thread thread;
public WCF()
{
InitializeComponent();
thread
= new System.Threading.Thread( new System.Threading.ThreadStart(threadProcess));
}

private void threadProcess()
{
// 这里书写你要执行的操作
ServiceHost host = new ServiceHost( typeof (WcfSvcLib.Service)); // WcfSvcLib.Service类是WCF服务类库中的实现类
host.Open();
Console.WriteLine(
" 服务端已启动 " );
while ( true )
{
System.Threading.Thread.Sleep(
600000 );
}
host.Close();
}

protected override void OnStart( string [] args)
{
thread.Start();
// 主线程里只有一个启动子线程的操作
}

protected override void OnStop()
{
if (thread.IsAlive)
{
thread.Abort();
// 主线程执行关闭子线程的操作
}
}
}
}

 

 //WCF的类库WCFsvcLib和WCF客户端和<<Web服务、Remoting、WCF>>中的一样, 直接拿过来就行

 

 

3.将制作Windows服务安装到"服务管理器"中.
在Windows服务设计界面, 右键点击"添加安装程序(Add Installer)"后, 会多出来两个东西:serviceProcessInstaller和serviceInstaller.
serviceProcessInstaller用来设置"服务管理器"中所需要的参数(如:启动服务的帐户类型等), 通常启动服务的帐户类型选择"本地系统(LocalSystem)", 若使用User帐户, 则可能会要求输入密码或权限不够等问题.
serviceInstaller用来设置"服务管理器"中的显示内容及操作方式(如:描述文本、显示名称、启动类型、服务依赖项等).
当以上设置完毕后, 重新生成项目后就可以使用InstallUtil工具(C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\)将Windows服务添加到"服务管理器"中. 较简单的做法是: 我们需要找到一个稳定的目录, 如:system32中, 把installutil.exe 和编译生成完的Windows服务程序(用Release生成)拷贝到System32下, 之后在cmd中运行命令即可, 安装完毕后可以删除刚拷贝的installutil.exe工具.
具体的Cmd命令为: installutil.exe -i servicename  (使用installutil.exe -u servicename 可以卸载Windows服务)
之后就可以在"服务管理器"中启动或者停止我们创建的Windows服务了.

 

 

二、NUnit单元测试工具
NUnit是个很常用的代码测试工具, 可以用来测试我们写的类. 曾经接到过面试电话就问是否会写NUnit的单元测试用例. 如何编写一个测试用例呢?
步骤:
1.安装NUnit, 没啥说的.
2.在VisualStudio中创建NUnit测试类库项目, 其实就是创建一个类库项目, 命名最好以"NTest+待测类库名"开始, 并添加nUnit.framework程序集.


3.添加待测项目, 也就是已经开发完的类库项目. 这里有两种情况: 
  a.这种公司有着完善的开发流程, 一般有规范的开发文档, 文档中规定了应该实现的类及方法, 并给出了类和方法的名称. 在开发前有的Project Manager会要求开发人员先写测试用例. 此刻, 我们可以跳过第3部而不添加待测项目, 直接写测试用例. 再测试用例过关了, 才开始实际的编码工作, 这样在开发过程中, 开发人员可以不断的检测目前的成果.
  b.有的公司文档说明不是那么规范, 但为了保证其产品质量或更甚者只是给客户装装样子, 会要求开发人员在实现完类及方法后, 再编写测试用例. 此时, 我们需要在第3部添加已完成的待测项目, 即添加已实现的类库项目及相关程序集.


4.在创建的NTest...项目中, 直接编写测试用例或者添加待测项目的程序集引用后开始编写测试用例.
编写测试用例时, 需要抛开待测项目, 专心从业务角度分析所有可能的情况, 主要考虑null、空串、边界值、极大值、极小值、边界以外的值、可能出现的业务异常等.
编写的规则很简单: 在包含测试方法的类上添加[TestFixture]标签(类名最好是"实际类名+Test"), 在测试方法上添加[Test]标签(方法名可以任意, 但最好能表明方法的行为). 注意: 这NTest...项目中的方测试法全部都是公有的、无参数、返回void的方法.
在测试方法的内部, new一个第3部中的待测项目中的实际类的对象, 并在对象上执行方法, 之后用类似于Assert.AreEqual(期望值,实际值)的方法来判断结果; 在测试方法外加[ExpectedException(typeof(ExceptionType))]标签, 用来获取预期的异常信息. 例如: 我们需要测试存款操作的方法, 可以书写如下的测试方法:

代码
 
   
[Test] // 表明这是测试方法
[ExpectedException( typeof (IndexOutOfRangeException))] // 预期异常信息
public void Deposit1000()
{
// 调用实际类中的方法
Account a = new Account();
a.Deposit(
1000 );

// 判断结果是否等于预期
Assert.AreEqual( 1500 ,a.Balence);
}

 
5.在NUnit工具中开始测试.
在NUnit工具中, 新建一个测试项目(*.New Test Project)并保存到NUnitTest...项目中. 在"Project"中选择"Add Assembly"后找到第4部中的NTest...项目的编译完的程序集"NTest+待测类库名".dll. 最后点击Run就知道结果是否合格. 因为结果只有红灯和绿灯, 所以NUnit测试也叫"红绿灯测试". 我们在写NUnit测试时, 可以参考NUnit安装目录下的\doc\quickStart.html指导手册和\samples\csharp示例项目.

编写好的类库文件, //BLL\CalculatorBLL.cs

代码
 
   
using System;
using System.Collections.Generic;
using System.Text;

namespace BLL
{
public class CalculatorBLL
{
#region 利用堆栈实现计算器
public double Compute( string expression)
{
if (expression == null || expression.Length <= 0 )
{
throw new NullReferenceException( " 输入参数不能为空引用 " );
}

// 处理最开始的-或者+号
if (expression.StartsWith( " + " ) || expression.StartsWith( " - " ))
{
expression
= " 0 " + expression;
}

// 1. 将+、-、*、/分离成出来
expression = expression.Replace( " + " , " ,+, " );
expression
= expression.Replace( " - " , " ,-, " );
expression
= expression.Replace( " * " , " ,*, " );
expression
= expression.Replace( " / " , " ,/, " );

string [] infos = expression.Split( ' , ' );
Stack
< string > stack = new Stack < string > (); // 使用list模拟堆栈

// 2. 利用堆栈计算乘除的结果
double prenum;
double nextnum;
for ( int i = 0 ; i < infos.Length; i ++ )
{
if (infos[i] == " * " || infos[i] == " / " )
{
prenum
= Convert.ToDouble(stack.Pop());
nextnum
= Convert.ToDouble(infos[i + 1 ]);
if (infos[i] == " * " )
{
stack.Push((prenum
* nextnum).ToString());
}
else
{
stack.Push((prenum
/ nextnum).ToString());
}
i
++ ; // 别忘了i要++
}
else
{
stack.Push(infos[i]);
}
}

// 3. 利用堆栈计算加减的结果

// infos = stack.ToArray(); // 将stack转存到数组中, 这里转存的结果是逆序的
infos = new string [stack.Count];
for ( int i = infos.Length - 1 ; i >= 0 ; i -- ) // 将stack正序转存到数组中
{
infos[i]
= stack.Pop();
}

prenum
= 0 ; // 重置prenum和nextnum
nextnum = 0 ;
for ( int i = 0 ; i < infos.Length; i ++ )
{
if (infos[i] == " + " || infos[i] == " - " )
{
prenum
= Convert.ToDouble(stack.Pop());
nextnum
= Convert.ToDouble(infos[i + 1 ]);
if (infos[i] == " + " )
{
stack.Push((prenum
+ nextnum).ToString());
}
else
{
stack.Push((prenum
- nextnum).ToString());
}
i
++ ; // 别忘了i++
}
else
{
stack.Push(infos[i]);
}
}
return Convert.ToDouble(stack.Pop());
}
#endregion

#region 利用.NET的编译过程实现计算器
/* .NET的编译器在编译过程中就将常量表达式计算出来, 但是编译过程只有一次, 而我们希望多次计算(每点一次按钮就编译一次).
因此为了得到一次结果就必须执行一次编译过程, 我们可以用动态拼接字符串的方法拼一个类, 然后调用Process.start执行csc得到编译结果
*/
public double Calculate( string expression)
{
GenerateTempClass(expression);
// 生成临时类

#region 通过反射加在程序集并调用其中的方法, 缺点: 只能执行一次
/*
System.Reflection.Assembly asm = System.Reflection.Assembly.LoadFrom(GetCurrentPath() + @"\TempClass.dll");
ITempClass itc = asm.CreateInstance("TempClass") as ITempClass;
return itc.TempMethod();
*/
#endregion

#region
/* 通过反射获得刚编译的程序集, .NET只提供了加载程序集的功能而无法卸载, 这样便只能计算一次结果. 为此, 我们可以通过卸载应用程序域来解决.
//应用程序域用来隔离程序, 默认情况下每个进程会自动创建一个应用程序域. 实际情况下, 每个进程可以有多个应用程序域, 每个应用程序域中可以有多个线程.
//为了实现[跨应用程序域]或者[跨进程]调用, 该类需要继承MarshalByRefObject.
*/
AppDomain ad
= AppDomain.CreateDomain( " AnyName " , null , new AppDomainSetup()); // AppDomain的静态方法创建应用程序域
object obj = ad.CreateInstanceAndUnwrap( " TempClass " , " TempClass " ); // 创建指定程序集中的类的实例并移除封装的额外信息(前面程序集名称, 后便是类名).
ITempClass itc = obj as ITempClass;
double result = itc.TempMethod();
AppDomain.Unload(ad);
// 使用完毕后, 卸载应用程序域

return result;
#endregion
}

private void GenerateTempClass( string expression) // 临时类中含有计算用的方法
{
expression
= System.Text.RegularExpressions.Regex.Replace(expression, " / " , " *1.0/ " ); // 舍去小数主要发生在除法中, 用这则表达式处理下即可
string temppath = System.Environment.GetEnvironmentVariable( " Temp " ); // 获得用户环境变量中的地址, 通过windir获得windows目录
string itempClassNameSpace = System.Reflection.MethodBase.GetCurrentMethod().DeclaringType.Namespace; // 获得当前方法命名空间, 还可以通过GetType(CalculatorBLL).Namespace获得

using (System.IO.StreamWriter sw = new System.IO.StreamWriter(temppath + " \\TempClass.cs " , false )) // 使用默认编码
{
// 通过字符流将类代码写入文件中, 该类需要被外界调用. MarshalByRefObject用来实现跨应用程序域or跨进程调用.
sw.WriteLine( " public class TempClass : System.MarshalByRefObject , " + itempClassNameSpace + " .ITempClass " ); // 当我们要在代码中调用TempClass中的方法时, 必须添加该类的引用, 而此时该类还不存在, 编译肯定过不了, 因此考虑用接口来做.
sw.WriteLine( " { " );
sw.WriteLine(
" public double TempMethod() " );
sw.WriteLine(
" { " );
sw.WriteLine(
" return {0}; " , expression.Trim());
sw.WriteLine(
" } " );
sw.WriteLine(
" } " );
}

// 调用csc编译我们写的类
System.Diagnostics.ProcessStartInfo psi = new System.Diagnostics.ProcessStartInfo();
psi.FileName
= System.Environment.SystemDirectory + " \\cmd.exe " ;
// csc编译器为位置
string cscpath = System.Environment.GetEnvironmentVariable( " windir " ) + @" \Microsoft.NET\Framework\v3.5\csc.exe " ;
string refdllpath = System.Reflection.Assembly.GetExecutingAssembly().Location;
// cmd参数/c表示运行后退出; csc参数/r:表示从指定程序集文件中获得元数据, 即(从主程序的debug或release中获得程序集文件(dll或exe), 这样才能使用程序中定义的接口); 编译后的dll文件放在主窗体程序的debug或release下
psi.Arguments = " /c " + cscpath + " /t:library /r: " + refdllpath + " " + temppath + " \\TempClass.cs > " + refdllpath.Substring( 0 , refdllpath.LastIndexOf( ' \\ ' ) + 1 ) + " log.text " ;
psi.WindowStyle
= System.Diagnostics.ProcessWindowStyle.Minimized;
System. Diagnostics.Process p
= System.Diagnostics.Process.Start(psi);
p.WaitForExit();
// 主线程等待子线程p结束(类似于线程中的join()方法)
}

private string GetCurrentPath()
{
return System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); // 获得不带文件名的路径
}
#endregion
}

// 当我们要在代码中调用TempClass中的方法时, 必须添加该类的引用, 而此时该类还不存在, 编译肯定过不了, 因此考虑用接口来做.
public interface ITempClass
{
double TempMethod();
}
}

 

测试用例, //NTestBLL\CalculatorBLLTest.cs

代码
 
   
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using BLL;

namespace NTestBLL
{
[NUnit.Framework.TestFixture]
// 表示该类包含测试方法
public class CalculatorBLLTest
{
// 测试方法必须是公有的、无参的、返回void的方法
[NUnit.Framework.Test] // 测试方法
[NUnit.Framework.ExpectedException( typeof (NullReferenceException))] // 预期异常
public void ComputeNull()
{
CalculatorBLL cb
= new CalculatorBLL();
double result = cb.Compute( null );

NUnit.Framework.Assert.That(
false ); // 用Assert.That方法表示结果是false
}

[NUnit.Framework.Test]
public void ComputeCommonExpress() // CommonExpress: 3*2+5/9+6
{
CalculatorBLL cb
= new CalculatorBLL();
double result = cb.Compute( " 3*2+5/9+6 " );

NUnit.Framework.Assert.AreEqual(
12.5555555555556D ,result);
}

[NUnit.Framework.Test]
public void ComputeCommonExpressWithBlank() // CommonExpress: " 3*2+5/9+6 "
{
CalculatorBLL cb
= new CalculatorBLL();
double result = cb.Compute( " 3*2+5/9+6 " );

NUnit.Framework.Assert.AreEqual(
12.5555555555556D , result);
}

[NUnit.Framework.Test]
// 当时未简便, 没有写验证. 实际上, 这里应该报错
[NUnit.Framework.ExpectedException( typeof (FormatException))]
public void ComputeCommonExpressWithIllegalChar() // CommonExpress: " 3*2+5/9+6 "
{
CalculatorBLL cb
= new CalculatorBLL();
double result = cb.Compute( " 3*(2+5)/(9+6) " );

NUnit.Framework.Assert.That(
false );
}

[NUnit.Framework.Test]
[NUnit.Framework.ExpectedException(
typeof (ArgumentNullException))] // 为简单, 这里就不去修改异常处理代码了
public void CalculateNull()
{
CalculatorBLL cb
= new CalculatorBLL();
double result = cb.Calculate( null );

NUnit.Framework.Assert.That(
false );
}

[NUnit.Framework.Test]
// 这个方法用控制台程序测试了, 没问题. 估计涉及到反射比较麻烦, 基本确定是路径的问题. --- 没通过
public void CalculateCommonExpression() // CommonExpress: (3+2)/5-6*(7+(3+5)/6)+20
{
CalculatorBLL cb
= new CalculatorBLL();
double result = cb.Calculate( " (3+2)/5-6*(7+(3+5)/6)+20 " );

NUnit.Framework.Assert.AreEqual(
- 29 , result);
}
}
}

 

测试反射计算器的代码, //Program.cs

代码
 
   
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace DebugObject
{
class Program
{
static void Main( string [] args)
{
BLL.CalculatorBLL cb
= new BLL.CalculatorBLL();
Console.Write(cb.Calculate(
" (3+2)/5-6*(7+(3+5)/6)+20 " ));
}
}
}

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值