尽管软件开发一直致力于追求高效、可读性强、易于维护的特性,但这些特性却像是一个不可能三角,相互交织,此消彼长。就像底层语言(如汇编和C语言)能够保持高效的运行性能,但在可读性和维护性方面却存在短板和劣势;而高级语言(如Java和Python)在可读性和可维护性方面表现出色,但在执行效率方面却存在不足。
构建语言生态的优势,弥补其存在短板,始终是编程语言的一个演进方向。不同编程语言,拥有不同的特性和规约,下面就以JAVA语言为例,细数那些开发过程中容易被人忽略,但必须掌握的知识点和实践技能。命名字典
见名知意:好的命名就是一种注释。
建议研发同学将业内常见业务场景的命名熟记,当然,已经有人帮我们总结过了,这里不再做过多的说明。这里摘录如下,可供参考:
管理类命名:Bootstrap,Starter,Processor,Manager,Holder,Factory,Provider,Registrar,Engine,Service,Task
传播类命名:Context,Propagator
回调类命名:Handler,Callback,Trigger,Listener,Aware
监控类命名:Metric,Estimator,Accumulator,Tracker
内存管理类命名:Allocator,Chunk,Arena,Pool
过滤检测类命名:Pipeline,Chain,Filter,Interceptor,Evaluator,Detector
结构类命名:Cache, Buffer, Composite, Wrapper, Option, Param, Attribute, Tuple, Aggregator, Iterator, Batch, Limiter
常见设计模式命名:Strategy, Adapter, Action, Command, Event, Delegate, Builder, Template, Proxy
解析类命名:Converter,Resolver,Parser,Customizer,Formatter
网络类命名:Packet,Encoder、Decoder、Codec,Request, Response
CRUD命名:Controller,Service,Repository
辅助类命名:Util,Helper
其他类命名:Mode,Type,Invoker,Invocation,Initializer,Future,Promise,Selector,Reporter,Constants,Accessor,Generator
public <PropertyType> get<PropertyName>();
public void set<PropertyName>(<PropertyType> p)
但是,布尔类型的变量propertyName则是另外一套命名原则的:
public boolean is<PropertyName>();
public void set<PropertyName>(boolean p)
由于各种RPC框架和对象序列化工具对于布尔类型变量的处理方式存在差异,就容易造成代码移植性问题。最常见的json序列化库Jackson和Gson之间就存在兼容性问题,前者是通过通过反射遍历出该类中的所有getter方法,通过方法名截取获得到对象的属性,后者则是通过反射直接遍历该类中的属性。为了规避这种差异对业务的影响,建议所有成员变量都不要以is开头,防止序列化结果出现不预知的情况发生。
3. 看看单词大小写能引起的哪些副作用?
JAVA语言本身是区分大小写的,但是在用文件路径、文件名对文件进行操作时,这里的文件名和路径是不区分大小写的,这是因为文件系统不区分大小写。典型的场景就是我们通过git等代码管理平台时,将package路径里的大写的文件名称,修改为小写时,git是无法更新的,为了规避不必要的麻烦,这里建议包路径统一使用小写单词,多个单词通过路径层次来进行定义。
4. 不同jar包里的类也会出现冲突问题?
(1) 一类是同一个jar包出现了多个不同的版本。应用选择了错误的版本导致jvm加载不到需要的类或者加载了错误版本的类;(借助maven管理工具相对容易解决
(2) 另一类是不同的jar包出现了类路径相同的类,同样的类出现在不同的依赖jar里,由于jar加载的先后顺序导致了JVM加载了错误版本的类;(比较难以解决)
这里着重介绍第二种情况,这种情况容易出现在系统拆分重构时,将原有的项目进行了复制,然后删减,导致部分工具或者枚举类和原有的路径和命名都一样,当第三方调用方同时依赖了这两个系统时,就容易为以后的迭代埋下坑。要规避此类问题,一定要为系统起一个独一无二的package路径。
补充:如果依赖的都是第三方的库,存在着类冲突时,可以通过引入第三方库jarjar.jar,修改其中某个冲突jar文件的包名,以此来解决jar包冲突。
5. 在变量命名的可读性和占用资源(内存,带宽)方面,如何去做权衡?可以通过对象序列化工具为突破口,以常见的Json(Jackson)序列化方式来举例:
public class SkuKey implements Serializable {
@JsonProperty(value = "sn")
@ApiModelProperty(name = "stationNo", value = " 门店编号", required = true)
private Long stationNo;
@JsonProperty(value = "si")
@ApiModelProperty(name = "skuId", value = " 商品编号", required = true)
private Long skuId;
// 省略get/set方法
}
其中@JsonProperty注解的作用就是将JavaBean中的普通属性在序列化的时候,重新命名成指定的新的名字。而这一实现对于业务实现没有影响,依然以原来的命名操作为准,只在对外RPC需要序列化和反序列化的过程生效。如此,比较好地解决了可读性和资源占用的冲突问题。
6. 对外提供服务的入参和出参,我们是用class对象,还是Map容器?
从灵活性的角度看,Map容器稳定且更灵活。从稳定性和可读性上来看,Map容器是个黑盒子,不知道里面有什么,得有辅助的详细说明文档才能协作,由于维护文档的动作往往与工程代码是分开的,这中机制就会导致信息的准确性和实时性很难得到保障。所以还是建议使用class结构对象维护出入参结构。
【 关于注释 】
注释是程序员和阅读者之间交流的重要手段,是对代码的解释和说明,好的注释可以提高软件的可读性,减少维护软件的成本。
好的注释
分层次:按照系统,包,类,方法,代码块,代码行等不同粒度,各有侧重点的进行注释说明。
(1) 系统注释:通过README.md文件体现宏观的功能和架构实现;
(2) 包注释:通过package-info文件体现模块职责边界,另外该文件也支持声明友好类,包常量以及为标注在包上的注解(Annotation)提供便利;
(3) 类注释:主要体现功能职责,版本支持,作者归属,应用示例等相关信息;
(4) 方法注释:关注入参,出参,异常处理声明,使用场景举例等相关内容;
(5) 代码块和代码行注释:主要体现逻辑意图,闭坑警示,规划TODO,放大关注点等细节内容;
有规范:好的代码优于大量注释,这和我们常说的“约定大于配置”是相同的道理。借助swagger等三方库实现注解即接口文档,是一个不错的规范方式;
坏的注释
为了能使注释准确清晰的表达出功能逻辑,注释的维护是有相当的维护成本的,所以注释并不是越多,越详细越好。下面就举一些坏的注释场景,辅助理解:
冗余式:如果一个函数,读者能够很容易的就读出来代码要表达的意思,注释就是多余的;
错误式:如果注释得不清楚,甚至出现歧义,那还不如不写;
签名式:类似“add by liuhuiqing 2023-08-05”这种注释,容易过期失效而且不太可信(不能保证所有人每次都采用这种方式注释),其功能完全可以由git代码管理工具来实现;
长篇大论式:代码块里,夹杂了大篇幅的注释,不仅影响代码阅读,而且维护困难;
非本地注释:注释应该在离代码实现最近的地方,比如:被调用的方法注释就由方法本身来维护,调用方无需对方法做详细的说明;
注释掉的代码:无用的代码应该删除,而不是注释。历史记录交给git等代码管理工具来维护;
【 关于分层 】
系统分层设计的主要目的是通过分离关注点,来降低系统的复杂度,同时提高可复用性和降低维护成本。所以懂得分层的概念,很大程度上系统的可维护性就有了骨架。【 类定义 】
public enum Color {
RED, GREEN, BLUE;
}
如果你只需要定义一个或少数几个只读的常量,那么使用类常量更为简洁和方便。
public class MyClass {
public static final int MAX_VALUE = 100;
}
工具类通常包含具有通用性的、某一非业务领域内的公共方法,不需要配套的成员变量,仅仅是作为工具方法被使用。因此,将其做成静态方法最合适,不需要实例化,能够获取到方法的定义并调用就行。
工具类不实例化的原因是可以节省内存空间,因为工具类提供的是静态方法,通过类就能调用,不需要实例化工具类对象。
public abstract class ObjectHelper {
public static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
}
为了实现不需要实例化对象的约束,我们最好在类定义时,加上abstract关键字进行声明限定,这也是为什么spring等开源工具类大都使用abstract关键字修饰的原因。
JavaBean
JavaBean的定义有两种常见实现方式:手动编写和自动生成。
使用lombok插件,通过注解方式来增强Java代码的编写,在编译期动态生成get和set方法。public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
插件包还提供了@Builder和@Accessors等比较实用的链式编程能力,在一定程度上能提高编码效率。import lombok.Data;
@NoArgsConstructor
@Data
@Accessors(chain = true)
public class Person {
private String name;
private int age;
}
不可变类
public final class String implements Serializable, Comparable<String>, CharSequence {
}
以下是一些不能被继承和重写的类,这在一些底层中间件中会有应用:
java.lang.String
java.lang.Math
java.lang.Boolean
java.lang.Character
java.util.Date
java.sql.Date
java.lang.System
java.lang.ClassLoader
匿名内部类
public class Example {
public static void main(String[] args) {
// 创建一个匿名内部类
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Hello, World!");
}
};
// 调用匿名内部类的方法
runnable.run();
}
}
声明类
Record类
Record 类在 Java14 中就开始预览,一直到Java17 才正式发布。根据 JEP395 的描述,Record 类是不可变数据的载体,类似于当下广泛应用的各种 model,dto,vo 等 POJO 类,但 record 本身在构造之后不再可赋值。所有的 record 类都继承自 java.lang.Record。Record 类默认提供了全字段的构造器,属性的访问,还有 equals,hashcode,toString 方法,其作用和 lombok 插件非常类似。
定义方式
/**
* 关键定义的类是不可变类
* 将所有成员变量通过参数的形式定义
* 默认会生成全部参数的构造方法
* @param name
* @param age
*/
public record Person(String name, int age) {
public Person{
if(name == null){
throw new IllegalArgumentException("提供紧凑的方式进行参数校验");
}
}
/**
* 定义的类中可以定义静态方法
* @param name
* @return
*/
public static Person of(String name) {
return new Person(name, 18);
}
}
使用方式
Person person = new Person("John", 30);
// Person person = Person.of("John");
String name = person.name();
int age = person.age();
使用场景
通过Record 构建一个临时存储对象,将 Person 数组对象按照年龄排序。public List<Person> sortPeopleByAge(List<Person> people) {
record Data(Person person, int age){};
return people.stream()
.map(person -> new Data(person, computAge(person)))
.sorted((d1, d2) -> Integer.compare(d2.age(), d1.age()))
.map(Data::person)
.collect(toList());
}
public int computAge(Person person) {
return person.age() - 1;
}
密封类
在上面的示例中,SealedClass是一个密封类,它包含两个子类SubClass1和SubClass2。在SubClass1和SubClass2的定义中,必须使用extends关键字来继承自SealedClass,并且使用permits关键字来指定它们允许哪些子类来继承。通过使用密封类,可以确保只有符合特定条件的子类才能继承或实现该协议或规范。sealed class SealedClass permits SubClass1, SubClass2 {
}
class SubClass1 extends SealedClass {
}
class SubClass2 extends SealedClass {
}
【 方法定义 】
构造方法
实现单例模式的一个重要特性就是不允许用户随意创建(new)对象,如何做到安全控制呢?将构造方法声明为私有(private)是必不可少的一步。public class MyClass {
private int myInt;
private String myString;
// 构造方法
public MyClass(int myInt, String myString) {
this.myInt = myInt;
this.myString = myString;
}
}
面向对象的三大特性之一的多态,方法重写是其核心。class Animal {
public void makeSound() {
System.out.println("Animal is making a sound");
}
}
class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Meow");
}
}
public class Main {
public static void main(String[] args) {
Animal myCat = new Cat();
myCat.makeSound(); // 输出 "Meow"
}
}
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public double add(double a, double b) {
return a + b;
}
}
public class Main {
public static void main(String[] args) {
Calculator calculator = new Calculator();
int result1 = calculator.add(2, 3);
double result2 = calculator.add(2.5, 3.5);
System.out.println(result1); // 输出 5
System.out.println(result2); // 输出 6.0
}
}
【 对象定义 】public static void main(String args[]) {
List<String> names = Arrays.asList("hello", "world");
// 使用 Lambda 表达式作为参数传递给 forEach 方法
names.forEach((String name) -> System.out.println("Name: " + name));
// 使用 Lambda 表达式作为独立表达式使用
Predicate<String> nameLengthGreaterThan5 = (String name) -> name.length() > 5;
boolean isLongName = nameLengthGreaterThan5.test("John");
System.out.println("Is long name? " + isLongName);
}
单例对象
单例对象是一种可以重复使用的对象,但只有一个实例。它有以下几个作用:
控制资源的使用:通过线程同步来控制资源的并发访问。
控制实例产生的数量:达到节约资源的目的。
作为通信媒介使用:也就是数据共享,它可以在不建立直接关联的条件下,让多个不相关的两个线程或者进程之间实现通信。
比如,使用枚举实现单例模式:
public enum Singleton {
INSTANCE;
public void someMethod() {
// ...其他代码...
}
}
Java中的不可变对象是指那些一旦被创建,其状态就不能被修改的对象。不可变对象是一种非常有用的对象,因为它们可以确保对象的状态在任何时候都是一致的,从而避免了因为修改对象状态而引发的问题。实现不可变对象有以下几种方式:
将对象的状态存储在不可变对象中:String、Integer等就是内置的不可变对象类型;
将对象的状态存储在final变量中:final变量一旦被赋值就不能被修改;
将对象的所有属性都设为不可变对象:这样就可以确保整个对象都是不可变的;
一些容器类的操作也有对应的包装类实现容器对象的不可变,比如定义不可变数组对象:
Collections.unmodifiableList(new ArrayList<>());
当领域内的对象作为入参往外传递时,将其定义为不可变对象,这在保持数据一致性方面非常重要,否则对象属性变更的不可预测性,在进行问题定位时,将会非常麻烦。
元组对象
元组(Tuple)是函数式编程语言中的常见概念,元组是一个不可变,并且能够以类型安全的形式保存多个不同类型的对象。它是一种非常有用的数据结构,可以让开发者在处理多个数据元素时更加方便和高效。但原生的Java标准库并没有提供元组的支持,需要我们自己或借助第三方类库来实现。
二元组实现
public class Pair<A,B> {
public final A first;
public final B second;
public Pair(A a, B b) {
this.first = a;
this.second = b;
}
public A getFirst() {
return first;
}
public B getSecond() {
return second;
}
}
三元组实现
public class Triplet<A,B,C> extends Pair<A,B>{
public final C third;
public Triplet(A a, B b, C c) {
super(a, b);
this.third = c;
}
public C getThird() {
return third;
}
public static void main(String[] args) {
// 表示姓名,性别,年龄
Triplet<String,String,Integer> triplet = new Triplet("John","男",18);
// 获得姓名
String name = triplet.getFirst();
}
}
多元组实现
Tuple主要有以下几个功能:public class Tuple<E> {
private final E[] elements;
public Tuple(E... elements) {
this.elements = elements;
}
public E get(int index) {
return elements[index];
}
public int size() {
return elements.length;
}
public static void main(String[] args) {
// 表示姓名,性别,年龄
Tuple<String> tuple = new Tuple<>("John", "男", "18");
// 获得姓名
String name = tuple.get(0);
}
}
NamedTuple namedTuple = Tuples.named("person", "name", "age");
临时对象
临时对象是指在程序执行过程中临时需要,但生命周期较短的对象。这些对象通常只在使用过程中短暂存在,不需要长期存储或重复使用。
关于临时对象的优化建议如下:
尽量重用对象。由于系统不仅要花时间生成对象,以后可能还需花时间对这些对象进行垃圾回收和处理,因此,生成过多的对象将会给程序的性能带来很大的影响,重用对象的策略有缓存对象,也可以针对具体场景进行定向优化,比如使用StringBuffer代替字符串拼接的方式;
尽量使用局部变量。调用方法时传递的参数以及在调用中创建的临时变量都保存在栈中,速度较快。其他变量,如静态变量、实例变量等,都在堆中创建,速度较慢;
分代收集。分代垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率;
Valhalla
Java作为高级语言,和更为底层的C语言,汇编语言在性能方面一直存在着不小的差距。为了弥补这一差距,Valhalla 项目于 2014 年启动,目标是为基于 JVM 的语言带来更灵活的扁平化数据类型。
我们都知道Java支持原生类型和引用类型两种。原生数据类型按值传递,赋值和函数传参都会把值给复制一份,复制之后两份之间就再无关联;引用类型无论什么情况传的都是指针,修改指针指向的内容会影响到所有的引用。而Valhalla又引入了值类型(value types),一种介于原生类型和引用类型之间的概念。
由于应用程序中的大多数Java数据结构都是对象,因此我们可以将Java视为指针密集型语言。这种基于指针的对象实现用于启用对象标识,对象标识本身用于语言特性,如多态性、可变性和锁定。默认情况下,这些特性适用于每个对象,无论它们是否真的需要。这就是值类型(value types)发挥作用的地方。
值类型(value types)的概念是表示纯数据聚合,这会删除常规对象的功能。因此,我们有纯数据,没有身份。当然,这意味着我们也失去了使用对象标识可以实现的功能。由于我们不再有对象标识,我们可以放弃指针,改变值类型的一般内存布局。让我们来比较一下对象引用和值类型内存布局。
Valhalla 在提高性能和减少泄漏的抽象方面将会显著提高:
性能增强通过展平对象图和移除间接来解决。这将获得更高效的内存布局和更少的分配和垃圾回收。
当用作泛型类型时,原语和对象具有更相似的行为,这是更好的抽象。
截止到2023年9月,Valhalla 项目仍在进行中,还没有正式版本的发布,这一创新项目值得期待的。
本文总结了软件开发过程中经常用到的基础常识,分为基础篇和实践篇两个篇章,其中基础篇中着重讲述了类,方法,变量的命名规范以及代码注释好坏的评判标准。实践篇中从类,方法以及对象三个层面分析了常见的技术概念和落地实践,希望这些常识能够为读者带来一些思考和帮助。
- END -