首先允许我们问一个严肃的问题?为什么Java初学者能够方便的从网上找到相对应的开发建议呢?
每当我去网上搜索想要的建议的时候,我总是能发现一大堆是关于基本入门的教程、书籍以及资源。
同样也发现网上到处充斥着从宽泛的角度描述一个大型的企业级项目:如何扩展你的架构,使用消息总线,如何与数据库互联,UML图表使用以及其它高层次的信息。
这时问题就来了:我们这些有经验的(专业的)Java开发者如何找到合适的开发建议呢?现在,这就是所谓的灰色区域,当然同样的也很难找到哪些是针对于资深开发者、团队领导者以及初级架构师的开发建议。
你会发现网上那些纷杂的信息往往只关注于开发世界的一角,要么是极致(甚至可以说变态级别)地关心开发代码的细节,要么是泛泛而谈架构理念。这种拙劣的模仿需要有一个终结。
说了半天,大家可能明白我希望提供的是那些好的经验、有思考的代码、和一些可以帮助从中级到资深开发者的建议。
本文记录了在我职业生涯里发现的那些有经验的开发者最常犯的10个问题。发生这些问题大多是对于信息的理解错误和没有特别注意,而且避免这些问题是很容易的。
让我们开始逐个讨论这些你可能不是很容易注意的问题。我之所以会用倒序是因为第一个问题给我带来了最大的困扰。但所有这10个问题(考虑一些额外的因素)对于你而言来说都有可能给你造成困扰(信不信由你);-)。
10、错误地使用或者误解了依赖式注入
对于一个企业级项目来说,依赖式注入通常被认为是好的概念。存在一种误解——如果使用依赖注入就不会出现问题。但是这是真的吗?
依赖式注入的一个基本思想是不通过对象本身去查看依赖关系,而是通过开发者以在创建对象之前预先定义并且初始化依赖关系(需要一个依赖式注入框架,譬如Spring)。当对象真正被创建时,仅仅需要在构造函数中传入预先配置好的对象(构造函数注入)或者使用方法(方法注入)。
然而总的思想是指仅仅传递对象需要的依赖关系。但是我依然可以在一些新的项目里发现如下的代码:
public class CustomerBill {
//Injected by the DI framework
private ServerContext serverContext;
public CustomerBill(ServerContext serverContext)
{
this.serverContext = serverContext;
}
public void chargeCustomer(Customer customer)
{
CreditCardProcessor creditCardProcessor = serverContext.getServiceLocator().getCreditCardProcessor();
Discount discount = serverContext.getServiceLocator().getActiveDiscounts().findDiscountFor(customer);
creditCardProcessor.bill(customer,discount);
}
}
当然,这不是真正的依赖注入。因为这个对象始终需要由它自己进行初始化。在上面的代码中 “serverContext.getServiceLocator().getCreditCardProcessor()”这一行代码更加体现了该问题。
译注:作者指的是 creditCardProcessor、discount 这两个变量的初始化。
当然,最好的方式应该是只注入那些真正需要的变量(最好是标记为final):
public class CustomerBillCorrected {
//Injected by the DI framework
private ActiveDiscounts activeDiscounts;
//Injected by the DI framework
private CreditCardProcessor creditCardProcessor;
public CustomerBillCorrected(ActiveDiscounts activeDiscounts,CreditCardProcessor creditCardProcessor)
{
this.activeDiscounts = activeDiscounts;
this.creditCardProcessor = creditCardProcessor;
}
public void chargeCustomer(Customer customer)
{
Discount discount = activeDiscounts.findDiscountFor(customer);
creditCardProcessor.bill(customer,discount);
}
}
译注:请注意两段代码的区别在于对于代码中需要的资源的范围。从使用依赖注入的角度来看,前一段代码中注入的范围很大,那就意味着有了更多的变化空间,但是容易造成代码的功能不单一,同时增加了代码测试的复杂度。后一段代码中注入的范围就很精确,代码简单易懂测试起来也比较容易。
9、像使用perl一样来使用Java
(跟其它编程语言比较)Java提供了一个好的属性,就是它的类型安全性。可能在一些小型项目中开发者只有你自己,你可以使用任何喜欢的编程风格。
但如果是一个代码量很大以及复杂系统的Java项目中, 在错误发生时你需要早一些得到警示。
大多数的错误应该在编译阶段而不是在到运行期就被发现(如果你对Java不甚了解,请阅读Java的相关资料)。
Java提供了许多特性去辅助产生这些编译器的警告。但是如果你写出下面的代码编译器还是没有办法捕获到对应的警告:
public class AnimalFactory {
public static Animal createAnimal(String type)
{
switch(type)
{
case "cat":
return new Cat();
case "cow":
return new Cow();
case "dog":
return new Dog();
default:
return new Cat();
}
}
}
译注:请注意这段代码只能工作在jdk 1.7 下。JDK 1.7以下的版本编译不能通过。
这段代码是非常危险,而且编译器不会产生任何的警告帮到你。一个开发者也许会调用工厂方法以一个错误拼写“dig”创建一个Cat对象。但实际上,他需要的是一个Dog对象。
这段代码不但会编译通过,而且错误往往只能在运行期被发现。更严重的是,这个错误的产生依赖于应用程序本身的特性,因而有可能在程序上线几个月以后才能发现它。
你是否希望Java编译器可以通过某种机制帮你提前捕获到这样错误呢?这里提供一个更正确的方式来确保代码只有被正确的使用的情况下才能编译通过(当然还有其他的解决方案)。
public class AnimalFactoryCorrected {
public enum AnimalType { DOG, CAT,COW,ANY};
public static Animal createAnimal(AnimalType type)
{
switch(type)
{
case CAT:
return new Cat();
case COW:
return new Cow();
case DOG:
return new Dog();
case ANY:
default:
return new Cat();
}
}
}
译注:
这里使用了java enum类型。
由于对Perl语言不慎了解,猜测作者隐含的意思是perl语言如果按照第一种写法,被错误调用的时候是否在编译器就会报错。
如果知道的人可以帮忙解释一下。
8、像C语言一样使用Java (即不理解面向对象编程的理念)
回到C语言编程的时代,C语言建议用过程化的形式来书写代码。开发者使用结构体存储数据,通过函数来描述那些发生在数据上的操作。这时数据是愚笨的,方法反而是聪明的。
译注:作者估计是想说,数据和函数是分离的没有直接的上下文来描述之间的关系。
然而Java正好是反其道而行。由于Java是一门面向对象的语言,在创建类的时候数据和函数被聪明地绑定在一起。
然而大多数的Java开发者要么不理解上述两门语言之间的区别,要么就是他们讨厌编写面向对象代码。虽然他们知道过程化开发方式与Java有些格格不入。
一个在Java应用程序中,最显而易见的过程化编程就是使用instanceof,并在随后的代码中判断向上转换或向下转换。instanceof有它合适使用的情况,但在企业级的代码中通常它是一个严重的反模式示例。
下面的示例代码描述了这种情况:
public void bill(Customer customer, Amount amount) {
Discount discount = null;
if(customer instanceof VipCustomer)
{
VipCustomer vip = (VipCustomer)customer;
discount = vip.getVipDiscount();
}
else if(customer instanceof BonusCustomer)
{
BonusCustomer vip = (BonusCustomer)customer;
discount = vip.getBonusDiscount();
}
else if(customer instanceof LoyalCustomer)
{
LoyalCustomer vip = (LoyalCustomer)customer;
discount = vip.getLoyalDiscount();
}
paymentGateway.charge(customer, amount);
}
使用面向对象的来重构以后的代码如下:
public void bill(Customer customer, Amount amount) {
Discount discount = customer.getAppropriateDiscount();
paymentGateway.charge(customer, amount);
}
译注:这里可以认为使用设计模式当中的Factory method模式。
每个继承Customer(或者实现Customer接口)定义了一个返回折扣的方法。这样做的好处在于你可以假如新的类型的Customer而不需要关系customer的管理逻辑。而使用instanceof的判断每次添加一个新的类型的Customer意味着你需要修改customer打印代码、财务代码、联系代码等等,当然同时还需要添加一个If判断。
你可能也需要查看一下关于充血模型vs贫血模型的讨论。
7、滥用延迟初始化 (即不能真正的理解对象的生命周期)
我经常能发现如下的代码:
public class CreditCardProcessor {
private PaymentGateway paymentGateway = null;
public void bill(Customer customer, Amount amount) {
//Billing a customer always needs a payment gateway anyway
getPaymentGateway().charge(customer.getCreditCart(),amount);
}
private PaymentGateway getPaymentGateway()
{
if(paymentGateway == null)
{
paymentGateway = new PaymentGateway();
paymentGateway.init(); //Network side Effects here
}
return paymentGateway;
}
}
延迟初始化初衷是好的,即如果你有个非常昂贵的对象(譬如对象需要网络连接或者连接Web API等等),当然应该只在需要的时候创建它。
然而,在你的项目中使用这项技术的时候最好确认以下两点:
这个对象真的很“昂贵”(你是如何给出这样的结论或者定义?)
存在这个对象不被使用的情况 (确实不需要创建这个对象)
在实际开发中,我不断发现延迟初始化被用在对象上。但实际上,这样的对象要么不是真的那么“昂贵”,要么总是在运行期创建。延迟初始化这种对象能得到什么好处呢?
过度使用延迟初始化的主要问题在于它隐藏了组件的生命周期。一个经过良好搭建的应用程序应该对它主要部件的生命周期有清晰的了解。应用程序需要非常清楚对象什么时候应该被创建、使用和销毁。依赖注入可以帮助定义对象的生命周期。
但依赖注入在对象创建时也有副作用。使用以来注入表明应用程序状态依赖于对象被创建的顺序(按照要求的类型顺序)。由于涵盖了过多的用例,对应用程序调试就变成了一件不可能完成的事情。复现生产环境也变成一项巨大的工程,因为你必须十分清楚场景执行的顺序。
相应的我们需要做的就是定义应用程序启动时需要的所有对象。这也带来了一个额外的好处,可以在应用程序发布过程中捕获任何致命的错误。
6、把GOF(俗称四人帮)当作圣经
我十分羡慕设计模式的几位作者。这本书籍以其他书籍所无可比拟的气势影响了整个IT界。如果你没看过《设计模式》,没有记住模式的名字或者准则的话,那么在面试中就可能无法通过。期望这样的错误可以慢慢改善。
不要误解我,这本书本身是没有问题的。问题出在人们如何解释以及使用它。下面是通常场景:
架构师马克,拿到这本书开始阅读。他觉得这本书牛逼坏了!
马克趁热打铁开始阅读现在工作的代码。
马克选择了一种设计模式并应用到了代码当中。
随后马克把这本书推荐给了那些跟他重复同样步骤的资深开发者。
结果就是一团糟。
如何正确使用这本书实际上已经在导读中做了清晰的说明(提醒那些不看导读的人)——“在过去你有个问题,而且这个问题总是一遍又一遍地困扰着你”。注意到其中的顺序了吗?先有一个问题,然后查看这本书之后找到对应的解决方案。
不要掉进看这本书的陷阱当中——“找到一个方案然后尝试把它应用在自己的的代码中。尤其要注意的是,一些书中描述的模式在现实当中已经不再正确。”
5、单例模式-已经成为反模式之一
译注:汗颜,我很难相信这个结论,不知道这句话的出处。
我已经在上面设计模式的讨论中提到了这一点,但单例模式需要单独说明。请跟着我重新下面的话:
单例模式是一个反模式 单例模式是一个反模式 单例模式是一个反模式 单例模式是一个反模式 单例模式是一个反模式……
单例模式以前有它们适用的场景。但是现在随着依赖注入框架的流行,单例模式已经可以完全剔除了。当你使用单例的时候,实际上正在向你的代码引入一系列新的问题。
之所以这么说,理由如下:
单例模式在类中引入了隐含的依赖关系。
单例模式让代码变得不便于测试(即使用mock帮助)
单例模式把资源创建与资源获取给混在一起了
单例模式容易带来全局状态副作用(译注:这个实际上好理解,即单例实际上引入了全局状态,那么状态更改失败或者发生错误容易造成整个应用程序的行为不可确定)
单例模式容易带来并发问题。
如果你始终不相信我的话可以搜索一下,可以发现有一大堆的文章在讲述为什么单例模式是一个反模式。
译注:真的找到了许多文章
http://Java.dzone.com/articles/singleton-design-pattern-%E2%80%93
http://stackoverflow.com/questions/11292109/why-is-singleton-considered-an-anti-pattern-in-Java-world-sometimes
4、忽略方法的可见性(作用域)
我一直羡慕那些有经验的Java开发者,他们认为Java只有3中方法可见性(作用域)修饰符。好吧,其实应该是四种,还有一个称为包缺省修饰符。但是这里我不想去解释这些事情,相反需要注意的是其它方面。
我真正想说的是,你需要特别注意公有方法。由于公有方法对整个程序来说都是可见的,所以这些方法需要尽可能足够简练,在编写类库时尤其如此(可以看一下SOLID准则)。
我很讨厌看见那些本应该是私有的方法被设计成公有方法。不仅仅因为这样对外界暴露了类的内部实现细节,更因为这些方法不能够也不应该被外界使用。
一个“经典”的结论是,你应该不停地对全局方法进行单元测试。我看到许多所谓的架构师相信把那些私有方法变成公有方法是可以接受的,仅仅因为这样做可以方便单元测试。
测试私有方法根本上就是错误的。正确的办法应该是通过测试公有方法来隐含地测试私有方法。需要提醒的是,如果只是为了单元测试,任何情况都不可以把类的私有方法变为公有方法(考虑使用方法覆盖method overriding)。
3、总是使用项目定制的StringUtils
这样的情况存在于一些早期的Java项目中,你会发现很多StringUtils、DateUtils、FileUtils等工具类。现在我开始了解这么做的问题,为了使这些工具类变得成熟稳定所付出的精力是巨大的。
但是新的代码在没有任何限制的情况下也这么做就变得不可理解了。一个架构师或者资深开发者的职责就是关注那些通过完整测试的解决方案,这些方案会更易于集成到你的应用程序。
如果你的职位描述包含架构师的字眼话,不应该有任何的理由说你不知道Apache Commons、Google Guava或者Joda Date。
你还需要在一些开发不熟悉领域的程序时提前做一些研究。例如,在写REST服务时候如果需要创建PDF文件,应该先花点时间了解在Java领域存在哪些REST框架以及创建一个PDF文件有什么推荐的方法(运行UNIX命令是不推荐的一种做法,Java可以通过iText自行创建PDF文件)。
2、构建过程依赖环境
我写Java程序已经10年了。告诉你一个秘密:构建一个企业级项目的时候只有一种可以接受的方式,那就是:
新人在公司出现的第一天(完成他自己相关的一些手续)。
新人熟悉工作环境(机器等),安装JDK和他所喜好的IDE(开发工具)。
新人从公司的代码仓库中拿出代码(使用SVN、Git或者其它工具)。
新人花费5分钟了解公司的代码是使用什么构建工具(例如:Maven、Gradle或者ANT)。
新人运行命令构建整个应用程序。
这不应该是一个理想化的状态,而应该是一种现实的状态去创建应用程序的构建系统(译注:顶,我也见过太多变态的构建系统了,最变态的系统我所碰到过为了构建系统需要运行20个命令,这还不包括发布的命令)。
如果你的应用程序依赖于一个特定IDE、特定版本的开发工具插件、个人电脑的本地文件、没有标注过的环境变量、网络资源或者其它任何非标准的资源。相信这时候需要重新思考你的构建系统了。
关于一步完成构建需要的信息可以参考 Joel test http://www.joelonsoftware.com/articles/fog0000000043.html
但请不要误解,一个需要自我检查的构建、额外的发布和生成文档的步骤、必要的外部设置或外部的数据库都需要在公司的wiki上有详细描述。
(对于一个新人来说)建议的构建行为应该至多1个小时内完成。我曾经看到有些项目,新人通常需要2天来完成第一次构建应用程序。
1、使用反射和自我检查
当你在编写ORM(对象关系映射)框架、Java代理、元编译器、IDE时,如果你愿意的话很有可能需要使用Java反射。然而大多数的企业级项目很大程度只是操作数据存储的一些CRUD接口(创建、读取、更新、删除),Java反射反而变成了一个杀手。
我经常看到Java反射被用作性能提升、向前兼容或向后兼容。而且几乎总是被错误地使用。反射总是基于一些现在成立但是将来未必成立的假设(例如,方法名称命名规则)。
反射的问题在于非常难于理解、调试以及解决相应的问题。
我甚至不愿意深入这种自我修正的代码。在Java企业级项目中,使用反射就相当于在一个大厦里面安装了一个定时炸弹。虽然这个大厦现在可能是稳定的,但只要到了那个时间所有将变成灰烬。
令我很困扰的是,那些“资深”的架构师一直反复介绍反射。在实际变成中,你可以通过清晰的对象层次、清晰的定义和文档、可插拔的架构完成同样的功能。
总结一下,不使用反射可以创建一个稳定、快速和可维护的Java应用程序。当然,如果想在企业级系统中增加麻烦的话那么就使用反射吧。
译注:实际上本人对Reflection,Annotation之类的技术,也有一些同样的想法。这里多加一些示例:
反射会创建许多匿名类,注意是类不是对象,会造成对于JVM的permanant generation(或old generation)空间占用。同时由于空间有限还会造成GC的发生,可能会加长系统响应延迟。
反射会提升性能?这是错误的观点。经过测试,反射一般会比正常的Java调用慢上1.X倍甚至10倍,所以很多时候需要缓存来加速。
反射有可能造成安全问题。譬如A.class.getMethod().setAccessbile(true),这样你可以访问私有类或者变量了)。
奖励环节:仅仅尝试优化你所真正掌握的实际上存在的瓶颈
译注:优化系统是一个经典悖论,没有瓶颈何须优化。
我经常看到架构师花费了巨大的精力在以下几个方面:
细粒度地调优log说明。
在旧的系统中替换Vector类。
替换String‘+’操作改为循环调用StringBuffer.Append()。
重构现有稳定的、成熟的、很少有bug发生的系统,仅仅是为了提升性能或者其他比较令人羡慕的目标。
悲观的说,很难评估这么做到底可以带来多大的收益和所需要花费的精力。即使系统运行地快了一些,但同时有可能带来其他更加严重的问题(像数据库死锁、内存泄漏、没有关闭打开的流)。很少有人会去关注这些问题。
花费时间解决logging可能只能提升了3%的性能,但改进SQL查询倒是有可能获得200%的性能提升。
因此,关于性能改进需要遵循下面四个重要步骤:
评估现在的系统。
应用你的更改。
再次评估修改过的系统。
衡量花费精力与提升性能之间的效能比。
请记住,优化的精髓在于—-评估而不是猜测。
网友评论