2024 年强劲的 Java 语言特性

无论您是初学者还是资深 Java 开发人员,您都努力通过代码实现雄心勃勃的目标,同时享受渐进式进步。除了许多性能、稳定性和安全性更新之外,Java 21 还提供了旨在提高 Java 开发效率的新功能和增强功能。学习这些语言特性的最佳方法是在 Java 项目中使用它们。

设置

12 月的假期已经过去,但还有许多其他机会可以提供礼物。因此,让我们构建一个 Java 应用程序,您可以在其中为某人订购一份包装好的礼物。项目 wrapup 是一个简单的 http 处理程序实现,它通过 HTTP POST 方法从发件人到收件人以 JSON 格式返回礼物。

在开始行动之前,您应该知道您需要一个 IDE,至少需要在本地机器上安装 JDK 21Maven 来重现示例。我使用 Oracle Java Platform Extension for Visual Studio Code 通过 View > Command Palette > Java: New Project > Java with Maven 生成了我的项目,将项目命名为 wrapup 并选择了包名 org.ammbra.advent

因此,让我们看看如何使用 Java 21 语言结构将礼物打包成 JSON。

走向 Java 的简化起点

IDE 生成的项目包含一个启动类 Wrappup.java,位于包 org.ammbra.advent 中。

package org.ammbra.advent;

public class Wrapup {

	public static void main(String[] args) {
		System.out.println("Hello World!");
	}
}

虽然 main 方法声明了参数,但这些参数后来并没有在其范围内被处理。在 JDK 21 中,JEP 445 引入了无名类和实例 main 方法作为预览功能,以减少编写简单程序时的冗长性。因此,您可以将之前的代码重构为

package org.ammbra.advent; 

class Wrapup {

    void main() { System.out.println("Hello, World!");}
}

要运行前面的代码片段,请转到终端窗口并键入以下命令

java --enable-preview --source 21  src/main/java/org/ammbra/advent/Wrapup.java

目前,让我们将 Wrapup 类演变为仅处理 HTTP POST 请求并生成 JSON 输出,方法是实现 com.sun.net.httpserver.HttpHandler

class Wrapup implements HttpHandler {

	void main() throws IOException {
		var server = HttpServer.create(
				new InetSocketAddress("", 8081), 0);
		var address = server.getAddress();
		server.createContext("/", new Wrapup());
		server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
		server.start();
		System.out.printf("http://%s:%d%n",
				address.getHostString(), address.getPort());
	}

	@Override
	public void handle(HttpExchange exchange)
			throws IOException {

		int statusCode = 200;

		String requestMethod = exchange.getRequestMethod();
		if (!"POST".equalsIgnoreCase(requestMethod)) {
			statusCode = 400;
		}

		// Get the request body input stream
		InputStream reqBody = exchange.getRequestBody();

		// Read JSON from the input stream
		JSONObject req = RequestConverter.asJSONObject(reqBody);

		String sender = req.optString("sender");
		String receiver = req.optString("receiver");
		String message = req.optString("celebration");
		String json = "{'receiver':'" + receiver
				+ "', 'sender':'" + sender
				+ "','message':'" + message + "'}";

		exchange.sendResponseHeaders(statusCode, 0);
		try (var stream = exchange.getResponseBody()) {
			stream.write(json.getBytes());
		}
	}
}

该项目依赖于 Maven 进行依赖项管理,为了处理 JSON,您应该修改 pom.xml 以包含 json 依赖项

<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>${json.version}</version>
</dependency>

要启动程序,您不需要使用 mvn 功能,您可以直接使用 java/javac,方法是转到终端窗口并运行

#export path to .m2 json library
export $JSON_PATH=/<YOU>/.m2/repository/org/json/json/20231013

#launch the app
java -classpath $JSON_PATH/json-20231013.jar --enable-preview --source 21 \
  src/main/java/org/ammbra/advent/Wrapup.java

如果您的操作系统是 Windows,请转到 命令提示符 窗口并运行

set JSON_PATH=C:\.m2\repository\org\json\json\20231013

java -classpath $JSON_PATH/json-20231013.jar --enable-preview --source 21 
  src/main/java/org/ammbra/advent/Wrapup.java

让我们尝试一个简单的 curl 请求来检查输出

curl -X POST http://127.0.0.1:8081 -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"Happy New Year!"}'

您应该收到以下响应

{'receiver':'Duke', 'sender':'Ana','message':'Happy New Year!'}

向某人打招呼是一个不错的举动,但当发件人希望发送更实质性的礼物时,该应用程序还应该提供更复杂的响应。为了解决这个问题,让我们对应用程序域进行建模。

使用记录和密封类型进行数据建模

在特殊场合赠送礼物可以从明信片到更实质性的礼物不等。

对于 wrapup 项目,让我们考虑以下要求

  • 发件人可以做一个友好的举动,并提供一份礼物。
  • 礼物可以是明信片,也可以在明信片的基础上添加以下内容之一:在线优惠券、购买体验或实物礼物。
  • 明信片没有关联的成本,其他 3 种类型的礼物都有价格。
  • 在线优惠券有过期日期。
  • 礼物可以放在盒子里,盒子有额外的成本。
  • 发件人可以根据庆祝活动赠送不同的明信片或惊喜,但绝不会将 2 张明信片作为礼物发送。

图 1 项目类图

上面的图表显示了对先前描述的场景进行建模的一种可能方式。 PostcardCouponExperiencePresent 是记录,因为它们应该是不可变数据的载体,代表可能的惊喜选项。它们还通过密封接口 Intention 共享一个通用的 JSON 格式化过程。

package org.ammbra.advent.surprise;

import org.json.JSONObject;

public sealed interface Intention
       permits Coupon, Experience, Present, Postcard {

    JSONObject asJSON();
}

一个 Gift 是另一种记录类型,包含一个 Postcard 和一个 Intention

package org.ammbra.advent.surprise;

import org.json.JSONObject;

public record Gift(Postcard postcard, Intention intention) {

    public JSONObject merge(String option) {
       JSONObject intentionJSON = intention.asJSON();
       JSONObject postcardJSON = postcard.asJSON();
       return postcardJSON.put(option, intentionJSON);
    }
}

Celebration 是一个枚举,存储发送礼物的定义场合。根据 Choice 枚举的值,wrapup 将以 JSON 格式返回相应的礼物。接下来,让我们定义 CouponExperiencePostcardPresent 记录,并使用字符串模板格式化它们的数据。

具有表达力字符串模板的语法灵活性

密封接口 Intention 限制了继承,只允许特定的子类型,但它也是一个有用的语言结构,用于传达 CouponExperiencePostcardPresent 记录的目的。例如,Coupon 对象的特征是它的价格、过期日期和成本的货币。由于礼物表示应该遵循 JSON 格式,让我们利用字符串模板来实现这一点。

字符串模板作为 Java 21 中的预览功能 提供,并将 在 JDK 22 中获得第二次预览。字符串模板将文字文本与嵌入式表达式和模板处理器混合在一起,以生成专门的结果,例如 JSONObject。要返回一个 JSONObject,模板表达式需要

  • 一个模板处理器 (JSON)
  • 一个点字符 (U+002E) 和
  • 包含嵌入式表达式的模板 (Coupon 记录字段)。

CouponExperiencePostcardPresent 记录可以从字符串到 JSON 共享相同的模板处理器

package org.ammbra.advent.surprise;

import org.json.JSONObject;

public sealed interface Intention
       permits Coupon, Experience, Present, Postcard {

    StringTemplate.Processor<JSONObject, RuntimeException> JSON = StringTemplate.Processor.of(
          (StringTemplate st) -> new JSONObject(st.interpolate())
    );

    JSONObject asJSON();
}

注意:为了简洁和清晰,本示例采取了一些捷径,并不一定实现了所有针对将投入生产的应用程序推荐的最佳实践(安全性、可扩展性、广泛的错误处理等)。例如,您应该考虑检查模板的值,并在值可疑时抛出已检查异常 (JSONException)!

有了这个模板处理器,Coupon 记录就变成了

package org.ammbra.advent.surprise;

import org.json.JSONObject;
import java.time.LocalDate;
import java.util.Currency;

public record Coupon(double price, LocalDate expiringOn, Currency currency)
       implements Intention {

	public Coupon {
		Objects.requireNonNull(currency, "currency is required");
		if (price < 0) {
			throw new IllegalArgumentException("Price of an item cannot be negative");
		}
	}

    @Override
    public JSONObject asJSON() {
       return JSON. """
             {
                 "currency": "\{currency}",
                 "expiresOn" : "\{ expiringOn}",
                 "cost": "\{price}"
             }
             """ ;
    }
}

ExperiencePostcard 记录共享类似的模板格式化逻辑。由于 Present 的成本取决于礼品包装的成本,因此 asJSON 方法的实现如下

package org.ammbra.advent.surprise;

import org.json.JSONObject;
import java.util.Currency;

public record Present(double itemPrice, double boxPrice, Currency currency) 
        implements Intention {

	public Present {
		Objects.requireNonNull(currency, "currency is required");
		if (itemPrice < 0) {
			throw new IllegalArgumentException("Price of an item cannot be negative");
		} else if (boxPrice < 0) {
			throw new IllegalArgumentException("Price of the box cannot be negative");
		}
	}

    @Override
    public JSONObject asJSON() {

       return JSON. """
             {
                 "currency": "\{currency}",
                 "boxPrice": "\{boxPrice}",
                 "packaged" : "\{ boxPrice > 0.0}",
                 "cost": "\{(boxPrice > 0.0) ? itemPrice + boxPrice : itemPrice}"
             }
             """ ;
    }
}

现在项目已经有了数据模型的每个元素,让我们研究如何对包含礼物的 JSON 的 HTTP 响应进行原型设计。

使用模式匹配在 switch 表达式中实现清晰的控制流

wrapup 应用程序的用户应该能够发出不同的请求,将个性化的礼物发送给某人

#send a postcard with a greeting for current year
curl -X POST http://127.0.0.1:8081 -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"CURRENT_YEAR", "type":"NONE"}' 

#send a coupon and a postcard with a greeting for current year 
curl -X POST http://127.0.0.1:8081 -H 'Content-Type: application/json' \ 
  -d '{"receiver":"Duke","sender":"Ana","celebration":"CURRENT_YEAR", "option":"COUPON", "itemPrice": "24.2"}' 

#send a birthday present and postcard
curl -X POST http://127.0.0.1:8081 -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"BIRTHDAY", "option ":"PRESENT", "itemPrice": "27.8", "boxPrice": "2.0"}'  

#send a happy new year postcard and an experience 
curl -X POST http://127.0.0.1:8081 -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"NEW_YEAR", "option ":"EXPERIENCE", "itemPrice": "47.5"}'

为了支持所有这些操作,自定义的 HTTPHandler 应该能够处理每个请求主体并返回相应的礼物作为 JSON。鉴于 POST 请求主体的复杂性,让我们将其表示为一个基于潜在数据的记录

package org.ammbra.advent.request;

import org.ammbra.advent.surprise.Celebration;

public record RequestData(String sender, String receiver,
                     Celebration celebration, Choice choice, 
                     double itemPrice, double boxPrice) {

    private RequestData(Builder builder) {
       this(builder.sender, builder.receiver,
             builder.celebration, builder.choice,
             builder.itemPrice, builder.boxPrice);
    }

    public static class Builder {
       private String sender;
       private String receiver;
       private Celebration celebration;
       private Choice choice;
       private double itemPrice;
       private double boxPrice;

       public Builder sender(String sender) {
          this.sender = sender;
          return this;
       }

       public Builder receiver(String receiver) {
          this.receiver = receiver;
          return this;
       }

       public Builder celebration(Celebration celebration) {
          this.celebration = celebration;
          return this;
       }

       public Builder choice(Choice choice) {
          this.choice = choice;
          return this;
       }

       public Builder itemPrice(double itemPrice) {
          this.itemPrice = itemPrice;
          return this;
       }

       public Builder boxPrice(double boxPrice) {
          this.boxPrice = boxPrice;
          return this;
       }

       public RequestData build() throws IllegalStateException {
          return new RequestData(this);
       }
    }
}

RequestData 使用备用构造函数将 Builder 实例传递给记录构造函数。使用此记录定义,handle(HttpExchange exchange) 方法内的逻辑重构为

@Override
public void handle(HttpExchange exchange) throws IOException {
    // ...

    // Get the request body input stream
    InputStream reqBody = exchange.getRequestBody();

    // Read JSON from the input stream
    JSONObject req = RequestConverter.asJSONObject(reqBody);
    RequestData data = RequestConverter.fromJSON(req);

// ... }

接下来,让我们根据请求中存在的礼物选项评估惊喜内容,并确保使用详尽的 switch 表达式对每种情况进行相应处理

double price = data.itemPrice();
double boxPrice = data.boxPrice();
Choice choice = data.choice();
Intention intention = switch (choice) {
	case NONE -> new Coupon(0.0, null, Currency.getInstance("USD"));
	case COUPON -> {
		LocalDate localDate = LocalDateTime.now().plusYears(1).toLocalDate();
		yield new Coupon(data.itemPrice(), localDate, Currency.getInstance("USD"));
	}
	case EXPERIENCE -> new Experience(data.itemPrice(), Currency.getInstance("EUR"));
	case PRESENT -> new Present(data.itemPrice(), data.boxPrice(), Currency.getInstance("RON"));
};

如果没有默认分支,添加新的 Choice 值会导致编译错误,这将使我们考虑如何处理这些新情况。

由于礼物意图现在已经明确,让我们通过使用 switch 的模式匹配来处理最终的 JSONObject 响应。

Postcard postcard = new Postcard(data.sender(), data.receiver(), data.celebration());
Gift gift = new Gift(postcard, intention);

JSONObject json = switch (gift) {
    case Gift(Postcard p1, Postcard p2) -> {
       String message = "You cannot send two postcards!";
       throw new UnsupportedOperationException(message);
    }
    case Gift(Postcard p, Coupon c)
          when (c.price() == 0.0) -> p.asJSON();
    case Gift(Postcard p, Coupon c) -> {
       String option = choice.name().toLowerCase();
       yield gift.merge(option); 
    }
    case Gift(Postcard p, Experience e) -> {
       String option = choice.name().toLowerCase();
       yield gift.merge(option); 
    }
    case Gift(Postcard p, Present pr) -> {
       String option = choice.name().toLowerCase(); 
       yield gift.merge(option);
    }
};

在这种情况下,switch 表达式使用 Gift 的嵌套记录模式来确定最终的 JSON。如初始要求中所述,发送者不能将两张明信片作为礼物发送,因此不支持该操作。另一种特殊情况是发送者只提供一张免费明信片,而最终的礼物没有相关成本。因此,switch 表达式首先在受保护的 case 标签中处理这种情况 -case Gift(Postcard p, Coupon c) when (c.price() == 0.0)– 因为不受保护的模式 case 标签 -case Gift(Postcard p, Coupon c)– 覆盖了具有相同模式的受保护模式 case 标签。

记录和记录模式非常适合简化数据处理,但 Wrapup 程序只需要一些组件进行进一步处理。

使用未命名模式和变量的简洁代码

当 switch 对多个情况执行相同的操作时,您可以使用未命名模式变量来提高其可读性。未命名模式和变量在 JDK 21 中成为预览功能,目标是在 JDK 22 中最终确定(参见 JEP 456)。

先前 switch 表达式中的某些情况需要 PostcardCouponExperiencePresent,但从未使用过这些记录中的其他值。在使用未命名模式变量重构 switch 后,它变为

Gift gift = new Gift(postcard, intention);

JSONObject json = switch (gift) {
    case Gift(Postcard _, Postcard _) -> {
       String message = "You cannot send two postcards!";
       throw new UnsupportedOperationException(message);
    }

    case Gift(Postcard p, Coupon c)
          when (c.price() == 0.0) -> p.asJSON();

    case Gift(_, Coupon _), Gift(_, Experience _), 
          Gift(_, Present _) -> {
       String option = choice.name().toLowerCase();
       yield gift.merge(option);
    }
};

现在 Wrapup 实现已达到最终状态,请构建项目并从终端窗口再次启动它

#compile the sources
javac --enable-preview --source 21 -g -classpath $JSON_PATH/json-20231013.jar \ 
  -sourcepath src/main/java -d target/classes src/main/java/org/ammbra/advent/Wrapup.java

#launch the app 
java --enable-preview --source 21 -classpath $JSON_PATH/json-20231013.jar:target/classes \
  src/main/java/org/ammbra/advent/Wrapup.java

并通过 curl 发出 POST 请求

curl -X POST http://127.0.0.1:8081 -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"NEW_YEAR", "option ":"EXPERIENCE", "itemPrice": "47.5"}'

如果您想进一步尝试本文中使用的代码,请访问 wrapup 存储库。

最后的想法

JDK 21 的预览功能,如字符串模板、未命名模式、变量、未命名类和实例 main 方法,可以帮助您最大限度地减少重复和冗长的代码量,使您能够更清晰、更简洁地表达意图。使用记录和密封类型来建模您的域,并使用记录模式和 switch 的模式匹配来启用强大的数据导航和处理形式。随着新年的开始,我鼓励您尝试这些功能,以提高您的 Java 生产力。

本文的内容最初在 The JVM Programming Advent Calendar 中分享。