本文主要介绍注册表的概念与其相关根项的功能,以及浏览器如何通过连接调用自定义协议并与客户端进行数据通信。文中讲及如何通过C#程序、手动修改、安装项目等不同方式对注册表进行修改。其中通过安装项目对注册表进行修改的情况最为常见,在一般的应用程序中都会涉及。
当中最为实用的例子将介绍如何通过"安装项目"修改注册表建立自定义协议,在页面通过ajax方式发送路径请求,并在回调函数中调用自定义协议。
最后一节还将介绍如何调用自定义协议去保持数据的保密性。
希望本篇文章能对各位的学习研究有所帮助,当中有所错漏的地方敬请点评。
目录
三、在 HKEY_CLASSES_ROOT 中添加自定义协议
一、注册表的概念
在谈及Windows自定义协议之前,不得不预先介绍的是注册表这个概念。注册表是windows操作系统的一个核心数据库,其作用是充当计算机上操作系统和应用程序的中央信息储存库,用于存放着各种系统级参数。它能直接控制着windows的启动、硬件驱动程序的装载以及一些windows应用程序的运行。
注册表中保存有应用程序和资源管理器外壳的初始条件、首选项和卸载数据等,联网计算机的整个系统的设置和各种许可,文件扩展名与应用程序的关联,硬件部件的描述、状态和属性,性能记录和其他底层的系统状态信息,以及其他数据等。
1.1 打开注册表
打开 "windows运行",然后输入regedit或regedt32即可打开注册表
1.2 注册表结构
注册表由键、子键和值项构成,一个键就是分支中的一个文件夹,而子键就是这个文件夹中的子文件夹,子键同样是一个键。一个值项则是一个键的当前定义,由名称、数据类型以及分配的值组成。一个键可以有一个或多个值,每个值的名称各不相同,如果一个值的名称为空,则该值为该键的默认值。
HKEY_CLASSES_ROOT 用于控制所有文件的扩展和所有可执行文件相关的信息,本章提到的Windows自定义协议也是在此项中注册产生的(在后面章节将详细讲述);
HEKY_CURRENT_USER 用于管理系统当前的用户信息,及其应用程序的相关资料,例如:当前登录的用户信息,包括用户登录用户名和暂存的密码、当前用户使用的应用软件信息等。用户登录时,其信息会在HEKY_USER表中拷贝到此表中,当HEKY_USER表中信息发生改动时,HEKY_CURRENT_USER表中的信息也将随之改动;
HKEY_CURRENT_MACHINE用于存储控制系统和软件的信息,当中包括网络和硬件上所有的软件设备信息,比如文件的位置,注册和未注册的状态,版本号等等;比较常用的例如在HKEY_LOCAL_MACHINE\Microsoft\Windows\CurrentVersion\Run下注册程序,程序就会在Windows启动时自动运行等等。其实在HKEY_LOCAL_MACHINE\SOFTWARE\Classes里面就包含了HKEY_CLASSES_ROOT信息,而HKEY_CLASSES_ROOT只是它的一个键值的映射,方便信息管理而已;
HEKY_USER 作用是把缺省用户和目前登陆用户的信息输入到注册表编辑器,但它仅被那些配置文件激活的登陆用户使用。当任何在HKEY_CURRENT_USER里的信息发生改变,HKEY_USERS里面的信息也会相应改动。
HKEY_CURRENT_CONFIG 用于存储当前系统的配置方式,例如当Windows为同一个硬件安装有多种驱动程序时,会在HEKY_CUREENT_MACHINE中记录多个程序信息,而在HEKY_CURRENT_CONFIG中只是存储默认使用的驱动信息,Windows 启动时会默认按照HEKY_CURRENT_CONFIG中的配置调用相关的驱动程序;
二、以C#程序修改注册表
微软建立了Registry、RegistryKey 常用类用于修改Windows 注册表中的节点。
2.1 Registry 类
Registry 主要用作获取 Windows 注册表中的根项的 RegistryKey 对象,并提供访问项/值对的 static 方法。
它有以下几个常用的属性可直接用于获取HEKY_CUREENT_MACHINE、HKEY_CLASSES_ROOT等几个基础项
属性 | 说明 |
---|---|
ClassesRoot | 定义文档的类型(或类)以及与那些类型关联的属性。该字段读取 Windows 注册表基项 HKEY_CLASSES_ROOT。 |
CurrentConfig | 包含有关非用户特定的硬件的配置信息。该字段读取 Windows 注册表基项 HKEY_CURRENT_CONFIG。 |
CurrentUser | 包含有关当前用户首选项的信息。该字段读取 Windows 注册表基项 HKEY_CURRENT_USER |
DynData | 已过时。包含动态注册表数据。该字段读取 Windows 注册表基项 HKEY_DYN_DATA。 |
LocalMachine | 包含本地计算机的配置数据。该字段读取 Windows 注册表基项 HKEY_LOCAL_MACHINE。 |
PerformanceData | 包含软件组件的性能信息。该字段读取 Windows 注册表基项 HKEY_PERFORMANCE_DATA。 |
Users | 包含有关默认用户配置的信息。该字段读取 Windows 注册表基项 HKEY_USERS。 |
Registry属性表2.1.1
Registry 也提供几个常用方法用于获取或设置注册表中指定名称的项值。
方法 | 说明 |
---|---|
GetValue (String, String, Object) | 检索与指定的注册表项中的指定名称关联的值。如果在指定的项中未找到该名称,则返回您提供的默认值;或者,如果指定的项不存在,则返回 null。 |
SetValue(String, String, Object) | 设置指定的注册表项的指定名称/值对。如果指定的项不存在,则创建该项。 |
SetValue(String, String, Object, RegistryValueKind) | 通过使用指定的注册表数据类型,设置该指定的注册表项的名称/值对。如果指定的项不存在,则创建该项。 |
Registry方法表2.1.2
2.2 RegistryKey 类
RegistryKey类主要用于管理 Windows 注册表中的项级节点,通过 Registry 类的属性就可以获取注册表中的根节点。它包含了以下几个常用属性
属性 | 说明 |
---|---|
Handle | 获取一个 SafeRegistryHandle 对象,该对象表示当前 RegistryKey 对象封装的注册表项。 |
Name | 检索项的名称。 |
SubKeyCount | 检索当前项的子项数目。 |
ValueCount | 检索项中值的计数。 |
View | 获取用于创建注册表项的视图。 |
RegistryKey属性表2.2.1
RegistryKey类的方法比较多,通过CreateSubKey(String)、GetValue(String)、SetValue(String, Object)、DeleteValue(String)等常用方法,就可以实现对注册表的查询修改。下面简单介绍一下RegistryKey的几个常用方法
方法 | 说明 |
---|---|
Close() | 关闭该项,如果该项的内容已修改,则将该项刷新到磁盘。 |
CreateSubKey(String) | 创建一个新子项或打开一个现有子项以进行写访问。 |
CreateSubKey(String, RegistryKeyPermissionCheck) | 使用指定的权限检查选项创建一个新子项或打开一个现有子项以进行写访问。 |
CreateSubKey(String, RegistryKeyPermissionCheck, RegistryOptions) | 使用指定的权限检查和注册表选项,创建或打开一个用于写访问的子项。 |
CreateSubKey(String, RegistryKeyPermissionCheck, RegistrySecurity) | 使用指定的权限检查选项和注册表安全性创建一个新子项或打开一个现有子项以进行写访问。 |
CreateSubKey(String, RegistryKeyPermissionCheck, RegistryOptions, RegistrySecurity) | 使用指定的权限检查选项、注册表选项和注册表安全性,创建或打开一个用于写访问的子项。 |
DeleteSubKey(String) | 删除指定的子项。 |
DeleteSubKey(String, Boolean) | 删除指定的子项,并指定在找不到该子项时是否引发异常。 |
DeleteSubKeyTree(String) | 递归删除子项和任何子级子项。 |
DeleteSubKeyTree(String, Boolean) | 以递归方式删除指定的子项和任何子级子项,并指定在找不到子项时是否引发异常。 |
DeleteValue(String) | 从此项中删除指定值。 |
DeleteValue(String, Boolean) | 从此项中删除指定的值,并指定在找不到该值时是否引发异常。 |
Flush() | 将指定的打开注册表项的全部特性写到注册表中。 |
GetSubKeyNames() | 检索包含所有子项名称的字符串数组。 |
GetValue(String) | 检索与指定名称关联的值。如果注册表中不存在名称/值对,则返回 null。 |
GetValue(String, Object) | 检索与指定名称关联的值。如果未找到名称,则返回您提供的默认值。 |
GetValue(String, Object, RegistryValueOptions) | 检索与指定的名称和检索选项关联的值。如果未找到名称,则返回您提供的默认值。 |
SetValue(String, Object) | 设置指定的名称/值对。 |
RegistryKey方法表2.2.2
2.3 应用实例
下面先通过几个例子,简单介绍一下如何通过 Registry、RegistryKey 类修改系统注册表。
2.3.1 新建自定义的项
下面应用程序将会在注册表中新建 MyApplication项,并在其子项Path的默认值中保存应用程序的StartupPath、在AppPath值中保存应用程序的UserAppDataPath
1
2
3
4
5
6
7
8
9
10
11
12
13
|
static
void
Main(
string
[] args)
{
//获取Machine根项
RegistryKey machine = Registry.LocalMachine;
//打开SOFTWARE项
RegistryKey software = machine.OpenSubKey(
"SOFTWARE"
,
true
);
//新项MyApplication项
RegistryKey myApplication = software.CreateSubKey(
"MyApplication"
);
RegistryKey subkey = myApplication.CreateSubKey(
"Path"
);
//新建键值,当键值名称为空时,将被设置为默认值
subkey.SetValue(
null
, Application.StartupPath);
subkey.SetValue(
"AppPath"
, Application.UserAppDataPath);
}
|
运行应用程序后,打开"windows运行",然后输入regedit,在注册表LocalMachine项中可以查找到新建的MyApplication项
2.3.2 开机启动 “命令提示符”
注册表所包含的信息很多,其中在“HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Run”处正是用于Windows开机启动的程序信息。下面以Windows自带的“命令提示符”工具为例子,通过修改注册表实现开机启动。(说明:"命令提示符"的路径是在“C:\Windows\System32\cmd.exe”)
1
2
3
4
5
6
7
8
|
static
void
Main(
string
[] args)
{
//打开注册表子项
RegistryKey key = Registry.LocalMachine
.OpenSubKey(
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run"
,
true
);
//增加开机启动项
key.SetValue(
"Cmd"
,
"C:\\Windows\\System32\\cmd.exe"
);
}
|
修改后可以看到注册表中在“HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Run”中会增加了一个名为Cmd的键
当重启计算机时就会看到“命令提示符”将自动启动
三、在 HKEY_CLASSES_ROOT 中添加自定义协议
上面的章节已经简单介绍如何通过程序操作注册表,下面将介绍一下如果通过修改HKEY_CLASSES_ROOT中的项,建立自定义协议。
首先建立一个应用程序MyApplication,写入简单的Hello World测试代码
1
2
3
4
5
|
static
void
Main(
string
[] args)
{
Console.WriteLine(
"Hello World"
);
Console.ReadKey();
}
|
手动在注册表中建立下面的项和键:
-
1、在HKEY_CLASSES_ROOT下添加项MyApplication.
-
2、修改MyApplication项下的默认值输入"URL:(可为空)"。
-
3、在MyApplication项下再添加一个键值"URL Protocol"。(必要健,否则在IE浏览器中可能无法运行)
-
4、在MyApplication项下新建项"shell"
-
5、在shell项下新建项"open"
-
6、在open项下新建项"command"
-
7、修改command项的默认键值为MyApplication应用程序的路径 "D:\C# Projects\MyApplication.exe" "%1"
注意:把 command 键值设置为 "D:\C# Projects\MyApplication.exe" "%1",只要当中包含标示符“%1”,应用程序可以根据自定义协议的路径获取对应的参数,其使用方式将在下面的章节再详细说明。
简单测试:建立一个HTML页面,如入以下代码。
注意:此连接路径正是以注册表产首项的MyApplication名称相同。
1
2
3
4
5
6
7
8
|
<
html
xmlns
=
"http://www.w3.org/1999/xhtml"
>
<
head
>
......
</
head
>
<
body
>
<
a
href
=
"MyApplication://command"
>Hello World</
a
>
</
body
>
</
html
>
|
当按下Hello World连接符时,系统就会调用自定义协议MyApplication,启动“D:\C# Projects\MyApplication.exe”
四、通过“安装项目”方式修改注册表
4.1 建立应用程序
上面章节所介绍的只是自定义协议的简单使用方式,然而在做软件项目的时候,无论是使用C/S或者B/S方式,自定义协议都必须实现在客户端的自动安装才能使用,因此客户端的注册表设置只能通过程序进行修改。有见及此,微软早在“安装项目”中设置了注册表修改功能。下面的章节将为大家介绍如何通过 Visual Studio 2010 的“安装项目”,实现注册表的修改。
建立一个新的MyApplication应用程序,输入以下代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
[DataContract]
public
class
Person
{
[DataMember]
public
int
ID;
[DataMember]
public
string
Name;
[DataMember]
public
int
Age;
}
class
Program
{
static
void
Main(
string
[] args)
{
if
(args !=
null
)
{
//获取输入参数
string
data = args[0].Split(
'&'
)[1];
//把JSON转换成Person对象
Person person = GetPerson(data);
//数据显示
Console.WriteLine(person.Name +
"'s age is:"
+ person.Age);
Console.ReadKey();
}
}
//数据转换
static
Person GetPerson(
string
data)
{
DataContractJsonSerializer serializer =
new
DataContractJsonSerializer(
typeof
(Person));
MemoryStream stream =
new
MemoryStream(Encoding.Unicode.GetBytes(data));
Person person = (Person)serializer.ReadObject(stream);
stream.Close();
return
person;
}
}
|
4.2 添加安装项目
然后在解决方案里面添加一个“安装项目”
右键点击"安装项目",选择“视图-文件系统”后,在应用程序文件夹中添加当前的“MyApplication”项目。
4.3 修改注册表
右键点击"安装项目",选择“视图-注册表”后,按照第三节的例子在HKEY_CLASSES_ROOT上建立多个必要项。为方法获取此应用程序的安装路径,可以在MyApplication项中加入一个键值Path,绑定"[TARGETDIR]",便于系统随时能通过此键值获取应用程序的安装路径。
在“安装项目”中有多个可用的绑定值,例如:“[TARGETDIR]”用于绑定应用程序的安装路径,“[Manufacturer]”用于绑定应用程序制造商名称等等。在command的值中输入"[TARGETDIR]MyApplication.exe""%1",系统成功安装后,此值就会转换成应用程序的安装路径。例如:MyApplication应用程序安装在"D:\C# Projects"安件夹中,那么注册表的command默认值就会变成“D:\C# Projects\MyApplication.exe” “%1”。
4.4 添加安装自定义操作
在安装应用程序的前后,很多时候需要做一些必要的操作,例如存储程序的Path值,为应用程序生成一个sn文件作为标识等等。这时候就可以通过建立Installer的子类,在安装的前后的事件进行操作。
首先建立新项目InstallComponent,在项目中加入一个具备RunInstaller特性的类继承Installer类,RunInstaller特性是作用是用于指示在程序集安装期间是否调用该安装程序。而Installer类是Framework 中所有自定义安装程序的基类,它具备了以下多个方法。
方法 | 说明 |
---|---|
Commit | 在派生类中重写时,完成安装事务。 |
Install | 在派生类中被重写时,执行安装。 |
OnAfterInstall | 引发 AfterInstall 事件。 |
OnAfterRollback | 引发 AfterRollback 事件。 |
OnAfterUninstall | 引发 AfterUninstall 事件。 |
OnBeforeInstall | 引发 BeforeInstall 事件。 |
OnBeforeRollback | 引发 BeforeRollback 事件。 |
OnBeforeUninstall | 引发 BeforeUninstall 事件。 |
OnCommitted | 引发 Committed 事件。 |
OnCommitting | 引发 Committing 事件。 |
Rollback | 在派生类中重写时,还原计算机的安装前状态。 |
Uninstall | 在派生类中重写时,移除安装。 |
Installer方法表4.4.1
只要自定义的类型继承了Installer类并重写 Install、Commit、Rollback 和 Uninstall 等方法,系统就可以在应用程序安装的多个不同状态下进行操作。下面这个常用例子当中,MyInstaller加入了AfterInstall事件的处理方法,在MyApplication应用程序安装完成后,会根据注册表的Path键值获取应用程序的安装路径,并在该文件夹内加入sn文件。sn文件内存储着一个GUID,作为该应用程序的一个标识。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
[RunInstaller(
true
)]
public
partial
class
MyInstaller : Installer
{
public
MyInstaller()
:
base
()
{
//绑定完成安装事件的处理方法
this
.AfterInstall +=
new
InstallEventHandler(OnAfterInstall);
}
/// 加入安装完成后的处理方法
protected
override
void
OnAfterInstall(
object
sender, InstallEventArgs e)
{
CreateSn();
}
//在完成安装后建立一个sn文件,对该应用程序进行标识
private
void
CreateSn()
{
var
regKey = Registry.ClassesRoot.OpenSubKey(
"MyApplication"
,
true
);
if
(regKey !=
null
)
{
//根据注册表中的Path键值,获取系统在客户端的安装路径
string
path = regKey.GetValue(
"Path"
).ToString();
//建立sn文件
string
snPath = path +
"sn"
;
StreamWriter writer =
new
StreamWriter(snPath,
true
, Encoding.Unicode);
writer.Write(Guid.NewGuid().ToString());
writer.Close();
}
}
/// 重写安装过程方法
public
override
void
Install(IDictionary stateSaver)
{
base
.Install(stateSaver);
}
/// 重写卸载方法
public
override
void
Uninstall(IDictionary savedState)
{
base
.Uninstall(savedState);
}
/// 重写回滚方法
public
override
void
Rollback(IDictionary savedState)
{
base
.Rollback(savedState);
}
//重写提交方法
public
override
void
Commit(IDictionary savedState)
{
base
.Commit(savedState);
}
}
|
4.5 在安装项目中绑定自定义操作
当完成自定义操作的设定后,回到安装项目,右键点击"安装项目",选择“视图-文件系统”后,在应用程序文件夹中添加自定义操作的“InstallComponent”项目。
然后右键点击"安装项目",选择“视图-自定义操作-添加自定义操作-应用程序文件夹”,选择刚才导入的 “InstallComponent”项目。
把安装、提交、回滚、卸载等操作都与InstallComponent的MyInstaller类进行绑定。
生成安装项目后,点击setup应用程序进行系统安装,完成安装后你就会发现在注册表的HKEY_CLASSES_ROOT下将添加了MyApplication项。而且在该应用程序文件夹中会自动增加一个sn文件,里面将保存着一个CUID码。
五、自定义协议的调用
5.1 以连接方式调用
调用自定义协议的方式很多,其中最简单的就是以连接方式来调用,好像下面的例子,当页面连接地址为MyApplication://自定义协议时,系统就会自动到注册表查找该协议,根据command默认项的绑定路径对该程序进行调用。并把“MyApplication://command&{'ID':'1','Name':'Rose','Age':'26'}" 路径名作为static void main(string[] args) 方法中的其中一个参数输入。
1
2
3
4
5
6
7
8
|
<
body
>
<
script
type
=
"text/javascript"
>
window.onload = function () {
var link = "MyApplication://command&{'ID':'1','Name':'Rose','Age':'26'}";
location.href = link;
}
</
script
>
</
body
>
|
观察第4节的内容,自定义协议的main方法会把args[0]参数按照字符'&'进行分组,然后把后面的"{'ID':'1','Name':'Rose','Age':'26'}"JSON字符串转换成Person对象进行显示。
5.2 动态调用
回顾前面例子,一直都是运用最简单的连接方式对自定义协议进行调用。然而在现实的开展过程中,这种方法并不可行,因为每次调用客户端的自定义协议可能需要不同的参数,把参数直接通过连接路径来传输具有安全性问题。
最简单的解决方案是把自定义协议的路径在IHttpHandler里面生成,再通过ajax来获取,在回调函数中再调用此协议。通过此方法,协议的路径不会以文本的方式存在于客户端。除非是熟悉开发人员通过测试工具进行逐步检索,否则一般人不能通过页面找到自定义协议信息。
下面就是一个以IHttpHandler生成自定义协议的一个例子,MyHandler会根据url路径获取请求对象的id值,然后进行数据查询,并把查询到的对象转化为JSON格式。最后把自定义协议的路径返回到客户端。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
[DataContract]
public
class
Person
{
public
Person(
int
id,
string
name,
int
age)
{
ID = id;
Name = name;
Age = age;
}
[DataMember]
public
int
ID;
[DataMember]
public
string
Name;
[DataMember]
public
int
Age;
}
public
class
MyHandler : IHttpHandler
{
public
bool
IsReusable
{
get
{
return
true
; }
}
public
void
ProcessRequest(HttpContext context)
{
//通过QueryString获取id
string
data = context.Request.QueryString[
"id"
];
if
(data !=
null
)
{
int
id =
int
.Parse(data);
//根据id进行数据查找
foreach
(
var
person
in
DataSource())
{
if
(person.ID == id)
{
//把Person对象转化为JSON数据
string
json = ConvertToJson(person);
//输出自定义协议路径
context.Response.Write(GetUrl(json));
}
}
}
}
//获取自定义协议路径
private
string
GetUrl(
string
json)
{
return
"MyApplication://command&"
+ json;
}
//把Person对象转化为JSON
private
string
ConvertToJson(Person person)
{
DataContractJsonSerializer serializer =
new
DataContractJsonSerializer(
typeof
(Person));
MemoryStream stream =
new
MemoryStream();
serializer.WriteObject(stream, person);
byte
[] bytes = stream.ToArray();
stream.Close();
return
Encoding.ASCII.GetString(bytes);
}
//模拟数据源
private
IList<Person> DataSource()
{
IList<Person> list =
new
List<Person>();
Person person1 =
new
Person(1,
"Rose"
, 34);
list.Add(person1);
......
return
list;
}
}
|
客户端通过ajax把id发送到MyHandler.ashx进行查询,在回调函数中调用所获取到的自定义协议。
如果自定义协议参数中具有保密资料的信息还可以通过加密方式进行传递,好像上面的一个例子,客户端可以先把自动生成的sn发送到服务器进行记录,并与客户ID进行绑定。在请求自定义协议的路径时通过IHttpHandler把相关的信息通过sn进行加密,等到数据发送到客户端后再进行解密。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
<
head
>
<
title
></
title
>
<
script
src
=
"Scripts/jquery-1.8.2.min.js"
type
=
"text/javascript"
></
script
>
</
head
>
<
body
>
<
a
name
=
"send"
id
=
"send"
href
=
"#"
>GetPerson</
a
>
<
script
type
=
"text/javascript"
>
$(function () {
$('#send').click(function () {
$.ajax({
type: "GET",
url: "MyHandler.ashx?id=1",
data: null,
dataType: null,
success: function (data) {
location.href = data;
}
});
});
});
</
script
>
</
body
>
</
html
>
|
本章小结
自定义协议有着广泛的应用,像QQ、迅雷、淘宝等等这些的常见的应用程序都会使用自定义协议。特别在大型的企业系统开发过程中,C/S、B/S融合开发的情况很常见,这时候自定义协议更发挥其独特的作用。一般在系统自动更新,客户端信息获取等这些功能上都会使用自定义协议进行开发。相对于ActiveX控件,自定义协议不会受到浏览器的约束,更能简化客户端与浏览器之间的信息传信。
对 .NET 开发有兴趣的朋友欢迎加入QQ群:230564952 共同探讨 !
C#综合揭秘
通过修改注册表建立Windows自定义协议
Entity Framework 并发处理详解
细说进程、应用程序域与上下文
细说多线程(上)
细说多线程(下)
细说事务
深入分析委托与事件
作者:风尘浪子
http://79100812.blog.51cto.com/2689556/1413486
原创作品,转载时请注明作者及出处