分类
Java Security

Java中的MD5散列

译者注

原文

https://www.baeldung.com/java-md5

Demo

https://github.com/eugenp/tutorials/tree/master/core-java-modules/core-java-security-2

一、前言

MD5广泛应用在密码散列中,它提供128位的散列。
在本文中,我们将会学习不同的方法,使用多种Java库来实现MD5散列

二、使用MessageDigest

java.security.MessageDigest类中提供了散列功能。
我们需要把你想使用的算法作为参数,来实例化一个对象:

MessageDigest.getInstance(String Algorithm)

然后使用update()方法来更新消息的内容:

public void update(byte [] input)

如果你想读取一个长文件,上面这个方法,可以被多次调用,最终我们需要使用digest()方法来生成一个散列代码:

public byte[] digest()

下面的案例展示了如何给一个密码生成散列,并且验证它:

@Test
public void givenPassword_whenHashing_thenVerifying() 
  throws NoSuchAlgorithmException {
    String hash = "35454B055CC325EA1AF2126E27707052";
    String password = "ILoveJava";

    MessageDigest md = MessageDigest.getInstance("MD5");
    md.update(password.getBytes());
    byte[] digest = md.digest();
    String myHash = DatatypeConverter
      .printHexBinary(digest).toUpperCase();
    assertThat(myHash.equals(hash)).isTrue();
}

相似的,我们也可以检查文件的校验和:

@Test
public void givenFile_generatingChecksum_thenVerifying() 
  throws NoSuchAlgorithmException, IOException {
    String filename = "src/test/resources/test_md5.txt";
    String checksum = "5EB63BBBE01EEED093CB22BB8F5ACDC3";

    MessageDigest md = MessageDigest.getInstance("MD5");
    md.update(Files.readAllBytes(Paths.get(filename)));
    byte[] digest = md.digest();
    String myChecksum = DatatypeConverter
      .printHexBinary(digest).toUpperCase();
    assertThat(myChecksum.equals(checksum)).isTrue();
}

我们需要注意的是,这个MessageDigest线程不安全的。因此,我们需要在每个线程中使用新的实例。

3. 使用Apache Commons

阿帕奇的org.apache.commons.codec.digest.DigestUtils类也提供了相似的功能,而且使用起来很简单。
下面给出一个生成密码散列和校验的例子:

@Test
public void givenPassword_whenHashingUsingCommons_thenVerifying()  {
    String hash = "35454B055CC325EA1AF2126E27707052";
    String password = "ILoveJava";

    String md5Hex = DigestUtils
      .md5Hex(password).toUpperCase();
    assertThat(md5Hex.equals(hash)).isTrue();
}

4. 使用Guava

下面的例子是另一个生成MD5校验和的方法,使用com.google.common.io.Files.hash类:

@Test
public void givenFile_whenChecksumUsingGuava_thenVerifying() 
  throws IOException {
    String filename = "src/test/resources/test_md5.txt";
    String checksum = "5EB63BBBE01EEED093CB22BB8F5ACDC3";

    HashCode hash = com.google.common.io.Files
      .hash(new File(filename), Hashing.md5());
    String myChecksum = hash.toString()
      .toUpperCase();

    assertThat(myChecksum.equals(checksum)).isTrue();
}

需要注意的是,Hashing.md5是被反对的,然而,正如官方文档所说,原因是建议用户不要使用MD5去生成安全信息。
这意味着我们仍然可以使用它,如果我们需要整合使用MD5的传统项目。除此之外,我们最好去考虑更安全的方案,例如使用SHA-256
(译者注:MD5已经被破解了,因此它不再安全了)

5、 总结

本文中我们学习了使用不同的方式,调用Java API或第三方API,来实现MD5的散列。
实际使用时,我们需要根据项目的需要和项目的依赖关系来选择具体的方案。

和往常一样,你可以在Github上找到本文的示例代码。

分类
Java

Java无头模式

译者注

原文:

https://www.baeldung.com/java-headless-mode

Demo

https://github.com/eugenp/tutorials/tree/master/core-java-modules/core-java-lang-2

1. 前言

有时,我们需要在没有真实的显示器、键盘、鼠标的情况下,来实现基于图形的Java应用程序,也就是说,程序运行在服务器或容器上。

本文我们将会学习Java的无头模式,以便实现上面提到的需求。我们将会知道,在无头模式的情况下,我们可以做什么、不能做什么。

2. 设置无头模式

在Java中,我们有很多方式来设置无头模式:

  • 将系统属性java.awt.headless设置为true
  • 使用命令行参数java -Djava.awt.headless=true
  • 在服务的启动脚本中,添加参数-Djava.awt.headless=trueJAVA_OPTS环境变量中

如果环境设置为无头模式,JVM就会识别到它。然而这样就会有一些细微的区别。我们来具体看一下。

3. 无头模式的UI组件示例

无头环境中的UI组件一个典型使用情况,就是作为图像转换器程序。
尽管它在运行过程中需要图形数据,但他并不需要显示。这样的app将会运行在服务器中,转换后的数据将会保存并通过网络传输给另一台机器来显示。

我们看一下如何操作。

首先,我们在JUnit类中启用无头模式,

@Before
public void setUpHeadlessMode() {
    System.setProperty("java.awt.headless", "true");
}

为了确保无头模式已经正常开启,我们可以写一个测试程序,通过调用java.awt.GraphicsEnvironment来断言无头模式是true:

@Test
public void whenSetUpSuccessful_thenHeadlessIsTrue() {
    assertThat(GraphicsEnvironment.isHeadless()).isTrue();
}

通过上面的测试方法,我们就可以准确的了解当前无头模式是否已经启用。
现在我们来做一个简单的图像转换器:

@Test
public void whenHeadlessMode_thenImagesWork() {
    boolean result = false;
    try (InputStream inStream = HeadlessModeUnitTest.class.getResourceAsStream(IN_FILE); 
      FileOutputStream outStream = new FileOutputStream(OUT_FILE)) {
        BufferedImage inputImage = ImageIO.read(inStream);
        result = ImageIO.write(removeAlphaChannel(inputImage), FORMAT, outStream);
    }

    assertThat(result).isTrue();
}

在下一个示例中,我们可以看到,所有字体的信息,包括字体规格都可用了:

@Test
public void whenHeadless_thenFontsWork() {
    GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
    String fonts[] = ge.getAvailableFontFamilyNames();
    assertThat(fonts).isNotEmpty();
    Font font = new Font(fonts[0], Font.BOLD, 14);
    FontMetrics fm = (new Canvas()).getFontMetrics(font);

assertThat(fm.getHeight()).isGreaterThan(0);
assertThat(fm.getAscent()).isGreaterThan(0);
assertThat(fm.getDescent()).isGreaterThan(0);
}

4. 无头异常HeadlessException

如果有组件依赖外围设备,它们就无法在无头模式中工作。当使用非交互环境时,就会抛出无头异常:

Exception in thread "main" java.awt.HeadlessException
    at java.awt.GraphicsEnvironment.checkHeadless(GraphicsEnvironment.java:204)
    at java.awt.Window.<init>(Window.java:536)
    at java.awt.Frame.<init>(Frame.java:420)

例如下面的代码,在一个无头模式的测试方法中,使用Frame对象就会导致无头异常:

@Test
public void whenHeadlessmode_thenFrameThrowsHeadlessException() {
    assertThatExceptionOfType(HeadlessException.class).isThrownBy(() -> {
        Frame frame = new Frame();
        frame.setVisible(true);
        frame.setSize(120, 120);
    });
}

根据经验,需要注意的是,这些顶级组件(比如Frame或者Button)需要交互环境,否则就会抛出异常。如果无头模式没有开启,它们可能会取而代之的抛出非交互错误irrecoverable Error

5. 在无头模式中绕过重量级组件

在本小节中,我们来提出一个问题:

如果我们把一个带有GUI组件的代码分别在“有头的生产环境机器上”和“无头的代码分析服务器上”运行,会发生什么?

在上面的例子中,我们已经知道了重量级组件无法在服务器上运行,并且会抛出异常。
所有我们可以使用一个条件来达到目的:

public void FlexibleApp() {
    if (GraphicsEnvironment.isHeadless()) {
        System.out.println("Hello World");
    } else {
        JOptionPane.showMessageDialog(null, "Hello World");
    }
}

用这样的模式,我们可以创造一个灵活的程序,根据它所在的环境来自动调整行为。

6、 总结

通过不同的代码示例,我们了解了Java的无头模式和它的部分原理。在这篇文章中提供了兼容列表,列表中给出了无头模式中可以进行哪些操作。

和往常一样,你可以在Github上找到本文中的示例代码。

分类
Java

Java内存不足错误: 垃圾回收超出开销限制

译者注

原文: https://www.baeldung.com/java-gc-overhead-limit-exceeded
Demo: https://github.com/eugenp/tutorials/tree/master/core-java-modules/core-java-perf

1. 概述

简单来说,当应用中的对象不再被使用时,JVM便会进行回收内存的操作,我们把这个操作称为垃圾回收,对应的原文为Garbage Collection,简称GC

GC Overhead Limit Exceeded error提示是由java.lang.OutOfMemoryError发出的,此异常代表资源已经被耗尽。

本文中我们将介绍引发java.lang.OutOfMemoryError: GC Overhead Limit Exceeded error的原因及解决方案。

2. “垃圾回收超出开销限制”的原因(GC Overhead Limit Exceeded Error)

OutOfMemoryError类是java.lang.VirtualMachineError的一个子类,当JVM 遇到资源相关的问题时,就会抛出此VirtualMachineError异常。
具体来讲,当JVM花费了太多的时间去执行垃圾回收,却只回收了很少的可用堆空间时,这个错误就会出现。

根据Java的文档, 在默认情况下JVM的设计是:如果Java进程花费了超过98%的时间去执行垃圾回收,而每次只收回了不到2%的内存时,就会抛出这个错误。
换句话讲,这就意味着我们的程序耗尽了几乎所有的可用内存,并且GC已经花费了太多了时间去清理内存,却不断的失败,不断的重复。

在这种情况下,这个应用程序的用户体验会变得非常缓慢。那些本来只需要花费毫秒级时间就能完成的操作,现在却要花费更多的时间才能完成。这是因为CPU正在使用它的计算资源来执行GC操作,因此无法执行任何其他的任务。

3. 复现这个问题

我们看一下抛出java.lang.OutOfMemoryError: GC Overhead Limit Exceeded错误的代码。

我们可以知道,例如,通过添加键值对在一个不会结束的循环(死循环)中:

public class OutOfMemoryGCLimitExceed {
    public static void addRandomDataToMap() {
        Map dataMap = new HashMap<>();
        Random r = new Random();
        while (true) {
            dataMap.put(r.nextInt(), String.valueOf(r.nextInt()));
        }
    }
}

当这个方法被调用的时候,带着如下的JVM参数运行`-Xmx100m -XX:+UseParallelGC` (这个参数的含义是:设置Java的堆大小为100M,GC收集算法为并行收集),我们就会得到一个`java.lang.OutOfMemoryError: GC Overhead Limit Exceeded`错误。
如果想要对于不同的垃圾回收策略有更深入的理解,可以学习Oracle的文章:[ Java Garbage Collection Basics tutorial.](http://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html " Java Garbage Collection Basics tutorial")

通过在项目的根目录运行下面的命令,我们马上就可以得到一个`java.lang.OutOfMemoryError: GC Overhead Limit Exceeded`错误:

mvn exec:exec

此外,值得注意的是,有些情况下,我们在遇到`GC Overhead Limit Exceeded`错误之前,先遇到了一个堆空间错误(`heap space error`)。

4. 解决“GC超出开销限制”错误

理想的办法就是,通过检查代码中的内存泄漏,去查找软件中的潜在问题、

下面的问题值得被解决:

  • 软件中的哪些对象占用了堆中的大量内存?
  • 这些对象在代码中的哪个位置被创建

我们也可以使用自动化图形工具(例如[JConsole](https://docs.oracle.com/en/java/javase/11/management/using-jconsole.html#GUID-77416B38-7F15-4E35-B3D1-34BFD88350B5 "JConsole")),来帮助我们检测代码中的性能问题,包括文中一直提到的java.lang.OutOfMemoryErrors。

最后一个办法是,通过改变JVM启动时的配置来增加堆内存。例如,给我们运行的Java程序设置1GB的堆内存:

java -Xmx1024m com.xyz.TheClassName

然而,这种做法并不能解决应用程序中存在的内存泄漏问题,而是会推迟这个问题的发生。因此,更可取的办法是,彻底重新评估应用程序的内存使用情况。

5. 结论

在本文中,我们学习了 java.lang.OutOfMemoryError: GC Overhead Limit Exceeded 错误,并且讨论了错误出现的原因以及如何解决它。
和其他文章一样,读者可以在Github中找到示例代码。

分类
Java

Java:Base64编码与解码

译者前言

原文地址:https://www.baeldung.com/java-base64-encode-and-decode
Demo仓库地址:https://github.com/liuyuxuan6666/java-base64-encode-demo

1.概述

在这个教程中,我们将会学习Java提供的Base64编码和解码功能。
我们主要讨论的是Java8中的API,顺便也会说一说Apache Commons包中的部分API。

2.Java8中Base64的使用

Java8通过java.util.Base64这个类,在标准API中实现了Base64编解码的相关功能。我们先从最基本的用法开始。

2.1. Java8中Base64的基本编码器(getEncoder)

“基本编码器”是最简单的编码方式,直接处理输入的字符,不进行任何分隔处理。

也就是把输入的字符按照某种确定的规则,映射到由"A-Z a-z 0-9 +/"这些字符组成字符集中,形成一个字符序列。

我们先来看简单的String字符串处理(代码文件见 demo_2_1):

// 设置需要编码的字符串
String originalInput = "test input";
// 直接使用Base64.getEncoder()方法编码
String encodedString = Base64.getEncoder().encodeToString(originalInput.getBytes());
// 将会输出 dGVzdCBpbnB1dA==

需要注意的是,此处我们直接使用基本的getEncoder()方法,来调用内部API,实现Base64的编码。

对应的解码,把getEncoder()替换为getDecoder()即可,这里注意,转换过程是先转换为字节流,再转换为字符串:

String encodedString = "dGVzdCBpbnB1dA==";
// 使用Base64.getDecoder()获得字节流  byte[10]: [116, 101, 115, 116, 32, 105, 110, 112, 117, 116]
byte[] decodedBytes = Base64.getDecoder().decode(encodedString);
// 新建一个String变量,把字节流转化成字符串
String decodedString = new String(decodedBytes);
// 将会输出 test input

译者注:如果想查看转换中的字节流是什么形式,打断点或加输出语句可以更好的帮助学习,注释中提供了字节流。

2.2. 无填充的Java8 Base64编码(withoutPadding)

在Base64编码中,输出的字符串长度必须是3的倍数。如果编码后长度不是3的倍数,编码器会在字符串的末尾添加1或2个字符来满足需要,这个字符是等号“=”(参考2.1,编码后的字符串中有两个等号)。
解码时,编码器先把这些等号去掉,再开始解码。
对于Base64填充的深入理解,可以参考Stack Overflow上的详细解释

但有的时候,我们不需要编码器自动为我们填充,例如某些情况下,只需要Base64编码而不需要解码,这个时候就可以不使用填充,而是直接进行编码。
使用方式也很简单,只需要在执行编码之前添加.withoutPadding()即可(demo_2_2):

String encodedString = Base64.getEncoder().withoutPadding().encodeToString(originalInput.getBytes());
// 将会输出 dGVzdCBpbnB1dA

与2.1对比,同样的字符串采用填充和无填充编码的唯一区别,就是去掉了末尾的等号。

2.3. Java8 URL编码(getUrlEncoder)

URL编码器和基本编码器非常相似,不同之处在于:

  1. 它使用URL和文件名安全的Base64字母表,也就是编码后的字符串符合文件命名规范
  2. 它默认不添加任何行分隔符

使用方法,只需要把基本编码器中的getEncoder替换为getUrlEncoder,如下(demo_2_3):

String originalUrl = "https://www.google.co.nz/?gfe_rd=cr&ei=dzbFV&gws_rd=ssl#q=java";
String encodedUrl = Base64.getUrlEncoder().encodeToString(originalUrl.getBytes());
// 将会输出 aHR0cHM6Ly93d3cuZ29vZ2xlLmNvLm56Lz9nZmVfcmQ9Y3ImZWk9ZHpiRlYmZ3dzX3JkPXNzbCNxPWphdmE=

解码也是相同的用法,这里使用getUrlDecoder,和前面普通字符串的解码相似,先转换为字节流,再转换为字符串:

byte[] decodedBytes = Base64.getUrlDecoder().decode(encodedUrl);
// byte[62]:
// [104, 116, 116, 112, 115, 58, 47, 47, 119, 119, 119, 46,
// 103, 111, 111, 103, 108, 101, 46, 99, 111, 46, 110, 122,
// 47, 63, 103, 102, 101, 95, 114, 100, 61, 99, 114, 38, 101,
// 105, 61, 100, 122, 98, 70, 86, 38, 103, 119, 115, 95, 114,
String decodedUrl = new String(decodedBytes);
// 将会输出 https://www.google.co.nz/?gfe_rd=cr&ei=dzbFV&gws_rd=ssl#q=java

2.4. Java8 MIME编码(getMimeEncoder)

什么是MIME?菜鸟教程中给出了详细解答:

MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的标准,用来表示文档、文件或字节流的性质和格式。
MIME 消息能包含文本、图像、音频、视频以及其他应用程序专用的数据。
浏览器通常使用 MIME 类型(而不是文件扩展名)来确定如何处理URL,因此 Web服务器在响应头中添加正确的 MIME 类型非常重要。如果配置不正确,浏览器可能会无法解析文件内容,网站将无法正常工作,并且下载的文件也会被错误处理。

我们先从基本的MIME输入开始编码(demo_2_4):

private static StringBuilder getMimeBuffer() {
    StringBuilder buffer = new StringBuilder();
    for (int count = 0; count < 10; ++count) {
        buffer.append(UUID.randomUUID().toString());
    }
    return buffer;
}

MIME编码器使用基本字母表生成Base64编码的结果,这个编码格式是对于MIME友好的。这体现在:

  1. 每行输出不超过76个字符
  2. 编码后的字符串以回车符和换行符结尾 (\r\n)
StringBuilder buffer = getMimeBuffer();
byte[] encodedAsBytes = buffer.toString().getBytes();
String encodedMime = Base64.getMimeEncoder().encodeToString(encodedAsBytes);

在解码过程中,我们可以使用getMimeDecoder()方法,仍然是字节流->字符串:

byte[] decodedBytes = Base64.getMimeDecoder().decode(encodedMime);
String decodedMime = new String(decodedBytes);

3. 使用Apache Commons Code包进行编码解码

前面提到的是用Java8内置的Base64 API来操作,此外,还有其他的第三方包也提供了Base64的一些功能,例如Apache Commons
假设我们使用的是Maven托管的项目,再pom.xml中添加commons-codec的依赖:

<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.15</version>
</dependency>

译者注:如果你第一次接触maven,添加依赖后需要在项目根目录执行一次mvn install,或者在IDEA中选择pom.xml右键-Maven-重新加载项目,即可完成依赖的更新。

我们使用的是名为org.apache.commons.codec.binary.Base64的类,可以通过不同参数的构造方法来初始化一个实例对象:

  1. Base64(boolean urlSafe) —— 创建Base64对象,打开或关闭URL安全模式
  2. Base64(int lineLength) —— 创建Base64对象,不启用URL安全模式,通过参数控制行的长度,默认长度为76
  3. Base64(int lineLength, byte[] lineSeparator) —— 创建Base64对象,参数一控制行的长度,参数二设置额外的分隔符,默认为 CRLF(“\r\n”)

当Base64实例对象生成后,调用它进行编码解码就十分简单了。编码(demo_3):

// 输入需要编码的字符串
String originalInput = "test input";
// 用默认参数创建Base64对象
Base64 base64 = new Base64();
// 执行编码
String encodedString = new String(base64.encode(originalInput.getBytes()));
// 将会输出 dGVzdCBpbnB1dA==

此外,Base64类的decode()方法来进行解码,获得字符串:

String decodedString = new String(base64.decode(encodedString.getBytes()));
// 将会输出 test input

除了上面提到的使用Base64的实例对象的方式,还有一种方式不创建对象,而是直接用Base64类的静态方法:

String originalInput = "test input";
String encodedString = new String(Base64.encodeBase64(originalInput.getBytes()));
// dGVzdCBpbnB1dA==
String decodedString = new String(Base64.decodeBase64(encodedString.getBytes()));
// test input

4. 将字符串对象转化为字节数组

有些情况下,我们需要把String对象转化为byte[],有多种方法。
最简单的是直接使用String对象内置的getBytes()方法(demo_4):

String originalInput = "test input";
byte[] result = originalInput.getBytes();
assertEquals(originalInput.length(), result.length);

我们可以不使用默认的编码方式,而是为其指定一个:

String originalInput = "test input";
byte[] result = originalInput.getBytes(StandardCharsets.UTF_16);
assertTrue(originalInput.length() < result.length);

如果我们输入的是Base64编码后的字符串,想获得原始数据,可以使用Base64解码器:

String originalInput = "dGVzdCBpbnB1dA==";
byte[] result = Base64.getDecoder().decode(originalInput);
assertEquals("test input", new String(result));

我们还可以使用DatatypeConverter类的parseBase64Binary()方法:

String originalInput = "dGVzdCBpbnB1dA==";
byte[] result = DatatypeConverter.parseBase64Binary(originalInput);
assertEquals("test input", new String(result));

5. 总结

在本文中,我们学习了Java中如何使用Base64进行编码和解码。我们分别使用了Java8的原生API和ApacheCommons引入的新API。
除了文中提到的,还有其他相似功能的API,例如java.xml.bind.DataTypeConverter带有带有printHexBinary和parseBase64Binary功能。
要学习其他的API的使用方式,可以从GitHub上寻找更多的代码。

分类
Java

Java中的不可变对象

1. 概述

本文我们将讨论什么是不可变对象,在JAVA中如何创建一个不可变的对象,以及为何要把某些对象设置为不可变的,这样做又有什么好处。

2. 什么是不可变对象

顾名思意,不可变对象就是说对象一旦通过实例化产生,其所有的属性将永远保持初始值,不会改变。

这也意味着:一旦不可变对象实例化创建完毕,在其被回收前,我们可以自由调用对象上任意暴露的方法,而对象将时刻保持实例化的初始状态。

字符串类型String是比较典型的不可变类,通过该类实例化的字符串为不可变对象。这同时意味着,字符串对象一旦创建,无论我们调用该对象是任意方法,该字符串均不会发生变化:

        String a = "codedemo.club";
        String b = a.replace("codedemo", "yunzhi");➊

        System.out.println(a);
        System.out.println(b);

        Assertions.assertEquals("codedemo.club", a);➋
        Assertions.assertEquals("yunzhi.club", b);
  • ➊ 调用a对象的replace方法来替换a字符串中的特定内容。
  • ➋ 替换方法执行后,a的值并未发生变化,我们说a为不可变对象。
codedemo.club
yunzhi.club

字符串String类型上的replace方法虽然提供了字符串替换的功能,但由于String被设计为不可变类,所以replace被调用后虽然返回了替换后的字符串,但原字符串a并不发生变化。我们把字符串a称为不可变对象,把字符串a属的String类称为不可变类

3. Java中的 final 关键字

在尝试自定义不可变类之前,我们先简单了解一下Java中的final关键字。

在Java中,变量默认是可以改变的(变量:可以改变的量),这意味着变量定义完成后,我们可以随时改变变量的值。

但如果我们使用final关键字来声明变量,Java编译器则不允许我们在后面改变该变量的值。比如以下代码将在编译时发生编译错误:

        final String a = "codedemo.club";

        // 以下代码将发生编译错误
        a = "yunzhi.club";

编译错误如下:

java: cannot assign a value to final variable a

值得注意的final关键字的作用仅是:禁止改变变量的引用值。但并不能禁止我们通过调用对象上的方法来改变其内部属性值(状态值),比如:

        final List<String> strings = new ArrayList<>(); 
        Assertions.assertEquals(0, strings.size());

        // 你不能改变strings的引用值,否则将发生编译错误
        // strings = new ArrayList<>();

        // 但可以调用ArrayList的add方法来改变strings的状态
        strings.add("codedemo.club"); 
        Assertions.assertEquals(1, strings.size());

上述代码成功的向被声明为final类型的strings列表中增加了子元素,也就是说strings虽然被声明了final,但其状态仍然是可改变不稳定的。这是由于ArrayList类型并不是一个不可变类型造成的。

4. Java中创建不可变对象

在了解了如何避免变量被改变的方法后,下面我们尝试创建不可变对象。

不可变对象的原则是:无论外部如何调用对象提供的公有方法,该对象的状态均不会发生改变。

比如我们可以直接将类中的属性全部声明为final:

public class Student {
    final private String name;
    final private int age;

上述代码中我们使用了final关键字来声明了Student类中的所有属性,而且这些属性的类型为主类型或不可变类型,这保证了Stduent类为不可变类,由该类实例化的对象为不可变对象。

但如果Student类型再增加一个Clazz班级属性,则欲保证Student为不可变类型,则需要保证Calzz类同样为不可变类。

public class Student {
    final private String name;
    final private int age;
    // 此时Student是否为可变类取决于Clazz是否为可变类
    final private Clazz clazz;

大多数时候,我们都需要在类中定义多个属性以存储对象的各个状态,对于声明为final类型的属性而言,只能在对象实例化时为其赋值:

public class Student {
    final private String name;
    
    // 声明属性时赋初值
    final private int age = 12;

    // 此时Student是否为可变类取决于Clazz是否为可变类
    final private Clazz clazz;

    /**
     * 或在构造函数中为其赋值
     */
    public Student(String name, Clazz clazz) {
        this.name = name;
        this.clazz = clazz;
    }
    
    // 此处省略了getter方法。所有属性均为final,所以该类无setter方法。

值得注意的是虽然Student在此为不可变类型,但Java提供的反射机制是可以忽视该不可变性,从而改变不可变对象的。在实际的使用中,我们往往不会(也不应该)这么做。

5. 不可变对象的优势

由于不可变对象状态的稳定性,所以在多线程情况下,我们可以放心地将不可变对象在不同的线程间传递、共享,而可以完全忽略各个线程是如何、何时利用该不可变对象的。

同时,我们也可以放心地将同一不可变对象分别嵌入到其它多个对象(可能是不同类型的)中,从而实现多对象共享某一相同对象的目标。重要的是,在此共享过程中,我们完全不必担心该共享对象可能会发生改变。

总之:不可变对象一旦实例化完成,我们便可以放心的使用它,而不必担心该对象可能会发生变化。

6. 总结

不可变对象一旦创建便会一直保持当前的状态,所以说它们是线程安全以及没有副作用的。正是由于这些特性,不可变对象往往被更多用于多线程环境中。

分类
Java

什么是POJO类

1. 概述

本文中,我们将讨论什么是POJO(Plain Old Java Object)。并将POJO与JavaBean做一个简单的比较,阐述为何以及如何将POJO转换为JavaBean。

2. Plain Old Java Object

2.1 什么是POJO

从字面意思上来看。P = plain,译为简单的,直白的;O = old,译为老的。综合来看笔者认为可以如下理解:一眼就能看明白的常规Java对象。维基百科上如下解释:In software engineering, a plain old Java object (POJO) is an ordinary Java object, not bound by any special restriction. The term was coined by Martin Fowler, Rebecca Parsons and Josh MacKenzie in September 2000(在软件工程领域,POJO是一个普通的Java对象,其不受任何特殊的限制。该术语由Martin Fowler, Rebecca Parsons and Josh MacKenzie在2000年提出)。

只所以说它简单、直白、老,是由于POJO具有以下特点:

  • ➊ 不继承任何类
  • ➋ 不实现任何接口
  • ➌ 无任何注解
  • ➍ 不依赖于任何第三方框架(库)

比如我们创建如下POJO:

// ➌
public class EmployeePojo➊ {
 
    public String➋ firstName;
    public String➋ lastName;
    private LocalDate➋ startDate;
 
    public EmployeePojo(String firstName, String lastName, LocalDate startDate) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.startDate = startDate;
    }
 
    public String name()➍ {
        return this.firstName + " " + this.lastName;
    }
 
    public LocalDate getStart()➍ {
        return this.startDate;
    }
}
  • ➊ 无继承、无实现
  • ➋ 属性类型均为java内置类型
  • ➌ 无注解

由于该类未依赖于任何第三方框架(库),所以可以被任何的Java程序使用,这是POJO最明显的优点。而类中的方法命名➍也可以比较随性,这就不太好了。

正是由于这种随性的方法命名➍方式,无意间增加了在交流上的成本:

首先,其它的程序员需要花更多的时间来理解该无规则命名的类,而当这样的类越来越多时,对程序员来讲无疑是一个恶梦。

其次,其它框架无法自动理解、使用该无规则命名的类,这也就导致了无法将此类直接应用到现在框架(库)上。

下面我们使用代码来展示下一般框架是如何利用反射机制来获取类的属性信息,以及为何随性命名的POJO无法很好地配合框架工作的。

2.2 反射POJO

我们添加一个commons-beanutils到当前项目中:

<dependency>
    <groupId>commons-beanutils</groupId>
    <artifactId>commons-beanutils</artifactId>
    <version>1.9.4</version>
</dependency>

然后使用如下方法来反射EmployeePojo的属性:

class EmployeePojoTest {
    @Test
    void inspectProperties() {
        // 反射EmployeePojo的属性
        List<String> propertyNames =
                Arrays.stream(PropertyUtils.getPropertyDescriptors(EmployeePojo.class))
                        .sequential()
                        .map(PropertyDescriptor::getDisplayName)
                        .collect(Collectors.toList());

        // 打印反射的结果
        System.out.println(propertyNames);

        // 打印的结果总归还需要眼睛来看,而使用断言则不需要眼睛来看控制台,且更规范
        assertEquals(1, propertyNames.size());
        assertEquals("start", propertyNames.get(0));
    }
}

运行测试代码,最终控制台打印如下:

[start]

可见对EmpoyeePojo进行反射后并获取到其它两个属性:firstName、lastName。

这并不是由于我们使用了commons-beanutils造成的,使用其它的库(比如:Jackson)来反射EmpoyeePojo,也会得到同样的结果。

我们希望对EmpoyeePojo的反射能够获取到所有的字段:firstName、lastName以及startDate,这对框架而言非常的有必要。虽然一般的POJO无法实现上述功能,但值得庆幸的是大多数的框架(库)均支持一种叫做JavaBean的命名规范,若想反射出所有的字段则只需要简单的遵循该规范即可。

3. JavaBeans

3.1 什么是JavaBean

JavaBean原则上也是一个POJO,相较于POJO它还需要遵循以下规范:

  • 访问级别:所有的属性需要定义为private类型,并且提供getter以及setter方法。
  • 方法命名:getter、setter方法必须以getX以及setX的命名规范(如果字段类型为boolean,则getter方法还可命名为:isX)。
  • 默认构造函数:必须提从一个没有任何参数的构造函数。因为只有这样才能够在不提供任何参数的情况下创建对应的实例,最经典的使用场景为:反序列化。
  • 可(反)序列化:必须实现Serializable接口(该接口中未定义任何方法,作用仅为标识class是否可(反)序列化。

3.2 将EmployeePojo转换为JavaBean

最终我们将EmployeePojo转换为如下JavaBean:

public class EmployeeBean implements Serializable➍ {
    private static final long serialVersionUID = -3760445487636086034L;➍
    private➊ String firstName;
    private➊ String lastName;
    private➊ LocalDate startDate;

    public EmployeeBean()➌ {
    }

    public EmployeeBean(String firstName, String lastName, LocalDate startDate) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.startDate = startDate;
    }

    public String getFirstName()➋ {
        return firstName;
    }

    public void setFirstName(String firstName)➋ {
        this.firstName = firstName;
    }

    public String getLastName()➋ {
        return lastName;
    }

    public void setLastName(String lastName)➋ {
        this.lastName = lastName;
    }

    public LocalDate getStartDate()➋ {
        return startDate;
    }

    public void setStartDate(LocalDate startDate)➋ {
        this.startDate = startDate;
    }
}
  • ➊ 访问级别
  • ➋ 方法命名
  • ➌ 空构造函数
  • ➍ 实现接口

3.3 反射JavaBean

使用同样的代码来反射EmployeeBean,则得到如下结果:

[firstName, lastName, startDate]

4. 权衡利弊

虽然JavaBeans在很多时候为我们带来了较大的便利,但其缺点也很明显,这需要我们在使用时结合其优缺点来权衡利弊。

JavaBeans的主要缺点如下:

  • 易变性:由于我们需要为每个字段都设置一个setter方法,所以原则上每个字段都可以通过setter方法来重新赋值。这有时候会引发一些并发或数据不一致的问题。
  • 死板:我们不得不为每个字段都提供getter/setter方法,而有些时候我们根本用不到某些getter/setter,但却不得不收书定大量样板化的代码。
  • 空构造函数:由于某些对象在实例化时必须传入相应的字段才会有意义,所以我们可以在构造函数中声明必须传入的字段从而达到约束特殊字段的目的。而此时我们提供的空构造函数破坏了这一约束性。

但值得庆幸的事,大多数的框架已经习惯并适应了JavaBean,并能够很好的对其缺点进行处理。

5. 总结

本文中我们对POJO进行了解读,给出了POJO的定义堆满,并给出了相应的事例。通过反射测试来说明POJO具有的天生缺点,随后引入了JavaBean的规范、定义,并通过实例、反射测试来说明JavaBean的优势。凡事都具有两面性,在文章的最后,我们列举了JavaBean的缺点。