本帖最后由 断天涯大虾 于 2017-5-9 14:24 编辑
本文讲述了C#开发人员应该了解到的13件事情,希望对C#开发人员有所帮助。
1. 开发过程开发过程是错误和缺陷开始的地方。使用工具可以帮助你在发布之后,解决掉一些问题。
编码标准 遵照编码标准可以编写出更多可维护的代码,特别是在由多个开发人员或团队编写和维护的代码库中。例如FxCop,StyleCop和ReSharper等,就是常用的实施编码标准的工具。 开发人员:在压缩代码之前,请使用工具仔细检查是否违反了标准,并且对结果进行分析。使用工具发现的代码路径问题,不比你预期的少。
代码审查 代码审查和结对编程是任务开发人员审查他人编写的源代码的常见做法。通过这些方式希望能够检查出作者的错误,如编码错误或实现错误。 代码审查是一个很有价值的做法,但是它依赖于人类,易犯错误,所以很难扩展。
静态分析 静态分析工具会在不运行代码的情况下分析代码,在不需要编写测试用例的情况下,查找违反编码标准或存在缺陷的问题。它们能有效地找到问题,但你需要选择出那些能够定位出有价值问题的工具,找出有价值的问题。C#静态分析工具包括Coverity,CAT.NET和Visual Studio代码分析。
动态分析 动态分析工具在运行时分析代码,帮助你查找缺陷,如安全漏洞,性能和并发问题。它分析运行时环境的上下文中的代码,因此其有效性受测试工作负载的限制。Visual Studio提供了一些动态分析工具,包括并发可视化器,IntelliTrace和分析工具。 管理人员/团队领导:利用开发最佳实践,以避免常见的陷阱。仔细考虑可用的工具,以确保它们与你的需求和文化兼容。
测试 有许多类型的测试,例如:单元测试,系统集成测试,性能测试,渗透测试。在开发阶段,大多数测试由开发人员或测试人员编写,以验证应用程序是否满足其要求。 测试仅在它们运行正确的代码时有效。在实现功能和测试的同时,保持开发速度是具有挑战性的。
开发最佳实践 投入时间来识别和配置工具,以便找到你关心的代码问题,无需为开发人员带来更多的工作。经常自动运行分析工具和测试,以确保开发人员在代码刚写完不久,就能定位到问题。 尽快地定位到所有的诊断输出 - 无论是编译器警告,标准违例,通过静态分析识别的缺陷,还是测试失败。如果新的诊断全部是可忽略的,那么审查所起的作用就增加了,开发人员也不必再为代码问题烦恼。 采用这些最佳实践有助于提高代码的质量,安全性和可维护性,开发人员的一致性和生产力以及发布的可预测性。 关心 | 工具 | 影响 | 一致性,可维护性 | 编码标准,静态分析,代码审查 | 一致的间距,命名和格式化提高了可读性,并使开发人员更容易编写和维护代码。 | 正确性 | 代码审查,静态分析,动态分析,测试 | 代码不仅需要在语法上有效,而且必须按照开发人员的意图并满足项目需求。 | 功能 | 测试 | 测试验证代码是否满足要求,如正确性,可扩展性,鲁棒性和安全性。 | 安全 | 编码标准,代码审查,静态分析,动态分析,测试 | 安全是一个非常复杂的问题; 任何弱点或缺陷都可能被利用。 | 开发人员生产力 | 编码标准,静态分析,测试 | 当他们有工具来识别错误时,开发人员更快地实现代码更改。 | 释放可预测性 | 编码标准,代码审查,静态分析,动态分析,测试 | 简化后期活动,尽早解决缺陷和问题,尽可能缩短修复周期。 |
2. 类型陷阱C#的一个主要优势是其灵活的类型系统; 类型安全有助于早期发现错误。通过强制实施严格的类型规则,编译器能够帮助你保持正确的编码实践。C#语言和.NET框架提供了丰富的类型集合以适应最常见的需求。大多数开发人员很好地了解常见的类型及其用途,但有一些常见的误解和误用。 有关.NET Framework类库的更多信息可以在MSDN库中找到。
了解和使用标准接口 某些接口涉及常用的C#特性。例如,IDisposable允许使用常用的资源处理习语,例如“using”关键字。理解什么时候使用接口,能够使你编写更容易维护的C#代码。 避免ICloneable - 设计者从来没有明确拷贝的对象是深拷贝还是浅拷贝。由于没有正确拷贝对象行为的标准,也就无法有效的使用这样的接口。
结构 尽量避免写到结构体。将它们视为不可变的,能够防止混淆的发生,并且在共享内存的场景(如多线程应用程序)下更安全。相反,在创建结构体时使用初始化对象,如果需要更改值,则创建新的实例。 要了解哪些标准类型/方法是不可变的并返回新值(例如,string,DateTime)和哪些是可变的(List.Enumerator)。
字符串 字符串可以为null,因此在适当时,使用起来很方便。等价(s.Length == 0)可能会抛出一个NullReferenceException,但是String.IsNullOrEmpty(s)和String.IsNullOrWhitespace(s)函数能够优雅地处理null。
标记枚举 枚举类型和常量值是能表露出自己含义的标识符,用于替换魔术数字,以便使得代码更加可读。 如果你发现需要创建枚举的集合,标记枚举可能是一个更简单的选择: - [Flag]
- public enum Tag {
- None =0x0,
- Tip =0x1,
- Example=0x2
- }
复制代码这使你能够轻松地为代码段添加多个标签: snippet.Tag = Tag.Tip | Tag.Example
这可以改善数据封装,因为你不必担心通过Tag property getter暴露内部集合。
等价比较 有两种类型的等价: - 引用相等,这意味着两个引用,引用了同一个对象。
- 值平等,这意味着两个不同的对象是等值的。
此外,C#提供了多种方法来测试等价。最常见的方法是使用: - ==和!=运算符
- 继承自Object的虚拟Equals方法
- 静态Object.Equals方法
- IEquatable接口的Equals方法
- 静态Object.ReferenceEquals方法
可能难以知道预期的是引用相等还是值相等。如果你重写Equals,不要忘记IEquatable <T>,GetHashCode(),如MSDN中所述。 注意无类型容器对重载的影响。考虑比较“myArrayList [0] == myString”。数组列表元素是编译时类型“对象”,因此使用引用等价。C#编译器会警告你这个潜在错误,但是有许多类似的情况,编译器不会对意外的引用相等发出警告。
3. 类陷阱封装你的数据 类负责正确地管理数据。出于性能原因,它们通常缓存部分结果或者对其内部数据的一致性做出假设。数据公开访问会影响你缓存或做出假设的能力,对性能,安全性和并发性都有潜在影响。例如,暴露可变成员,如通用集合和数组,允许用户在你不知情的情况下修改这些结构。
属性 属性使你能够精确控制用户如何与你的对象进行交互,除了你通过访问修改器控制的之外。具体来说,属性使你能够控制读取和写入时发生的情况。 属性使你能够建立稳定的API,同时重写getter和setter中的数据访问逻辑,或提供数据绑定源。
不要也不要让属性获取器抛出异常,避免修改对象状态。这样就意味着需要一种方法而不是属性获取器。
仔细的使用getters,因为它有副作用。开发者认为成员访问是一个微不足道的操作,所以他们经常忘记在代码审查期间考虑带来的副作用。
对象初始化 你可以在创建表达式时,对新创建的对象设置属性。使用特定值来创建Class Cde 对象,并用到Foo和Bar属性: - new C {Foo=blah, Bar=blam}
复制代码你还可以使用特定的属性名称,创建匿名类型的实例: - var myAwesomeObject = new {Name=”Foo”, Size=10};
复制代码
初始化会在构造主体运行之前执行,确保在进入构造器之前字段已经初始化了。因为构造函数还没有运行,所以字段初始化器不能以任何方式引用“this”。
过度指定输入参数 为了帮助防止特定方法的过度使用,请尝试采用方法所需的最小特定类型。例如,考虑一个迭代List <Bar>的方法: - public void Foo(List<Bar> bars)
- {
- foreach(var b in bars)
- {
- // do something with the bar...
- }
- }
复制代码对于其他的IEnumerable <Bar>集合,这段代码能够很好地运行,但是通过为参数指定List <Bar>,你就需要集合必须是一个List。选择参数的最小特定类型(IEnumerable <T>,ICollection <T>等),以确保方法的最大有用性
4. 泛型泛型是一种十分有效的方式,来定义与类型无关的结构体和确保类型安全的算法。 使用诸如List <T>之类的泛型集合,而不是无类型的集合如ArrayList,能够提高类型的安全性和性能。
当实现泛型类型时,可以使用“default”关键字来获取那种无法硬编码到实现中的默认值。具体来说就是,数字类型的默认值为0; 引用和可空值类型的默认值为null。 T t = default(T);
5. 类型转化有两种类型的conversions转化。显式转换必须由开发人员调用,隐式转换由编译器基于上下文来应用。 [td]Cast | 描述 | Tree tree =(Tree)obj; | 如果obj是tree类型时,请使用这个。如果obj不是Tree类型,将产生一个InvalidCast异常。 | Tree tree = obj as Tree; | 当你无法确定obj是否是Tree类型时,请使用这个。如果obj不是Tree类型,将会给Tree分配一个空值。在必要时,请使用这种转换方式,因为它需要对返回值进行条件处理。这些额外的代码可能产生更多的错误,使得代码更难以读取和调试。 |
类型转化时,经常会遇到以下两种情形: - 表达式的运行时类型比编译器能推断出的类型更加具体。转换指示编译器将表达式当做更具体的类型来处理。如果你的假设不正确,编译器将抛出异常的代码。例如,从对象到字符串的转换。
- 转换指示编译器会生成关联表达式的值的代码,如果没有生成,则会抛出异常。例如,从double到integer的转换。
两种类型转换都是很危险的。第一种类型的转换提出了一个问题,“为什么开发人员知道,而编译器不知道?”如果在这种情况下,尝试更改程序,以便编译器可以成功地推导出正确的类型。如果你认为一个对象的运行时类型可能比编译时类型更具体,那么你可以使用“is”或“as”运算符。
第二种类型转换引发了一个问题,“为什么操作是在开始的地方执行的,而不是在目标数据类型?”如果你需要一个int类型的结果,使用int比double更有意义。 在显式转换是正确的操作情况下,通过使用适当的运算符来提高可读性,调试能力和可测试性。
6. 异常异常不是条件 异常通常不应用于控制程序流; 它们代表的是,在运行时你可能无法恢复的意外情况。如果你预期你应该处理的情况,主动检查情况,而不是等待异常发生。 要将格式不正确的字符串正常转换为数字,请使用TryParse()方法; 而不是抛出异常,它返回一个布尔值,指示解析是否成功。
使用异常处理范围 在catch内部写代码,并且仔细处理成程序块。已执行过的代码已经不存在这些异常。例如:
- Frobber originalFrobber = null;
- try {
- originalFrobber = this.GetCurrentFrobber();
- this.UseTemporaryFrobber();
- this.frobSomeBlobs();
- }
- finally {
- this.ResetFrobber(originalFrobber);
- }
复制代码如果GetCurrentFrobber()抛出异常,那么当finally block被执行时,originalFrobber仍然为null;
明智的处理异常 只捕获你准备处理的特定异常,并且只针对特定代码段。除非你的意图只是简单的记录并重新抛出异常。某些例外可能使应用程序处于一种状态,那么就需要避免处理所有异常或根类异常的实例。最好是在没有进一步损坏的情况下应用已经崩溃,而不是试图恢复并造成损害。你的恢复尝试可能会在不经意间使事情更糟。
使用最高级异常处理,来安全到处理程序的意外情况并公开信息以帮助调试问题。请谨慎使用catch块来解决本可以安全处理的特定情况,为无法预料的异常预留最高级的处理。
如果你捕获到一个异常,那么就需要采取一些措施来处理。不计其它后果地处理当前异常只会使问题难以识别和调试。 对于公开了工作API的代码来说,将异常包含于自定义异常中,是特别有用的。异常是方法的可见接口的一部分,应该与参数和返回值一起被控制。可能导致更多异常的方法,是不应该被使用在可维护解决方案中的。
抛出和重新抛出异常 当你希望在更深层次处理一个捕获到的异常时,维护原始异常状态和堆栈对于调试有极大的帮助。需要仔细地平衡,调试和安全注意事项。
简单的重新抛出异常也是一个好选择: throw; 或者在新的throw中使用异常作为InnerException: throw new CustomException(...,ex); 不要显式地重新抛出捕获的异常,如下所示: throw e; 这将复位异常状态到当前行,并且阻止调试。
一些异常发生在代码的上下文之外。对于这些情况,你可能需要添加事件的处理程序,如ThreadException或UnhandledException,而不是使用catch块。例如,表单处理程序线程的上下文中引发的Windows窗体异常。 数据完整性 异常不得影响数据模型的完整性。你需要确保你的对象处于一致的状态 - 不会违反类实现所做的任何假设。否则,通过“恢复”,你只能使你的代码变得混乱,之后还会导致进一步的损害。
7. 事件事件和代理相互协助,当事件发生时,为类提供了一种方法来通知用户。事件类似于委托类型的字段; 当创建对象时,它们将自动初始化为null。 事件的值是一个多级代理。也就是一个可以依次调用其他代理的代理。你可以为事件分配委托; 可以通过+ =和 - =等操作符操作事件。
注意竞逐条件 如果事件在线程之间共享,则有可能在你检查null之后并且在调用它之前,另一个线程将删除所有参数 – 就会抛出NullReferenceException异常。 标准解决方案是创建事件的本地副本,用于测试和调用。你仍然需要小心,在其他线程中删除的任何参数,在他们的委托被意外调用时会正常运行。你还可以实施锁定,以一种能够避免问题的方式为操作排队列。 - public event EventHandler SomethingHappened;
- private void OnSomethingHappened()
- {
- // The event is null until somebody hooks up to it
- // Create our own copy of the event to protect against another thread removing our subscribers
- EventHandler handler = SomethingHappened;
- if (handler != null)
- handler(this,new EventArgs());
- }
复制代码
8. 属性属性提供了一种方法,用于将组件,类和属性的元数据与其属性的信息一起输入。它们通常用于向代码用户提供信息,如代码调试器,测试框架和应用程序。你可以定义自己使用的属性,也可以使用表中列出的预定义属性。 属性 | 使用 | 目的 | Debugger显示 | 调试器 | 调试器显示格式 | InternalsVisibleTo | 会员访问 | 能将内部成员暴露给特定的其他类。使用它,测试例程可以访问受保护的成员。 | 默认值 | 属性 | 指定属性的默认值。 |
小心使用DebuggerStepThrough属性,如果应用了这个属性,会导致很难在方法中找到bug,因为你不能单步执行或打断它们!
9. 调试调试是任何开发工作中重要的组成部分。除了提供对运行时环境的常规不透明方面的可见性之外,调试器可以进入运行时环境,同时调试器还会导致应用程序的在没有调试器的情况下,获的不同的结果。
使异常堆栈可见 要查看当前框架的异常状态,可以在Visual Studio Watch窗口中添加表达式“$ exception”。此变量包含当前异常状态,类似于你在catch块中看到的情况,除非你可以在调试器中看到异常状态,否则就不必在代码中实际捕获异常。
注意访问器中的副作用 如果你所使用的属性有副作用,请考虑是否应使用属性或调试器设置,来防止调试器自动调用getter。例如,你的类可能具有这些属性: - private int remainingAccesses = 10;
- private string meteredData;
- public string MeteredData
- {
- get
- {
- if (remainingAccesses-- > 0)
- return meteredData;
- return null;
- }
- }
复制代码第一次在调试器中查看此对象时,remainingAccesses将显示为值10,MeteredData显示为null。如果你将鼠标悬停在remainingAccesses上,你会看到它的值现在是9。调试器显示的属性值已经改变了对象的状态。
10. 优化早做计划,经常衡量,然后优化 在设计期间设置合理的性能目标。在开发期间,专注于正确性而不是细微优化。经常根据目标衡量你的效果。如果你没有达到目标,则应该花费时间来优化程序。
始终采用最合适的工具,在具有可重复性和尽可能接近用户所经历的实际条件的情况下,对性能进行经验性测量。 由于CLR优化,有时效率低下的代码实际上比高效的代码运行速度更快。例如,CLR优化覆盖了整个数组的循环,以避免隐式的单元范围检查。开发人员通常在循环数组之前计算长度: - int[] a_val = int[4000];
- int len = a_val.Length;
- for (int i = 0; i < len; i++)
- a_val[i] = i;
复制代码通过将长度放在变量中,CLR可能无法识别模式,并将跳过优化。手动优化违反了直觉,会导致性能较差。
构建字符串 如果你要做很多字符串连接,应该使用System.Text.StringBuilder对象,这样可以避免构建许多临时字符串对象。
对集合使用批处理操作 如果需要创建和填充已知大小的集合,请在创建集合时保留空间,以避免由于重复重新分配而导致的性能和资源问题。你可以使用AddRange方法(如List <T>中的方法)进一步提高性能: - Persons.AddRange(listBox.Items);
复制代码
11. 资源管理垃圾回收器能够自动清理内存。即使如此,所有一次性资源,也必须妥善处理 - 特别是那些不由垃圾收集器管理的资源。 资源管理问题的常见来源 | 内存碎片 | 如果没有足够大的连续块的虚拟地址空间,分配将失败。 | 过程限制 | 进程通常访问系统可用的内存和资源的严格子集。 | 资源泄漏 | 垃圾回收器只管理内存。其他资源需要由应用程序正确管理。 | 资源困境 | 依赖于垃圾收集器和终结器的资源在不再使用时,不会变得立即可用。事实上,它们可能永远不可用。 |
使用try / finally块来确保资源正确释放,或让你的类实现IDisposable,并利用更清洁和更安全的using语句。 - using (StreamReader reader=new StreamReader(file))
- {
- //your code here
复制代码避免代码中使用垃圾收集器 尽量不要通过调用GC.Collect()干扰垃圾收集器,而应该将重点放在正确释放或处置资源。当测量性能时,如果你能够正确的评估影响,在小心的让垃圾收集器运行。
避免编写终结器 不同于最流行的错误认知,你的类不需要一个Finalizer,仅仅是因为它实现IDisposable!你可以实现IDisposable以使你的类能够在任何所有的复合实例上调用Dispose,但是终结器只应在直接拥有非托管资源的类上实现。
Finalizer主要用于调用interop API,来处理Win32句柄,SafeHandle更容易处理。 你不能推测你的终结器 - 它总是在终结器线程上运行 - 可以安全地与其他对象交互。那些其他对象本身可能已经完成了。
12. 并发并发和多线程编程是一件很复杂和困难的事情。在向应用程序添加并发之前,请确保你真正了解自己正在做什么 - 有很多细微之处需要了解! 多线程应用程序非常难以推理,并且容易受到诸如通常不影响单线程应用程序的竞争条件和死锁等问题的影响。鉴于这些风险,你应该最后才考虑多线程。如果你必须使用多个线程,请尽量通过不在线程之间共享内存来最小化同步的需要。如果必须同步线程,请使用最高级别的同步机制。 最高级别,这些机制包括: - Async-await/Task Parallel Library/Lazy<T>
- Lock/monitor/AutoResetEvent
- Interlocked/Semaphore
- Volatile fields and explicit barriers
C#/ .NET中并发的复杂性很难就在这里解释清楚。如果你想要或需要开发一个利用并发的应用程序,请查看详细的文档,如O'Reilly的“Concurrency in C# Cookbook”。
使用volatile 将字段标记为“易变”是高级功能,即使专家也经常误解。C#编译器将确保访问字段具有获取和释放语义; 这不同于确保对该字段的所有访问都处于锁定状态。如果你不知道什么是获取和释放语义,以及它们如何影响CPU级优化,则应避免使用volatile字段。相反,应该使用较高级别的工具,如任务并行库或CancellationToken类型。
利用线程安全的内置方法 标准库类型通常提供方便线程安全访问对象的方法。例如,Dictionary.TryGetValue()。使用这些方法通常使你的代码更清洁,你不需要担心如TOCTTOU or TOCTOU场景等数据竞争的情况。
不要锁定“this”,字符串或其他常见的公共对象 当实现在多线程上下文中使用的类时,要非常小心使用锁。锁定此字符串或其他公共对象,会阻止封装锁定状态,并可能导致死锁。你需要防止其他代码锁定你的实现上正在使用的对象; 最安全的做法是使用一个私人的对象成员。
13. 避免常见错误引用null 不适当的使用null,是编码缺陷的常见来源,可能会导致程序崩溃和其它意外行为。如果你尝试访问一个空引用,以为它是一个对象的有效引用一样 - 例如,通过访问一个属性或方法,运行时将抛出一NullReferenceException异常。
静态和动态分析工具可以帮助你在发布代码之前识别潜在的NullReferenceException异常。在C#中,空引用通常由尚未引用对象的变量引起。对于空值类型和引用类型来说,Null是一个有效值。例如,Nullable <Int>,空委托,取消订阅事件,会在“as”转换,以及在许多其他情况下失败。 每个空引用异常是都一个错误。不应该去捕获NullReferenceException,而应该尝试在使用它们之前测试对象是否为null。这也使得代码更容易被最小化try / catch块读取。
从数据库表中读取数据时,请确保,缺失值可以表示为DBNull对象,而不是空引用。不要指望它们表现的像潜在的空引用。
将十进制值替换为二进制数 浮点数和双精度表示二进制有理数,不是小数有理数,在存储十进制值时必须使用二进制的近似值。从十进制的角度来看,这些二进制近似具有不一致的舍入和精度 - 有时导致算术运算的意外结果。因为浮点运算通常在硬件中执行,硬件条件可能会不可预测地加剧这些差异。 当小数精度非常重要时,使用十进制,就像财务计算等情况。
修改结构 一个常见的错误情况是忘记结构体是值类型的,这就意味着它们被复制了并且通过值来进行传递。假设你有这样的代码: - struct P { public int x; public int y; }
- void M()
- {
- P p = whatever;
- …
- p.x = something;
- …
- N(p);
复制代码有一天,维护者决定将代码重构为: - void M()
- {
- P p = whatever;
- Helper(p);
- N(p);
- }
- void Helper(P p)
- {
- …
- p.x = something;
复制代码现在当在M()中调用N(p)时,p具有错误的值。调用助手(p)传递p的副本,而不是p的引用,因此Helper()中执行的变化将丢失。相反,Helper会返回修改的p的副本。
意外的算术 C#编译器保护你出现常量的算术溢出,但不一定是计算值。
忽略保存返回值 与结构体不同,类是引用类型,方法可以修改引用的对象。然而,不是所有的对象方法都实际修改了引用的对象,一些会返回一个新对象。当开发人员调用后者时,他们需要记住将返回值赋给变量,以便使用修改后的对象。在代码审查期间,这种类型的问题通常在会被发现。一些对象,如字符串,是不可变的,所以方法从不修改这些对象。即使如此,开发人员也会通常忘记。 例如,考虑string.Replace(): - string label = “My name is Aloysius”;
- label.Replace(“Aloysius”, “secret”);
复制代码代码打印“我的名称是Aloysius”,因为Replace方法不修改字符串。
不要使迭代器/枚举器变得无效 不要在迭代时修改集合。 - List<Int> myItems = new List<Int>{20,25,9,14,50};
- foreach(int item in myItems)
- {
- if (item < 10)
- {
- myItems.Remove(item);
- // iterator is now invalid!
- // you’ll get an exception on the next iteration
复制代码如果你运行这个代码,一旦循环到集合中的下一个项目时。你会收到一个异常抛出。 正确的解决方案,是使用第二个列表来保存你要删除的项目,然后在删除时迭代该列表: - List<Int> myItems = new List<Int>{20,25,9,14,50};
- List<Int> toRemove = new List<Int>();
- foreach(int item in myItems)
- {
- if (item < 10)
- {
- toRemove.Add(item);
- }
- }
- foreach(int item in toRemove)
- {
复制代码或者如果你使用C#3.0或更高版本,你可以使用List <T> .RemoveAll。 就像这样: - myInts.RemoveAll(item => (item < 10));
复制代码
属性名称错误 在实现属性时,请注意属性名称不同于类中使用的数据成员。在访问属性时,容易意外使用相同的名称,并导致出现无限递归的情况。 - // The following code will trigger infinite recursion
- private string name;
- public string Name
- {
- get
- {
- return Name; // should reference “name” instead.
复制代码当重命名间接属性时要小心。例如,WPF中的数据绑定,会将属性名称指定为字符串。如果不小心更改该属性名称,你将会无意中创建了一个编译器无法防护的问题。
以上就是所有C#开发人员应该知道的13件事情。
了解了C#开发中应该知道的13件事情,有助于我们更好地使用C#进行开发,当然在开发时,也可以借助一些使用C#编写的开发工具。如ComponentOne Studio Enterprise,这是一款专注于企业应用的.NET全功能控件套包,支持WinForms、WPF、UWP、ASP.NET MVC等多个平台,帮助、在缩减成本的同时,提前交付丰富的桌面、Web和移动企业应用。
原文链接: https://dzone.com/refcardz/csharp
|