Java数据库应用原型

一个使用 Spring Boot 和容器进行测试、Keycloak 提供安全、PostgreSQL 提供数据持久化的,带有 REST 和安全功能的 Java 数据库应用原型。

在工作中开发时,我多次需要一个简单应用的模板,以便基于此模板开始为手头的项目添加特定代码。
在本文中,我将创建一个简单的 Java 应用程序,它连接到数据库,暴露一些 REST 端点,并使用基于角色的访问来保护这些端点。
目的是拥有一个最小化且功能齐全的应用程序,然后可以针对特定任务进行定制。
对于数据库,我们将使用 PostgreSQL;对于安全,我们将使用 Keycloak,两者都通过容器部署。在开发过程中,我使用 podman 来测试容器是否正确创建(作为 docker 的替代品——它们在大多数情况下可以互换)作为一次学习体验。
应用程序本身是使用 Spring Boot 框架开发的,并使用 Flyway 进行数据库版本管理。
所有这些技术都是 Java EE 领域业界标准,在项目中被使用的可能性很高。

我们构建原型的核心需求是一个图书馆应用程序,它暴露 REST 端点,允许创建作者、书籍以及它们之间的关系。这将使我们能够实现一个多对多关系,然后可以将其扩展用于任何可以想象的目的。
完整可用的应用程序可以在 https://github.com/ghalldev/db_proto 找到。
本文中的代码片段取自该代码库

在创建容器之前,请确保使用您偏好的值定义以下环境变量(教程中故意省略了它们,以避免传播多个用户使用的默认值):

DOCKER_POSTGRES_PASSWORD
DOCKER_KEYCLOAK_ADMIN_PASSWORD
DOCKER_GH_USER1_PASSWORD

配置 PostgreSQL:

docker container create --name gh_postgres --env POSTGRES_PASSWORD=$DOCKER_POSTGRES_PASSWORD --env POSTGRES_USER=gh_pguser --env POSTGRES_INITDB_ARGS=--auth=scram-sha-256 --publish 5432:5432 postgres:17.5-alpine3.22
docker container start gh_postgres

配置 Keycloak:
首先是容器的创建并启动:

docker container create --name gh_keycloak --env DOCKER_GH_USER1_PASSWORD=$DOCKER_GH_USER1_PASSWORD --env KC_BOOTSTRAP_ADMIN_USERNAME=gh_admin --env KC_BOOTSTRAP_ADMIN_PASSWORD=$DOCKER_KEYCLOAK_ADMIN_PASSWORD --publish 8080:8080 --publish 8443:8443 --publish 9000:9000 keycloak/keycloak:26.3 start-dev
docker container start gh_keycloak

在容器启动并运行后,我们可以继续创建领域、用户和角色(这些命令必须在正在运行的容器内部执行):

cd $HOME/bin
./kcadm.sh config credentials --server http://localhost:8080 --realm master --user gh_admin --password $KC_BOOTSTRAP_ADMIN_PASSWORD
./kcadm.sh create realms -s realm=gh_realm -s enabled=true
./kcadm.sh create users -s username=gh_user1 -s email="[email protected]" -s firstName="gh_user1firstName" -s lastName="gh_user1lastName" -s emailVerified=true -s enabled=true -r gh_realm
./kcadm.sh set-password -r gh_realm --username gh_user1 --new-password $DOCKER_GH_USER1_PASSWORD
./kcadm.sh create roles -r gh_realm -s name=viewer -s 'description=Realm role to be used for read-only features'
./kcadm.sh add-roles --uusername gh_user1 --rolename viewer -r gh_realm
./kcadm.sh create roles -r gh_realm -s name=creator -s 'description=Realm role to be used for create/update features'
./kcadm.sh add-roles --uusername gh_user1 --rolename creator -r gh_realm
ID_ACCOUNT_CONSOLE=$(./kcadm.sh get clients -r gh_realm --fields id,clientId | grep -B 1 '"clientId" : "account-console"' | grep -oP '[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}')
./kcadm.sh update clients/$ID_ACCOUNT_CONSOLE -r gh_realm -s 'fullScopeAllowed=true' -s 'directAccessGrantsEnabled=true'

用户 gh_user1 在领域 gh_realm 中被创建,并拥有 viewercreator 角色。

您可能已经注意到,我们没有创建新的客户端,而是使用了 Keycloak 自带的一个默认客户端:account-console。这是为了方便起见,在实际场景中,您会创建一个特定的客户端,然后将其更新为具有 fullScopeAllowed(这会导致领域角色被添加到令牌中——默认情况下不添加)和 directAccessGrantsEnabled(允许通过 Keycloak 的 openid-connect/token 端点生成令牌,在我们的例子中使用 curl)。

创建的角色随后可以在 Java 应用程序内部使用,以根据我们约定的契约来限制对某些功能的访问——viewer 只能访问只读操作,而 creator 可以执行创建、更新和删除操作。当然,同样地,可以根据任何原因创建各种角色,只要约定的契约被明确定义并被所有人理解。
角色还可以进一步添加到组中,但本教程不包含这部分内容。

但是,在实际使用这些角色之前,我们必须告诉 Java 应用程序如何提取角色——这是必须的,因为 Keycloak 将角色添加到 JWT 的方式是其特有的,所以我们必须编写一段自定义代码,将其转换为 Spring Security 可以使用的东西:

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    // 遵循与 org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter 相同的模式
    Converter<Jwt, Collection<GrantedAuthority>> keycloakRolesConverter = new Converter<>() {
        private static final String DEFAULT_AUTHORITY_PREFIX = "ROLE_";
        //https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java#L901
        private static final String KEYCLOAK_REALM_ACCESS_CLAIM_NAME = "realm_access";
        private static final String KEYCLOAK_REALM_ACCESS_ROLES = "roles";

        @Override
        public Collection<GrantedAuthority> convert(Jwt source) {
            Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            Map<String, List<String>> realmAccess = source.getClaim(KEYCLOAK_REALM_ACCESS_CLAIM_NAME);
            if (realmAccess == null) {
                logger.warn("No " + KEYCLOAK_REALM_ACCESS_CLAIM_NAME + " present in the JWT");
                return grantedAuthorities;
            }
            List<String> roles = realmAccess.get(KEYCLOAK_REALM_ACCESS_ROLES);
            if (roles == null) {
                logger.warn("No " + KEYCLOAK_REALM_ACCESS_ROLES + " present in the JWT");
                return grantedAuthorities;
            }
            roles.forEach(
                    role -> grantedAuthorities.add(new SimpleGrantedAuthority(DEFAULT_AUTHORITY_PREFIX + role)));

            return grantedAuthorities;
        }

    };
    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(keycloakRolesConverter);

    return jwtAuthenticationConverter;
}

AppConfiguration 类中还完成了其他重要配置,例如启用方法安全性和禁用 CSRF。

现在我们可以在 REST 控制器中使用 @org.springframework.security.access.prepost.PreAuthorize 注解来限制访问:

@PostMapping("/author")
@PreAuthorize("hasRole('creator')")
public void addAuthor(@RequestParam String name, @RequestParam String address) {
  authorService.add(new AuthorDto(name, address));
}

@GetMapping("/author")
@PreAuthorize("hasRole('viewer')")
public String getAuthors() {
  return authorService.allInfo();
}

通过这种方式,只有成功通过身份验证且拥有 hasRole 中列出的角色的用户才能调用端点,否则他们将收到 HTTP 403 Forbidden 错误。

在容器启动并配置完成后,Java 应用程序可以启动了,但在启动之前需要添加数据库密码——这可以通过环境变量完成(下面是一个 Linux shell 示例):

export SPRING_DATASOURCE_PASSWORD=$DOCKER_POSTGRES_PASSWORD

现在,如果一切正常启动并运行,我们可以使用 curl 来测试我们的应用程序(以下所有命令均为 Linux shell 命令)。

使用之前创建的用户 gh_user1 登录并提取身份验证令牌:

KEYCLOAK_ACCESS_TOKEN=$(curl -d 'client_id=account-console' -d 'username=gh_user1' -d "password=$DOCKER_GH_USER1_PASSWORD" -d 'grant_type=password' 'http://localhost:8080/realms/gh_realm/protocol/openid-connect/token' | grep -oP '"access_token":"\K[^"]*')

创建一个新作者(这将测试 creator 角色是否有效):

curl -X POST --data-raw 'name="GH_name1"&address="GH_address1"' -H "Authorization: Bearer $KEYCLOAK_ACCESS_TOKEN" 'localhost:8090/library/author'

检索库中的所有作者(这将测试 viewer 角色是否有效):

curl -X GET -H "Authorization: Bearer $KEYCLOAK_ACCESS_TOKEN" 'localhost:8090/library/author'

至此,您应该拥有了创建自己的 Java 应用程序所需的一切,可以根据需要对其进行扩展和配置。


【注】本文译自:Java Spring Boot Template With PostgreSQL, Keycloak Securit

单体架构中的事件驱动架构:Java应用程序的渐进式重构

传统观点认为事件驱动架构属于微服务架构范畴,服务通过消息代理进行异步通信。然而,事件驱动模式一些最具价值的应用恰恰发生在单体应用程序内部——在这些地方,紧密耦合已造成维护噩梦,而渐进式重构则提供了一条通往更好架构的路径,且无需分布式系统的运维复杂性。

为何在单体应用中使用事件有意义

传统的分层单体应用存在一个特定问题:直接的方法调用在组件之间创建了僵化的依赖关系。您的订单处理代码直接调用库存管理,库存管理又调用仓库系统,继而触发电子邮件通知。每个组件都了解其他几个组件,从而形成一个纠缠不清的网,更改其中一部分需要理解并测试它所触及的所有内容。

事件驱动模式引入了间接性。当下单时,订单服务发布一个"OrderPlaced"事件。其他对订单感兴趣的组件——库存、发货、通知——订阅此事件并独立响应。订单服务不知道也不关心谁在监听。即使这些组件存在于同一个代码库并共享同一个数据库,它们也变得松散耦合。

这种方法提供了立竿见影的好处,而无需将应用程序拆分为微服务。您在保持单体应用运维简单性的同时,获得了可测试性、灵活性和更清晰的边界。当您最终需要提取服务时,事件驱动的结构使得过渡更加平滑,因为组件已经通过定义良好的消息进行通信,而不是直接的方法调用。

起点:一个紧密耦合的订单系统

考虑一个使用 Spring Boot 构建的典型电子商务单体应用。订单创建流程如下所示:

@Service
@Transactional
public class OrderService {
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final ShippingService shippingService;
    private final LoyaltyService loyaltyService;
    private final EmailService emailService;
    private final AnalyticsService analyticsService;

    public OrderService(
        OrderRepository orderRepository,
        InventoryService inventoryService,
        PaymentService paymentService,
        ShippingService shippingService,
        LoyaltyService loyaltyService,
        EmailService emailService,
        AnalyticsService analyticsService
    ) {
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.shippingService = shippingService;
        this.loyaltyService = loyaltyService;
        this.emailService = emailService;
        this.analyticsService = analyticsService;
    }

    public Order createOrder(CreateOrderRequest request) {
        // 验证库存
        for (OrderItem item : request.getItems()) {
            if (!inventoryService.checkAvailability(item.getProductId(), item.getQuantity())) {
                throw new InsufficientInventoryException(item.getProductId());
            }
        }

        // 处理支付
        PaymentResult payment = paymentService.processPayment(
            request.getCustomerId(),
            calculateTotal(request.getItems()),
            request.getPaymentDetails()
        );

        if (!payment.isSuccessful()) {
            throw new PaymentFailedException(payment.getErrorMessage());
        }

        // 创建订单
        Order order = new Order(
            request.getCustomerId(),
            request.getItems(),
            payment.getTransactionId()
        );
        order.setStatus(OrderStatus.CONFIRMED);
        Order savedOrder = orderRepository.save(order);

        // 预留库存
        for (OrderItem item : request.getItems()) {
            inventoryService.reserveInventory(item.getProductId(), item.getQuantity());
        }

        // 创建发货单
        shippingService.createShipment(savedOrder);

        // 更新忠诚度积分
        loyaltyService.addPoints(
            request.getCustomerId(),
            calculateLoyaltyPoints(savedOrder)
        );

        // 发送确认邮件
        emailService.sendOrderConfirmation(savedOrder);

        // 跟踪分析
        analyticsService.trackOrderPlaced(savedOrder);

        return savedOrder;
    }
}

这段代码可以工作,但存在严重问题。OrderService 知道七个不同的服务。测试需要模拟所有这些服务。添加新的订单后操作意味着要修改此方法。如果电子邮件服务缓慢,订单创建就会变慢。如果分析跟踪失败,整个订单就会失败并回滚。

事务边界也是错误的。所有操作都在单个数据库事务中发生,这意味着即使电子邮件服务临时停机也会阻止订单创建。库存预留和发货单创建在事务上耦合,尽管它们在逻辑上是独立的操作。

引入 Spring 应用事件

Spring Framework 提供了一个内置的事件系统,在单个 JVM 内工作。默认情况下它是同步的,这使得它易于推理和调试。首先定义领域事件:

public abstract class DomainEvent {
    private final Instant occurredAt;
    private final String eventId;

    protected DomainEvent() {
        this.occurredAt = Instant.now();
        this.eventId = UUID.randomUUID().toString();
    }

    public Instant getOccurredAt() {
        return occurredAt;
    }

    public String getEventId() {
        return eventId;
    }
}

public class OrderPlacedEvent extends DomainEvent {
    private final Long orderId;
    private final Long customerId;
    private final List<OrderItem> items;
    private final BigDecimal totalAmount;

    public OrderPlacedEvent(Order order) {
        super();
        this.orderId = order.getId();
        this.customerId = order.getCustomerId();
        this.items = new ArrayList<>(order.getItems());
        this.totalAmount = order.getTotalAmount();
    }

    // Getters
}

事件应该是不可变的,并包含订阅者需要的所有信息。避免直接传递实体——而是复制相关数据。这可以防止订阅者意外修改共享状态。

重构 OrderService 以发布事件,而不是直接调用服务:

@Service
@Transactional
public class OrderService {
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final ApplicationEventPublisher eventPublisher;

    public OrderService(
        OrderRepository orderRepository,
        InventoryService inventoryService,
        PaymentService paymentService,
        ApplicationEventPublisher eventPublisher
    ) {
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.eventPublisher = eventPublisher;
    }

    public Order createOrder(CreateOrderRequest request) {
        // 验证库存
        for (OrderItem item : request.getItems()) {
            if (!inventoryService.checkAvailability(item.getProductId(), item.getQuantity())) {
                throw new InsufficientInventoryException(item.getProductId());
            }
        }

        // 处理支付
        PaymentResult payment = paymentService.processPayment(
            request.getCustomerId(),
            calculateTotal(request.getItems()),
            request.getPaymentDetails()
        );

        if (!payment.isSuccessful()) {
            throw new PaymentFailedException(payment.getErrorMessage());
        }

        // 创建并保存订单
        Order order = new Order(
            request.getCustomerId(),
            request.getItems(),
            payment.getTransactionId()
        );
        order.setStatus(OrderStatus.CONFIRMED);
        Order savedOrder = orderRepository.save(order);

        // 同步预留库存(仍在关键路径上)
        for (OrderItem item : request.getItems()) {
            inventoryService.reserveInventory(item.getProductId(), item.getQuantity());
        }

        // 为非关键操作发布事件
        eventPublisher.publishEvent(new OrderPlacedEvent(savedOrder));

        return savedOrder;
    }
}

现在 OrderService 仅依赖四个组件,而不是八个。更重要的是,它只了解对订单创建至关重要的操作——库存验证、支付处理和库存预留。其他所有操作都通过事件发生。

为解耦的操作创建事件监听器:

@Component
public class OrderEventListeners {
    private static final Logger logger = LoggerFactory.getLogger(OrderEventListeners.class);

    private final ShippingService shippingService;
    private final LoyaltyService loyaltyService;
    private final EmailService emailService;
    private final AnalyticsService analyticsService;

    public OrderEventListeners(
        ShippingService shippingService,
        LoyaltyService loyaltyService,
        EmailService emailService,
        AnalyticsService analyticsService
    ) {
        this.shippingService = shippingService;
        this.loyaltyService = loyaltyService;
        this.emailService = emailService;
        this.analyticsService = analyticsService;
    }

    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        try {
            shippingService.createShipment(event.getOrderId());
        } catch (Exception e) {
            logger.error("Failed to create shipment for order {}", event.getOrderId(), e);
            // 不要重新抛出 - 其他监听器仍应执行
        }
    }

    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateLoyaltyPoints(OrderPlacedEvent event) {
        try {
            int points = calculatePoints(event.getTotalAmount());
            loyaltyService.addPoints(event.getCustomerId(), points);
        } catch (Exception e) {
            logger.error("Failed to update loyalty points for order {}", event.getOrderId(), e);
        }
    }

    @EventListener
    public void sendConfirmationEmail(OrderPlacedEvent event) {
        try {
            emailService.sendOrderConfirmation(event.getOrderId());
        } catch (Exception e) {
            logger.error("Failed to send confirmation email for order {}", event.getOrderId(), e);
        }
    }

    @EventListener
    public void trackAnalytics(OrderPlacedEvent event) {
        try {
            analyticsService.trackOrderPlaced(event.getOrderId(), event.getTotalAmount());
        } catch (Exception e) {
            logger.error("Failed to track analytics for order {}", event.getOrderId(), e);
        }
    }
}

每个监听器在它自己的事务中运行(在适当的时候)并独立处理故障。如果发送电子邮件失败,发货单创建仍然会发生。即使分析跟踪抛出异常,订单创建事务也会成功提交。

理解事务边界

@Transactional(propagation = Propagation.REQUIRES_NEW) 注解至关重要。没有它,所有监听器都会参与订单创建事务。如果任何监听器失败,整个订单都会回滚——这正是我们试图避免的情况。

使用 REQUIRES_NEW,每个监听器都会启动一个新的事务。当监听器运行时,订单已经提交。这意味着:

  • 监听器无法阻止订单创建
  • 监听器故障不会回滚订单
  • 每个监听器的工作是独立原子性的

但这有一个权衡。如果监听器失败,订单存在但某些后处理没有发生。您需要处理这些部分故障的策略:

@EventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleOrderPlaced(OrderPlacedEvent event) {
    try {
        shippingService.createShipment(event.getOrderId());
    } catch (Exception e) {
        logger.error("Failed to create shipment for order {}", event.getOrderId(), e);

        // 记录失败以便重试
        failedEventRepository.save(new FailedEvent(
            event.getClass().getSimpleName(),
            event.getEventId(),
            "handleOrderPlaced",
            e.getMessage()
        ));
    }
}

一个单独的后台作业可以重试失败的事件:

@Component
public class FailedEventRetryJob {
    private final FailedEventRepository failedEventRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Scheduled(fixedDelay = 60000) // 每分钟
    public void retryFailedEvents() {
        List failures = failedEventRepository.findRetryable();

        for (FailedEvent failure : failures) {
            try {
                // 重建并重新发布事件
                DomainEvent event = reconstructEvent(failure);
                eventPublisher.publishEvent(event);

                failure.markRetried();
                failedEventRepository.save(failure);
            } catch (Exception e) {
                logger.warn("Retry failed for event {}", failure.getEventId(), e);
                failure.incrementRetryCount();
                failedEventRepository.save(failure);
            }
        }
    }
}

这种模式提供了最终一致性——系统可能暂时不一致,但通过重试自行恢复。

转向异步事件

Spring 的 @EventListener 默认是同步的。事件处理发生在发布事件的同一线程中,发布者等待所有监听器完成。这提供了强有力的保证,但限制了可扩展性。

通过启用异步支持并注解监听器来使监听器异步:

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "eventExecutor")
    public Executor eventExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("event-");
        executor.initialize();
        return executor;
    }
}

@Component
public class OrderEventListeners {
    // ... 依赖 ...

    @Async("eventExecutor")
    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        shippingService.createShipment(event.getOrderId());
    }

    @Async("eventExecutor")
    @EventListener
    public void sendConfirmationEmail(OrderPlacedEvent event) {
        emailService.sendOrderConfirmation(event.getOrderId());
    }
}

使用 @AsynccreateOrder() 方法在发布事件后立即返回。监听器在线程池中并发执行。这显著提高了响应时间——订单创建不再等待电子邮件发送或分析跟踪。

但异步事件引入了新的复杂性。当监听器执行时,订单创建事务可能尚未提交。监听器可能尝试从数据库加载订单,但由于事务仍在进行中而找不到它。

Spring 提供了 @TransactionalEventListener 来处理这种情况:

@Component
public class OrderEventListeners {
    @Async("eventExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        // 这仅在订单创建事务成功提交后运行
        shippingService.createShipment(event.getOrderId());
    }
}

AFTER_COMMIT 阶段确保监听器仅在发布事务成功提交后运行。如果订单创建失败并回滚,监听器永远不会执行。这可以防止处理实际上不存在的订单的事件。

实现事件存储

随着事件驱动架构的成熟,存储事件变得有价值。事件存储提供了审计日志,支持调试,并支持更复杂的模式,如事件溯源。

创建一个简单的事件存储:

@Entity
@Table(name = "domain_events")
public class StoredEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String eventId;

    @Column(nullable = false)
    private String eventType;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String payload;

    @Column(nullable = false)
    private Instant occurredAt;

    @Column(nullable = false)
    private Instant storedAt;

    @Column
    private String aggregateId;

    @Column
    private String aggregateType;

    // 构造器、getter、setter
}

@Repository
public interface StoredEventRepository extends JpaRepository<StoredEvent, Long> {
    List<StoredEvent> findByAggregateIdOrderByOccurredAt(String aggregateId);
    List<StoredEvent> findByEventType(String eventType);
}

拦截并存储所有领域事件:

@Component
public class EventStoreListener {
    private final StoredEventRepository repository;
    private final ObjectMapper objectMapper;

    public EventStoreListener(StoredEventRepository repository, ObjectMapper objectMapper) {
        this.repository = repository;
        this.objectMapper = objectMapper;
    }

    @EventListener
    @Order(Ordered.HIGHEST_PRECEDENCE) // 在其他监听器之前存储
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void storeEvent(DomainEvent event) {
        try {
            StoredEvent stored = new StoredEvent();
            stored.setEventId(event.getEventId());
            stored.setEventType(event.getClass().getSimpleName());
            stored.setPayload(objectMapper.writeValueAsString(event));
            stored.setOccurredAt(event.getOccurredAt());
            stored.setStoredAt(Instant.now());

            // 如果可用,提取聚合信息
            if (event instanceof OrderPlacedEvent) {
                OrderPlacedEvent orderEvent = (OrderPlacedEvent) event;
                stored.setAggregateId(orderEvent.getOrderId().toString());
                stored.setAggregateType("Order");
            }

            repository.save(stored);
        } catch (JsonProcessingException e) {
            throw new EventStoreException("Failed to serialize event", e);
        }
    }
}

现在,每个领域事件在业务逻辑处理之前都会持久化。您可以通过重放事件来重建系统中发生的情况:

@Service
public class OrderHistoryService {
    private final StoredEventRepository eventRepository;

    public List<OrderEvent> getOrderHistory(Long orderId) {
        List<StoredEvent> events = eventRepository.findByAggregateIdOrderByOccurredAt(
            orderId.toString()
        );

        return events.stream()
            .map(this::deserializeEvent)
            .collect(Collectors.toList());
    }

    private OrderEvent deserializeEvent(StoredEvent stored) {
        // 根据事件类型反序列化
        try {
            Class<?> eventClass = Class.forName("com.example.events." + stored.getEventType());
            return (OrderEvent) objectMapper.readValue(stored.getPayload(), eventClass);
        } catch (Exception e) {
            throw new EventStoreException("Failed to deserialize event", e);
        }
    }
}

这实现了强大的调试能力。当客户报告其订单问题时,您可以准确看到发生了什么事件以及发生的顺序。

Saga 和补偿操作

某些工作流需要跨多个步骤进行协调,其中每个步骤都可能失败。传统方法使用分布式事务,但这些方法扩展性不佳且增加了复杂性。Saga 使用编排事件和补偿操作提供了一种替代方案。

考虑一个更复杂的订单流程,您需要:

  1. 预留库存
  2. 处理支付
  3. 创建发货单

如果在预留库存后支付失败,您需要释放预留。通过补偿事件实现这一点:

public class InventoryReservedEvent extends DomainEvent {
    private final Long orderId;
    private final List<ReservationDetail> reservations;

    // 构造器、getter
}

public class PaymentFailedEvent extends DomainEvent {
    private final Long orderId;
    private final String reason;

    // 构造器、getter
}

@Component
public class InventorySagaHandler {
    private final InventoryService inventoryService;

    @EventListener
    public void handlePaymentFailed(PaymentFailedEvent event) {
        // 补偿操作:释放预留库存
        inventoryService.releaseReservation(event.getOrderId());
    }
}

Saga 通过事件而不是中央协调器进行协调:

@Service
public class OrderSagaService {
    private final ApplicationEventPublisher eventPublisher;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;

    public void processOrder(Order order) {
        // 步骤 1: 预留库存
        List<ReservationDetail> reservations = inventoryService.reserve(order.getItems());
        eventPublisher.publishEvent(new InventoryReservedEvent(order.getId(), reservations));

        try {
            // 步骤 2: 处理支付
            PaymentResult payment = paymentService.processPayment(order);

            if (payment.isSuccessful()) {
                eventPublisher.publishEvent(new PaymentSucceededEvent(order.getId(), payment));
            } else {
                // 触发补偿
                eventPublisher.publishEvent(new PaymentFailedEvent(order.getId(), payment.getReason()));
                throw new PaymentException(payment.getReason());
            }
        } catch (Exception e) {
            // 触发补偿
            eventPublisher.publishEvent(new PaymentFailedEvent(order.getId(), e.getMessage()));
            throw e;
        }
    }
}

这种模式在没有分布式事务的情况下保持了一致性。每个步骤发布记录所发生事件的事件。当发生故障时,补偿事件会触发撤销先前步骤的操作。

桥接到外部消息代理

随着单体应用的增长,您可能希望与外部系统集成或为最终的服务提取做准备。Spring Cloud Stream 提供了对 RabbitMQ 或 Kafka 等消息代理的抽象,同时保持相同的事件驱动模式:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-kafka</artifactId>
</dependency>

application.yml 中配置绑定:

spring:
  cloud:
    stream:
      bindings:
        orderPlaced-out-0:
          destination: order.placed
        orderPlaced-in-0:
          destination: order.placed
          group: order-processors
      kafka:
        binder:
          brokers: localhost:9092

创建内部事件和外部消息之间的桥接:

@Component
public class EventPublisher {
    private final StreamBridge streamBridge;

    public EventPublisher(StreamBridge streamBridge) {
        this.streamBridge = streamBridge;
    }

    @EventListener
    public void publishToExternalBroker(OrderPlacedEvent event) {
        // 将内部事件发布到外部消息代理
        streamBridge.send("orderPlaced-out-0", event);
    }
}

@Component
public class ExternalEventConsumer {
    private final ApplicationEventPublisher eventPublisher;

    public ExternalEventConsumer(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    @Bean
    public Consumer<OrderPlacedEvent> orderPlaced() {
        return event -> {
            // 将外部事件重新发布为内部事件
            eventPublisher.publishEvent(event);
        };
    }
}

这种模式让您可以选择性地将事件发布到外部,同时将内部事件保留在本地。关键的实时操作使用内部事件以实现低延迟。跨服务通信使用消息代理以实现可靠性和可扩展性。

监控与可观测性

事件驱动系统引入了新的可观测性挑战。理解正在发生的情况需要跨多个异步处理步骤跟踪事件。实施全面的日志记录和指标:

@Aspect
@Component
public class EventMonitoringAspect {
    private static final Logger logger = LoggerFactory.getLogger(EventMonitoringAspect.class);
    private final MeterRegistry meterRegistry;

    public EventMonitoringAspect(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Around("@annotation(org.springframework.context.event.EventListener)")
    public Object monitorEventListener(ProceedingJoinPoint joinPoint) throws Throwable {
        String listenerName = joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();
        DomainEvent event = (DomainEvent) args[0];

        Timer.Sample sample = Timer.start(meterRegistry);

        try {
            logger.info("Processing event {} in listener {}", 
                event.getEventId(), listenerName);

            Object result = joinPoint.proceed();

            sample.stop(Timer.builder("event.listener.duration")
                .tag("listener", listenerName)
                .tag("event_type", event.getClass().getSimpleName())
                .tag("status", "success")
                .register(meterRegistry));

            meterRegistry.counter("event.listener.processed",
                "listener", listenerName,
                "event_type", event.getClass().getSimpleName(),
                "status", "success"
            ).increment();

            return result;
        } catch (Exception e) {
            sample.stop(Timer.builder("event.listener.duration")
                .tag("listener", listenerName)
                .tag("event_type", event.getClass().getSimpleName())
                .tag("status", "failure")
                .register(meterRegistry));

            meterRegistry.counter("event.listener.processed",
                "listener", listenerName,
                "event_type", event.getClass().getSimpleName(),
                "status", "failure"
            ).increment();

            logger.error("Error processing event {} in listener {}", 
                event.getEventId(), listenerName, e);

            throw e;
        }
    }
}

这个切面自动跟踪每个事件监听器的执行时间和成功率。结合 Prometheus 和 Grafana 等工具,您可以可视化事件处理模式并识别瓶颈。

添加关联 ID 以跟踪系统中的事件:

public abstract class DomainEvent {
    private final Instant occurredAt;
    private final String eventId;
    private final String correlationId;

    protected DomainEvent(String correlationId) {
        this.occurredAt = Instant.now();
        this.eventId = UUID.randomUUID().toString();
        this.correlationId = correlationId != null ? correlationId : UUID.randomUUID().toString();
    }

    // Getters
}

通过事件链传播关联 ID:

@EventListener
public void handleOrderPlaced(OrderPlacedEvent event) {
    MDC.put("correlationId", event.getCorrelationId());

    try {
        // 执行工作

        // 发布具有相同关联 ID 的后续事件
        eventPublisher.publishEvent(new ShipmentCreatedEvent(
            event.getOrderId(),
            event.getCorrelationId()
        ));
    } finally {
        MDC.clear();
    }
}

现在,与单个订单流相关的所有日志消息共享一个关联 ID,使得跨多个异步操作跟踪整个工作流变得微不足道。

测试事件驱动代码

事件驱动架构需要不同的测试策略。传统的单元测试适用于单个监听器,但集成测试对于验证事件流变得更加重要:

@SpringBootTest
@TestConfiguration
public class OrderEventIntegrationTest {
    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @Autowired
    private ShippingService shippingService;

    @Autowired
    private EmailService emailService;

    @Test
    public void shouldProcessOrderPlacedEventCompletely() throws Exception {
        // 给定
        Order order = createTestOrder();
        OrderPlacedEvent event = new OrderPlacedEvent(order);

        // 当
        eventPublisher.publishEvent(event);

        // 等待异步处理
        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> {
            // 然后
            verify(shippingService).createShipment(order.getId());
            verify(emailService).sendOrderConfirmation(order.getId());
        });
    }
}

对于单元测试,注入一个间谍事件发布器以验证事件是否正确发布:

@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
    @Mock
    private OrderRepository orderRepository;

    @Mock
    private InventoryService inventoryService;

    @Mock
    private PaymentService paymentService;

    @Spy
    private ApplicationEventPublisher eventPublisher = new SimpleApplicationEventPublisher();

    @InjectMocks
    private OrderService orderService;

    @Test
    public void shouldPublishOrderPlacedEventAfterCreatingOrder() {
        // 给定
        CreateOrderRequest request = createValidRequest();

        when(inventoryService.checkAvailability(any(), anyInt())).thenReturn(true);
        when(paymentService.processPayment(any(), any(), any()))
            .thenReturn(PaymentResult.successful("txn-123"));
        when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        // 当
        orderService.createOrder(request);

        // 然后
        verify(eventPublisher).publishEvent(argThat(event -> 
            event instanceof OrderPlacedEvent
        ));
    }
}

迁移之旅

将单体应用重构为使用事件驱动架构并非全有或全无的命题。从一个工作流开始——通常是造成最多痛苦的那个。识别可以事件驱动的直接服务调用,并逐步引入事件。

从同步事件开始,以最小化行为变更。一旦事件正确流动,为非关键监听器切换到异步处理。当您需要审计跟踪或调试能力时,添加事件存储。仅当您需要跨服务通信或准备提取微服务时,才集成外部消息代理。

目标不是实现完美的事件驱动架构。而是减少耦合、提高可测试性,并在组件之间创建更清晰的边界。即使是部分采用也能提供价值——具有一些事件驱动模式的单体应用比完全没有的模式更易于维护。

这种渐进式方法使您能够持续交付价值,而不是投入一个需要数月时间、直到完全结束时才能交付任何成果的重构项目。您能够了解在特定领域和团队中哪些方法有效,根据实际经验而非理论理想来调整实施策略。

有用的资源


【注】本文译自: Event-Driven Architecture in Monoliths: Incremental Refactoring for Java Apps – Java Code Geeks

Java有哪些优势?

Java 的价值

当具有开创性的 Java 白皮书在 1995 年推出该语言时,它列出了七项使其超越竞争对手的核心价值。如今,Java 为在 AWS 和 Google Cloud 等主要云上运行的大规模系统提供动力,这使得这些价值对于现代部署和认证路径更具现实意义。
那份白皮书撰写至今已过去近 30 年,虽然其中许多价值仍然有效,但在 2025 年,选择 Java 作为您的部署平台的理由比以往任何时候都多。如果您关注 Java 路线图或热门技术博客,您会看到 Java 出现在云架构师、开发人员和数据领域的各个路径中。

Java 的优势

以下是 Java、JVM 和 JDK 的十大现代优势:

  1.  Java 是开源的
  2.  Java 是由社区驱动的
  3.  Java 快速且高性能
  4.  Java 易于学习
  5.  Java 是静态类型的
  6.  Java 拥有专家领导
  7.  Java 功能添加迅速
  8.  Java 是面向对象的
  9.  Java 支持函数式编程
  10. Java 优先考虑向后兼容性

    Java 是开源的

    Java 自 2011 年起已开源。任何人都可以查看 JDK 的源代码并创建定制化和优化的构建版本。这种开放性与 AWS 开发者GCP 专业云开发者等云学习路径非常契合,在这些路径中,基于 Java 的微服务很常见。
    流行的 OpenJDK 和 JVM 发行版包括:

    •   Azul 的高性能实现
    •   Oracle 的授权版本
    •   AdoptOpenJDK(现称为 Adoptium)
    •   IBM 的 Java 运行时
    •   Amazon Corretto
    •   Red Hat 的 OpenJDK 发行版
    •   微软构建的 OpenJDK
    •   高性能的 GraalVM
      谷歌甚至不惜借用 Java 源代码来构建自己的移动操作系统。这样做在道德上可能值得商榷,但美国最高法院表示,为构建 Android 操作系统而侵犯 Oracle 的版权是完全公平合理的。

      Java 是由社区驱动的

      Oracle 拥有 Java 商标这一事实在技术社区中引发了无休止的、任性的焦虑。然而,事实是 Java 通过 Java 社区进程向前发展,而非根据 Larry Ellison 的个人意愿。社区驱动的学习也体现在认证项目中,如 AWS 云从业者AWS 解决方案架构师GCP 助理云工程师
      JCP 是向 Java 编程语言添加新功能、新规范和新 API 的途径。在过去的 20 年里,JCP 完成了以下工作:

    •   增加了 1000 多名成员
    •   欢迎了 200 多家公司
    •   鼓励独立开发人员加入

社区支持和贡献是 Java 为软件开发社区带来的巨大优势之一,这种精神您同样可以在 AWS DevOps 工程师GCP DevOps 工程师圈子里找到。

Java 快速且高性能

Java 虚拟机是一个抽象层,使得 Java 程序能够跨平台运行。这种可移植性与 AWS 安全专家AWS 数据工程师GCP 专业数据工程师路径中的云工作负载非常匹配。
JVM 架构中立是 Java 的一大优势,但人们总是担心所需的抽象层可能会严重影响性能。但事实并非如此
在 JVM 上运行的 Java 可能无法达到与 C++ 或 Rust 等编译语言相同的性能。然而,垃圾收集器工作方式的改进、即时编译器的使用以及大量其他底层优化为 Java 平台带来了接近原生的性能。

Java 易于学习

1995 年的 Java 白皮书曾夸耀 Java 易于学习,因为它采用了该语言发布时流行的、类似 C 的熟悉语法。如果您喜欢结构化的目标和问责制,来自 Scrumtuous 的 Scrum 式冲刺可以帮助您规划 Java 学习节奏。
2023 年,JDK 拥有了 JShell,这使得 Java 对 Python 和 JavaScript 开发人员来说变得熟悉且易于学习。应试耐力可以通过像 Udemy 实践考试合集这样的题库来培养,即使它针对的是 AWS。这种训练方法可以很好地迁移到 Java 考试和云认证中。
此外,像 Replit 和 OneCompiler 这样的在线编译器允许学习者无需安装 IDE 或配置 JAVA_HOME 即可开始使用 Java。如果您的最终目标包括云角色,请参阅基础的 AWS 云从业者GCP 助理云工程师页面。

Java 是静态类型的

与 Python 或 JavaScript 等语言不同,Java 是静态类型的。
在 Java 中,您需要指定变量是 float、double、int、Integer、char 还是 String。这比动态类型语言提供了两个显著好处:

  •   它使得管理大型代码库更加容易,这对于 AWS 解决方案架构师GCP 云架构师非常重要。
  •   它使得优化运行时环境成为可能,这对 AWS 数据工程师GCP 数据库工程师等数据密集型角色有所帮助。
    Java 在 Python 和 JavaScript 失败的情况下仍能扩展的原因,通常可以追溯到 Java 的静态类型特性。

    Java 语言的静态类型特性是其主要优势。

    Java 拥有专家领导

    虽然该语言通过 Java 社区进程向前发展,但有两位杰出的软件架构师在 Oracle 内部指导着 Java 平台的演进。领导力和管理也是云项目中的主题,例如 AWS 专业级解决方案架构师以及专注于安全的路径,如 AWS 安全专家GCP 安全工程师

    功能采纳迅速

    与其他语言相比,Java 的优势之一是采纳新功能和响应社区需求的速度非常快。同样的迭代速度也反映在实践角色中,如 AWS DevOps 工程师GCP DevOps 工程师,这些角色会持续部署 Java 服务。

    Java 是面向对象的

    Java 用户认为这是理所当然的,但讨论 Java 的优势不能忽视 Java 是完全面向对象的,它实现了重要的 OOA&D 概念,例如:

  •   继承
  •   组合
  •   多态
  •   封装
  •   接口

对于使用 Scrum 主管产品负责人角色等框架组织工作的团队来说,Java 的对象建模自然地契合了映射到领域驱动设计的待办事项项。

Java 支持函数式编程

软件开发行业出现了向函数式编程的重大转变,而 Java 一直是这一趋势的重要组成部分。如果您旨在将 ML 服务与 Java 微服务融合,请探索 AWS 机器学习AWS AI 从业者路径。
函数式编程和不可变类型的使用可以使程序更快、更简洁且更易于理解。Java 在 Java 8 中进行了重大转变,引入了 Java Streams 和 lambda 表达式,这开启了 Java 函数式编程的新时代。您可以使用该语言同时进行函数式编程和面向对象编程,这是一个主要优势。

向后兼容性

随着 Java 社区推动 API 的重大更改,该语言的维护者始终优先考虑向后兼容性和非破坏性功能的添加。稳定性是 Java 在准备 AWS 助理级解决方案架构师GCP 专业云架构师角色的架构师中保持首选的原因之一。
即使引入了作为函数式编程的默认接口和 lambda 表达式,Java 平台也保持了向后兼容性。早期版本编写的代码可以在更新的环境中运行,无需重新编译。
在 2025 年,Java 的价值众多,因为 JDK 和 JVM 对于包含 AWS 云从业者解决方案架构师开发者数据工程师安全专家,以及高级角色如 AWS 专业级解决方案架构师,还有 GCP 路径如 GCP 数据从业者GCP 专业云网络工程师GCP Workspace 管理员GCP 机器学习工程师GCP 生成式 AI 负责人GCP 数据库工程师在内的多云职业而言,比以往任何时候都更具现实意义。

Java、JVM 和 JDK 的诸多优势持续推动着该编程语言的采用。


【注】本文译自:What are the advantages of Java?

Spring Boot WebSocket:使用 Java 构建多频道聊天系统

这是一个使用 WebFlux 和 MongoDB 构建响应式 Spring Boot WebSocket 聊天的分步指南,包括配置、处理程序和手动测试。


正如您可能已经从标题中猜到的,今天的主题将是 Spring Boot WebSockets。不久前,我提供了一个基于 Akka 工具包库的 WebSocket 聊天示例。然而,这个聊天将拥有更多一些功能,以及一个相当不同的设计。

我将跳过某些部分,以避免与上一篇文章的内容有太多重复。在这里您可以找到关于 WebSockets 更深入的介绍。请注意,本文中使用的所有代码也可以在 GitHub 仓库中找到。

Spring Boot WebSocket:使用的工具

让我们从描述将用于实现整个应用程序的工具开始本文的技术部分。由于我无法完全掌握如何使用经典的 Spring STOMP 覆盖来构建真正的 WebSocket API,我决定选择 Spring WebFlux 并使一切具有响应式特性。

  • Spring Boot – 基于 Spring 的现代 Java 应用程序离不开 Spring Boot;所有的自动配置都是无价的。
  • Spring WebFlux – 经典 Spring 的响应式版本,为处理 WebSocket 和 REST 提供了相当不错且描述性的工具集。我敢说,这是在 Spring 中实际获得 WebSocket 支持的唯一方法。
  • Mongo – 最流行的 NoSQL 数据库之一,我使用它来存储消息历史记录。
  • Spring Reactive Mongo – 用于以响应式方式处理 Mongo 访问的 Spring Boot 启动器。在一个地方使用响应式而在另一个地方不使用并不是最好的主意。因此,我决定也让数据库访问具有响应式特性。

让我们开始实现吧!

Spring Boot WebSocket:实现

依赖项与配置

pom.xml

<dependencies>
    <!--编译时依赖-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
    </dependency>
</dependencies>

application.properties

spring.data.mongodb.uri=mongodb://chats-admin:admin@localhost:27017/chats

我更喜欢 .properties 而不是 .yml——依我拙见,YAML 在较大规模上不可读且难以维护。

WebSocketConfig

@Configuration
class WebSocketConfig {

    @Bean
    ChatStore chatStore(MessagesStore messagesStore) {
        return new DefaultChatStore(Clock.systemUTC(), messagesStore);
    }

    @Bean
    WebSocketHandler chatsHandler(ChatStore chatStore) {
        return new ChatsHandler(chatStore);
    }

    @Bean
    SimpleUrlHandlerMapping handlerMapping(WebSocketHandler wsh) {
        Map<String, WebSocketHandler> paths = Map.of("/chats/{id}", wsh);
        return new SimpleUrlHandlerMapping(paths, 1);
    }

    @Bean
    WebSocketHandlerAdapter webSocketHandlerAdapter() {
        return new WebSocketHandlerAdapter();
    }
}

出乎意料的是,这里定义的四个 Bean 都非常重要。

  • ChatStore – 用于操作聊天的自定义 Bean,我将在后续步骤中详细介绍这个 Bean。
  • WebSocketHandler – 将存储所有与处理 WebSocket 会话相关逻辑的 Bean。
  • SimpleUrlHandlerMapping – 负责将 URL 映射到正确的处理器,此处理的完整 URL 看起来大致像这样:ws://localhost:8080/chats/{id}
  • WebSocketHandlerAdapter – 一种功能性的 Bean,它为 Spring Dispatcher Servlet 添加了 WebSocket 处理支持。

ChatsHandler

class ChatsHandler implements WebSocketHandler {

    private final Logger log = LoggerFactory.getLogger(ChatsHandler.class);

    private final ChatStore store;

    ChatsHandler(ChatStore store) {
        this.store = store;
    }

    @Override
    public Mono<Void> handle(WebSocketSession session) {
        String[] split = session.getHandshakeInfo()
            .getUri()
            .getPath()
            .split("/");
        String chatIdStr = split[split.length - 1];
        int chatId = Integer.parseInt(chatIdStr);
        ChatMeta chatMeta = store.get(chatId);
        if (chatMeta == null) {
            return session.close(CloseStatus.GOING_AWAY);
        }
        if (!chatMeta.canAddUser()) {
            return session.close(CloseStatus.NOT_ACCEPTABLE);
        }

        String sessionId = session.getId();
        store.addNewUser(chatId, session);
        log.info("New User {} join the chat {}", sessionId, chatId);
        return session
               .receive()
               .map(WebSocketMessage::getPayloadAsText)
               .flatMap(message -> store.addNewMessage(chatId, sessionId, message))
               .flatMap(message -> broadcastToSessions(sessionId, message, store.get(chatId).sessions()))
               .doFinally(sig -> store.removeSession(chatId, session.getId()))
               .then();
    }

    private Mono<Void> broadcastToSessions(String sessionId, String message, List<WebSocketSession> sessions) {
        return Flux.fromStream(sessions
                .stream()
                .filter(session -> !session.getId().equals(sessionId))
                .map(session -> session.send(Mono.just(session.textMessage(message)))))
                .then();
    }
}

正如我上面提到的,在这里您可以找到所有与处理 WebSocket 会话相关的逻辑。首先,我们从 URL 解析聊天的 ID 以获取目标聊天。根据特定聊天的上下文,响应不同的状态。

此外,我还将消息广播到与特定聊天相关的所有会话——以便用户实际交换消息。我还添加了 doFinally 触发器,它将从 chatStore 中清除已关闭的会话,以减少冗余通信。总的来说,这段代码是响应式的;我需要遵循一些限制。我试图使其尽可能简单和可读,如果您有任何改进的想法,我持开放态度。

ChatsRouter

@Configuration(proxyBeanMethods = false)
class ChatRouter {

    private final ChatStore chatStore;

    ChatRouter(ChatStore chatStore) {
        this.chatStore = chatStore;
    }

    @Bean
    RouterFunction<ServerResponse> routes() {
        return RouterFunctions
        .route(POST("api/v1/chats/create"), e -> create(false))
        .andRoute(POST("api/v1/chats/create-f2f"), e -> create(true))
        .andRoute(GET("api/v1/chats/{id}"), this::get)
        .andRoute(DELETE("api/v1/chats/{id}"), this::delete);
    }
}

WebFlux 定义 REST 端点的方法与经典 Spring 有很大不同。上面,您可以看到用于管理聊天的 4 个端点的定义。与 Akka 实现中的情况类似,我希望有一个用于管理聊天的 REST API 和一个用于实际处理聊天的 WebSocket API。我将跳过函数实现,因为它们非常简单;您可以在 GitHub 上查看它们。

ChatStore

首先,接口:

public interface ChatStore {
    int create(boolean isF2F);
    void addNewUser(int id, WebSocketSession session);
    Mono<String> addNewMessage(int id, String userId, String message);
    void removeSession(int id, String session);
    ChatMeta get(int id);
    ChatMeta delete(int id);
}

然后是实现:

public class DefaultChatStore implements ChatStore {

    private final Map<Integer, ChatMeta> chats;
    private final AtomicInteger idGen;
    private final MessagesStore messagesStore;
    private final Clock clock;

    public DefaultChatStore(Clock clock, MessagesStore store) {
        this.chats = new ConcurrentHashMap<>();
        this.idGen = new AtomicInteger(0);
        this.clock = clock;
        this.messagesStore = store;
    }

    @Override
    public int create(boolean isF2F) {
        int newId = idGen.incrementAndGet();
        ChatMeta chatMeta = chats.computeIfAbsent(newId, id -> {
            if (isF2F) {
                return ChatMeta.ofId(id);
            }
            return ChatMeta.ofIdF2F(id);
        });
        return chatMeta.id;
    }

    @Override
    public void addNewUser(int id, WebSocketSession session) {
        chats.computeIfPresent(id, (k, v) -> v.addUser(session));
    }

    @Override
    public void removeSession(int id, String sessionId) {
        chats.computeIfPresent(id, (k, v) -> v.removeUser(sessionId));
    }

    @Override
    public Mono<String> addNewMessage(int id, String userId, String message) {
        ChatMeta meta = chats.getOrDefault(id, null);
        if (meta != null) {
            Message messageDoc = new Message(id, userId, meta.offset.getAndIncrement(), clock.instant(), message);
            return messagesStore.save(messageDoc)
                    .map(Message::getContent);
        }
        return Mono.empty();
    }
    // 省略部分
}

ChatStore 的基础是 ConcurrentHashMap,它保存所有开放聊天的元数据。接口中的大多数方法都不言自明,背后没有什么特别之处。

  • create – 创建一个新聊天,带有一个布尔属性,指示聊天是 f2f 还是群聊。
  • addNewUser – 向现有聊天添加新用户。
  • removeUser – 从现有聊天中移除用户。
  • get – 获取具有 ID 的聊天的元数据。
  • delete – 从 CMH 中删除聊天。

这里唯一复杂的方法是 addNewMessages。它增加聊天内的消息计数器,并将消息内容持久化到 MongoDB 中,以实现持久性。

MongoDB

消息实体

public class Message {
   @Id
   private String id;
   private int chatId;
   private String owner;
   private long offset;
   private Instant timestamp;
   private String content;
}

存储在数据库中的消息内容模型,这里有三个重要的字段:

  1. chatId – 表示发送特定消息的聊天。
  2. ownerId – 消息发送者的用户 ID。
  3. offset – 消息在聊天中的序号,用于检索排序。

MessageStore

public interface MessagesStore extends ReactiveMongoRepository<Message, String> {}

没什么特别的,经典的 Spring 仓库,但是以响应式方式实现,提供了与 JpaRepository 相同的功能集。它直接在 ChatStore 中使用。此外,在主应用程序类 WebsocketsChatApplication 中,我通过使用 @EnableReactiveMongoRepositories 来激活响应式仓库。没有这个注解,上面的 messageStore 将无法工作。好了,我们完成了整个聊天的实现。让我们测试一下!

Spring Boot WebSocket:测试

对于测试,我使用 Postman 和 Simple WebSocket Client。

  1. 我正在使用 Postman 创建一个新聊天。在响应体中,我得到了最近创建的聊天的 WebSocket URL。

图片:Postman 创建聊天请求的屏幕截图

  1. 现在是使用它们并检查用户是否可以相互通信的时候了。Simple Web Socket Client 在这里派上用场。因此,我在这里连接到新创建的聊天。

图片:Simple Web Socket Client 连接界面的屏幕截图

  1. 好了,一切正常,用户可以相互通信了。

图片:两个 WebSocket 客户端交换消息的屏幕截图
图片:两个 WebSocket 客户端交换消息的屏幕截图
图片:两个 WebSocket 客户端交换消息的屏幕截图

还有最后一件事要做。让我们花点时间看看哪些地方可以做得更好。

可以改进的地方

由于我刚刚构建的是最基础的聊天应用程序,有一些(或者实际上相当多)地方可以做得更好。下面,我列出了一些我认为值得改进的方面:

  • 身份验证和重新加入支持 – 目前,一切都基于 sessionId。这不是一个最优的方法。最好能有一些身份验证机制,并基于用户数据实现实际的重新加入。
  • 发送附件 – 目前,聊天仅支持简单的文本消息。虽然发消息是聊天的基本功能,但用户也喜欢交换图片和音频文件。
  • 测试 – 目前没有测试,但为什么要保持这样呢?测试总是一个好主意。
  • offset 溢出 – 目前,它只是一个简单的 int。如果我们要在非常长的时间内跟踪 offset,它迟早会溢出。

总结

好了!Spring Boot WebSocket 聊天已经实现,主要任务已完成。您对下一步要开发什么有了一些想法。

请记住,这个聊天案例非常简单,对于任何类型的商业项目,都需要大量的修改和开发。

无论如何,我希望您在阅读本文时学到了一些新东西。

感谢您的时间。


【注】本文译自:Spring Boot WebSocket: Building a Multichannel Chat in Java

Spring框架中的Component与Bean注解

Spring Boot 中的 @Bean 与 @Component

Spring 的 @Component@Bean 注解的关键区别在于:@Bean 注解可用于暴露您自己编写的 JavaBeans,而 @Component 注解可用于暴露源代码由他人维护的 JavaBeans。
Spring 框架的核心是其控制反转 (IoC) 容器,它管理着应用程序中最重要的 JavaBeans 的生命周期。然而,IoC 容器并不管理应用程序可能需要的每一个 JavaBean。它只管理您明确要求它管理的 JavaBeans 的生命周期。
何时使用 Spring 的 @Bean 注解?
如果您自己编写了一个 JavaBean,可以直接在源代码中添加 Spring 的 @Bean 注解。这里我们要求 Spring 的 IoC 容器管理 Score 类所有实例的生命周期。

@Bean
public class Score {
    int wins, losses, ties;
}

何时使用 Spring 的 @Component 注解?
但是,如果您想让 Spring 的 IoC 容器管理来自 Jackson API 的 ObjectMapper,或者来自 JDBC API 的 DataSource 组件呢?您不能简单地编辑 JDK 中的代码并在标准 API 的类上添加 @Bean 注解。这就是 @Component 注解的用武之地。
如果您希望 Spring 管理一个您无法控制其代码的 JavaBean,您可以创建一个返回该 JavaBean 实例的方法,然后用 @Component 注解装饰该方法,如下例所示:

@Configuration
public class MyConfig {
    @Component
    public DataSource getMyHikariDataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:h2:mem:roshambo");
        return ds;
    }
    @Component
    public ObjectMapper getMyObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.enable(SerializationFeature.INDENT_OUTPUT);
        return mapper;
    }
}

在此示例中,我们使用了 @Component 注解来告诉 Spring IoC 容器管理 DataSourceObjectMapper bean 的生命周期。
这些组件来自 Jackson 和 JDBC API,因此我们无法编辑其源代码。这就是为什么我们不能直接在类声明上方添加 @Bean 注解的原因。但是,我们可以使用 @Component 注解,并结合放在类文件本身的 @Configuration 注解,来告诉 Spring 管理这些外部提供的资源。
用 @Component 代替 @Bean?
@Component 注解并不仅限于与外部 API 一起使用。开发者完全允许使用 @Component 注解代替 @Bean 注解来暴露他们自己编写的 JavaBeans。
如果我们从上方的 Score 类中移除 @Bean 注解,我们可以像下面代码中看到的那样,通过使用 @Component 注解来通过 IoC 容器暴露 Score

@Configuration
public class MyRoshamboConfig {
    @Component
    public Score getTheScore() {
        return new Score();
    }
}

何时使用 @Component vs @Bean?
在具有一定规模的 Spring Boot 项目中,我实际上更倾向于使用 @Component 注解而不是 @Bean 注解。这样,配置被限制在单个文件中,而您编写的 JavaBeans 不会被那些将您的源代码紧密绑定到 Spring 框架的注解所充斥。
在较小的项目和原型中?我完全支持使用 @Bean 注解。它更容易使用,并且如果您的项目不需要大量配置,它可以帮助您更快地启动和运行您的微服务。


【注】本文译自:Component vs. Bean annotations in Spring

Java中的多态与继承

Java中的多态与继承

开始学习Java中的多态及如何在多态方法调用中进行方法调用

多态——即对象根据其类型执行特定操作的能力——是Java代码灵活性的核心。四人组(Gang Of Four)创建的许多设计模式都依赖于某种形式的多态,包括命令模式。本文将介绍Java多态的基础知识及如何在程序中使用它。

关于Java多态需要了解的内容

  • 多态与Java继承
  • 为何多态重要
  • 方法重写中的多态
  • 核心Java类中的多态
  • 多态方法调用与类型转换
  • 保留关键字与多态
  • 多态的常见错误
  • 关于多态需要记住的要点

多态与Java继承

我们将重点探讨多态与Java继承的关系。需记住的核心点是:多态需要继承或接口实现。以下示例通过Duke和Juggy展示这一点:

public abstract class JavaMascot {
    public abstract void executeAction();
}

public class Duke extends JavaMascot {
    @Override
    public void executeAction() {
        System.out.println("Punch!");
    }
}

public class Juggy extends JavaMascot {
    @Override
    public void executeAction() {
        System.out.println("Fly!");
    }
}

public class JavaMascotTest {
    public static void main(String... args) {
        JavaMascot dukeMascot = new Duke();
        JavaMascot juggyMascot = new Juggy();
        dukeMascot.executeAction();
        juggyMascot.executeAction();
    }
}

代码输出为:

Punch!
Fly!

由于各自的具体实现,Duke和Juggy的动作均被执行。

为何多态重要

使用多态的目的是将客户端类与实现代码解耦。客户端类通过接收具体实现来执行所需操作,而非硬编码。这种方式下,客户端类仅需了解执行操作的必要信息,这是松耦合的典范。

为了更好地理解多态的优势,请观察以下SweetCreator

public abstract class SweetProducer {
    public abstract void produceSweet();
}

public class CakeProducer extends SweetProducer {
    @Override
    public void produceSweet() {
        System.out.println("Cake produced");
    }
}

public class ChocolateProducer extends SweetProducer {
    @Override
    public void produceSweet() {
        System.out.println("Chocolate produced");
    }
}

public class CookieProducer extends SweetProducer {
    @Override
    public void produceSweet() {
        System.out.println("Cookie produced");
    }
}

public class SweetCreator {
    private List<SweetProducer> sweetProducer;

    public SweetCreator(List<SweetProducer> sweetProducer) {
        this.sweetProducer = sweetProducer;
    }

    public void createSweets() {
        sweetProducer.forEach(sweet -> sweet.produceSweet());
    }
}

public class SweetCreatorTest {
    public static void main(String... args) {
        SweetCreator sweetCreator = new SweetCreator(
            Arrays.asList(
                new CakeProducer(),
                new ChocolateProducer(),
                new CookieProducer()
            )
        );
        sweetCreator.createSweets();
    }
}

此例中,SweetCreator类仅知晓SweetProducer类,而不了解每个甜点的具体实现。这种分离使类能灵活更新和重用,并大幅提升代码可维护性。设计代码时,应始终寻求使其尽可能灵活和可维护。多态是编写可重用Java代码的强力技术。

提示@Override注解强制程序员使用必须被重写的相同方法签名。若方法未被重写,将产生编译错误。

方法重载是多态吗?

许多程序员对多态与方法重写、重载的关系感到困惑。但只有方法重写是真正的多态。重载共享相同方法名但参数不同。多态是广义术语,因此相关讨论将持续存在。

方法重写中的多态

若返回类型是协变类型,则允许修改重写方法的返回类型。协变类型本质上是返回类型的子类。示例如下:

public abstract class JavaMascot {
    abstract JavaMascot getMascot();
}

public class Duke extends JavaMascot {
    @Override
    Duke getMascot() {
        return new Duke();
    }
}

由于DukeJavaMascot的子类,我们可在重写时修改返回类型。

核心Java类中的多态

我们在核心Java类中频繁使用多态。一个简单示例是实例化ArrayList类时声明List接口为类型:

List<String> list = new ArrayList<>();

进一步观察以下未使用多态的Java集合API代码:

public class ListActionWithoutPolymorphism {
    // 无多态的示例
    void executeVectorActions(Vector<Object> vector) {/* 此处代码重复 */}
    void executeArrayListActions(ArrayList<Object> arrayList) {/* 此处代码重复 */}
    void executeLinkedListActions(LinkedList<Object> linkedList) {/* 此处代码重复 */}
    void executeCopyOnWriteArrayListActions(CopyOnWriteArrayList<Object> copyOnWriteArrayList)
    { /* 此处代码重复 */}
}

public class ListActionInvokerWithoutPolymorphism {
    listAction.executeVectorActions(new Vector<>());
    listAction.executeArrayListActions(new ArrayList<>());
    listAction.executeLinkedListActions(new LinkedList<>());
    listAction.executeCopyOnWriteArrayListActions(new CopyOnWriteArrayList<>());
}

这段代码很糟糕,不是吗?想象维护它的难度!现在观察使用多态的相同示例:

public static void main(String … polymorphism) {
    ListAction listAction = new ListAction();    
    listAction.executeListActions();
}
public class ListAction {
    void executeListActions(List<Object> list) {
        // 对不同列表执行操作
    }
}
public class ListActionInvoker {
    public static void main(String... masterPolymorphism) {
        ListAction listAction = new ListAction();
        listAction.executeListActions(new Vector<>());
        listAction.executeListActions(new ArrayList<>());
        listAction.executeListActions(new LinkedList<>());
        listAction.executeListActions(new CopyOnWriteArrayList<>());
    }
}

多态的优势在于灵活性和扩展性。我们无需创建多个不同方法,只需声明一个接收通用List类型的方法。

多态方法调用与类型转换

可以在多态调用中调用特定方法,但会牺牲灵活性。示例如下:

public abstract class MetalGearCharacter {
    abstract void useWeapon(String weapon);
}
public class BigBoss extends MetalGearCharacter {
    @Override
    void useWeapon(String weapon) {
        System.out.println("Big Boss is using a " + weapon);
    }
    void giveOrderToTheArmy(String orderMessage) {
        System.out.println(orderMessage);
    }
}
public class SolidSnake extends MetalGearCharacter {
    void useWeapon(String weapon) {
        System.out.println("Solid Snake is using a " + weapon);
    }
}
public class UseSpecificMethod {
    public static void executeActionWith(MetalGearCharacter metalGearCharacter) {
        metalGearCharacter.useWeapon("SOCOM");
        // 以下行无法工作
        // metalGearCharacter.giveOrderToTheArmy("Attack!");
        if (metalGearCharacter instanceof BigBoss) {
            ((BigBoss) metalGearCharacter).giveOrderToTheArmy("Attack!");
        }
    }
    public static void main(String... specificPolymorphismInvocation) {
        executeActionWith(new SolidSnake());
        executeActionWith(new BigBoss());
    }
}

此处使用的技术是类型转换(casting),即在运行时显式改变对象类型。

注意:只有将通用类型强制转换为具体类型后,才能调用特定方法。这相当于明确告诉编译器:“我知道自己在做什么,因此要将对象转换为具体类型并使用特定方法。”

在上述示例中,编译器拒绝接受特定方法调用的原因很重要:传入的类可能是SolidSnake。在此情况下,编译器无法确保每个MetalGearCharacter的子类都声明了giveOrderToTheArmy方法。

保留关键字

注意保留字instanceof。在调用特定方法前,我们需检查MetalGearCharacter是否为BigBoss的实例。若BigBoss实例,将收到以下异常信息:

Exception in thread "main" java.lang.ClassCastException: com.javaworld.javachallengers.polymorphism.specificinvocation.SolidSnake cannot be cast to com.javaworld.javachallengers.polymorphism.specificinvocation.BigBoss

若需引用Java超类的属性或方法,可使用保留字super。例如:

public class JavaMascot {
    void executeAction() {
        System.out.println("The Java Mascot is about to execute an action!");
    }
}
public class Duke extends JavaMascot {
    @Override
    void executeAction() {
        super.executeAction();
        System.out.println("Duke is going to punch!");
    }
    public static void main(String... superReservedWord) {
        new Duke().executeAction();
    }
}

在Duke的executeAction方法中使用super可调用超类方法,再执行Duke的特定动作。因此输出如下:

The Java Mascot is about to execute an action!
Duke is going to punch!

多态的常见错误

  • 常见错误是认为无需类型转换即可调用特定方法。
  • 另一个错误是在多态实例化类时不确认将调用哪个方法。需记住:被调用的方法是所创建实例的方法。
  • 还需注意方法重写不同于方法重载
  • 若参数不同,则无法重写方法。若返回类型是超类方法的子类,则可以修改重写方法的返回类型。

关于多态需要记住的要点

  • 所创建的实例将决定使用多态时调用哪个方法。
  • @Override注解强制程序员使用重写方法;否则将产生编译错误。
  • 多态可用于普通类、抽象类和接口。
  • 大多数设计模式依赖某种形式的多态。
  • 调用多态子类中特定方法的唯一方式是使用类型转换。
  • 可通过多态设计强大的代码结构。

接受Java多态挑战!

让我们测试你对多态和继承的理解。在此挑战中,你需要根据Matt Groening的辛普森一家代码推断每个类的输出。首先仔细分析以下代码:

public class PolymorphismChallenge {
    static abstract class Simpson {
        void talk() {
            System.out.println("Simpson!");
        }
        protected void prank(String prank) {
            System.out.println(prank);
        }
    }
    static class Bart extends Simpson {
        String prank;
        Bart(String prank) { this.prank = prank; }
        protected void talk() {
            System.out.println("Eat my shorts!");
        }
        protected void prank() {
            super.prank(prank);
            System.out.println("Knock Homer down");
        }
    }
    static class Lisa extends Simpson {
        void talk(String toMe) {
            System.out.println("I love Sax!");
        }
    }
    public static void main(String... doYourBest) {
        new Lisa().talk("Sax :)");
        Simpson simpson = new Bart("D'oh");
        simpson.talk();
        Lisa lisa = new Lisa();
        lisa.talk();
        ((Bart) simpson).prank();
    }
}

你认为最终输出是什么?不要使用IDE!重点是提升代码分析能力,请自行推断结果。

选项:
A)

I love Sax!  
 D'oh  
 Simpson!  
 D'oh  

B)

Sax :)  
 Eat my shorts!  
 I love Sax!  
 D'oh  
 Knock Homer down  

C)

Sax :)  
 D'oh  
 Simpson!  
 Knock Homer down  

D)

I love Sax!  
 Eat my shorts!  
 Simpson!  
 D'oh  
 Knock Homer down

解答挑战
对于以下方法调用:

new Lisa().talk("Sax :)");

输出为“I love Sax!”,因为我们向方法传递了字符串且Lisa类有此方法。

下一调用:

Simpson simpson = new Bart("D'oh");
simpson.talk();

输出为“Eat my shorts!”,因为我们用Bart实例化了Simpson类型。

以下调用较为复杂:

Lisa lisa = new Lisa();
lisa.talk();

此处通过继承使用了方法重载。由于未向talk方法传递参数,因此调用Simpsontalk方法,输出为:

"Simpson!"

最后一个调用:

((Bart) simpson).prank();

此例中,prank字符串在实例化Bart时通过new Bart("D'oh")传入。此时首先调用super.prank方法,再执行Bart的特定prank方法。输出为:

"D'oh"
"Knock Homer down"

因此正确答案是D。输出为:

I love Sax!
Eat my shorts! 
Simpson!
D'oh
Knock Homer down

【注】本文译自:Polymorphism and inheritance in Java | InfoWorld

如何在Java程序中使用泛型

如何在Java程序中使用泛型

泛型可以使你的代码更灵活、更易读,并能帮助你在运行时避免ClassCastExceptions。让我们通过这篇结合Java集合框架的泛型入门指南,开启你的泛型之旅。

Java 5引入的泛型增强了代码的类型安全性并提升了可读性。它能帮助你避免诸如ClassCastException(当尝试将对象强制转换为不兼容类型时引发的异常)这类运行时错误。

本教程将解析泛型概念,通过三个结合Java集合框架的实例演示其应用。同时我们将介绍原始类型(raw types),探讨选择使用原始类型而非泛型的场景及其潜在风险。

Java编程中的泛型

  • 为何使用泛型?
  • 如何利用泛型保障类型安全
  • Java集合框架中的泛型应用
  • Java泛型类型示例
  • 原始类型与泛型对比

为何使用泛型?

泛型在Java集合框架中被广泛用于java.util.List、java.util.Set和java.util.Map等接口。它们也存在于Java其他领域,如java.lang.Class、java.lang.Comparable 和java.lang.ThreadLocal。

在泛型出现前,Java代码常缺乏类型安全保障。以下是非泛型时代Java代码的典型示例:

List integerList = new ArrayList();
integerList.add(1);
integerList.add(2);
integerList.add(3);

for (Object element : integerList) {
    Integer num = (Integer) element; // 必须显式类型转换
    System.out.println(num);
}

这段代码意图存储Integer对象,但没有任何机制阻止你添加其他类型(如字符串):

integerList.add("Hello");

当尝试将String强制转换为Integer时,这段代码会在运行时抛出ClassCastException。

利用泛型保障类型安全

为解决上述问题并避免ClassCastExceptions,我们可以使用泛型指定列表允许存储的对象类型。此时无需手动类型转换,代码更安全且更易理解:

List<Integer> integerList = new ArrayList<>();

integerList.add(1);
integerList.add(2);
integerList.add(3);

for (Integer num : integerList) {
    System.out.println(num);
}

List表示"存储Integer对象的列表"。基于此声明,编译器确保只有Integer对象能被添加至列表,既消除了类型转换需求,也预防了类型错误。

Java集合框架中的泛型

泛型深度集成于Java集合框架,提供编译时类型检查并消除显式类型转换需求。当使用带泛型的集合时,你需指定集合可容纳的元素类型。Java编译器基于此规范确保你不会意外插入不兼容对象,从而减少错误并提升代码可读性。

为演示泛型在Java集合框架中的使用,让我们观察几个实例。

List和ArrayList的泛型应用

前例已简要展示ArrayList的基本用法。现在让我们通过List接口的声明深入理解这一概念:

public interface List<E> extends SequencedCollection<E> { … }

此处声明泛型变量为"E",该变量可被任何对象类型替代。注意变量E代表元素(Element)。

接下来演示如何用具体类型替换变量。下例中将替换为

List<String> list = new ArrayList<>();
list.add("Java");
list.add("Challengers");
// list.add(1); // 此行会导致编译时错误

List声明该列表仅能存储String对象。如代码最后一行所示,尝试添加Integer将引发编译错误。

Set和HashSet的泛型应用

Set接口与List类似:

public interface Set<E> extends Collection<E> { … }

我们将用替换,使集合只能存储Double值:

Set<Double> doubles = new HashSet<>();
doubles.add(1.5);
doubles.add(2.5);
// doubles.add("three"); // 编译时错误

double sum = 0.0;
for (double d : doubles) {
    sum += d;
}

Set确保只有Double值能被添加至集合,防止因错误类型转换引发的运行时错误。

Map和HashMap的泛型应用

我们可以声明任意数量的泛型类型。以键值数据结构Map为例,K代表键(Key),V代表值(Value):

public interface Map<K, V> { … }

现在用String替换K作为键类型,用Integer替换V作为值类型:

Map<String, Integer> map = new HashMap<>();
map.put("Duke", 30);
map.put("Juggy", 25);
// map.put(1, 100); // 此行会导致编译时错误

此例展示将String键映射到Integer值的HashMap。添加Integer类型的键将不被允许并导致编译错误。

泛型命名规范

我们可以在任何类中声明泛型类型。虽然可以使用任意名称,但建议遵循命名规范:

  • E 代表元素(Element)
  • K 代表键(Key)
  • V 代表值(Value)
  • T 代表类型(Type)

应避免使用无意义的"X"、"Y"或"Z"等名称。

Java泛型类型使用示例

现在通过更多示例深入演示Java中泛型类型的声明与使用。

创建通用对象容器

我们可以在自定义类中声明泛型类型,不必局限于集合类型。下例中,Box类通过声明泛型类型E来操作任意元素类型。注意泛型类型E声明于类名之后,随后即可作为属性、构造器、方法参数和返回类型使用:

// 定义带泛型参数E的Box类
public class Box<E> {
    private E content; // 存储E类型对象

    public Box(E content) { this.content = content; }
    public E getContent() { return content; }
    public void setContent(E content) { 
        this.content = content;
    }

    public static void main(String[] args) {
        // 创建存储Integer的Box
        Box<Integer> integerBox = new Box<>(123);
        System.out.println("整数盒内容:" + integerBox.getContent());

        // 创建存储String的Box
        Box<String> stringBox = new Box<>("Hello World");
        stringBox.setContent("Java Challengers");
        System.out.println("字符串盒内容:" + stringBox.getContent());
    }
}

输出结果:

整数盒内容:123
字符串盒内容:Java Challengers

代码要点:

  • Box类使用类型参数E作为容器存储对象的占位符,允许Box处理任意对象类型
  • 构造器初始化Box实例时接受指定类型对象,确保类型安全
  • getContent返回与实例创建时指定的泛型类型匹配的对象,无需类型转换
  • setContent通过类型参数E确保只能设置正确类型的对象
  • main方法创建了存储Integer和String的Box实例
  • 每个Box实例操作特定数据类型,展现泛型在类型安全方面的优势

此例展示了Java泛型的基础实现,演示了如何以类型安全方式创建和操作任意类型对象。

处理多数据类型

我们可以声明多个泛型类型。以下Pair类包含<K, V>泛型值。如需更多泛型参数,可扩展为<K, V, V1, V2, V3>等,代码仍可正常编译。

Pair类示例:

class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() { return key; }
    public V getValue() { return value; }

    public void setKey(K key) { this.key = key; }
    public void setValue(V value) { this.value = value; }
}

public class GenericsDemo {
    public static void main(String[] args) {
        Pair<String, Integer> person = new Pair<>("Duke", 30);

        System.out.println("姓名:" + person.getKey());
        System.out.println("年龄:" + person.getValue());

        person.setValue(31);
        System.out.println("更新后年龄:" + person.getValue());
    }
}

输出结果:

姓名:Duke
年龄:30
更新后年龄:31

代码要点:

  • Pair<K, V>类包含两个类型参数,适用于任意数据类型组合
  • 构造器与方法使用类型参数实现严格类型检查
  • 创建存储String(姓名)和Integer(年龄)的Pair对象
  • 访问器和修改器方法操作Pair数据
  • Pair类可存储管理关联信息而不受特定类型限制,展现泛型的灵活性与强大功能

此例展示泛型如何创建支持多数据类型的可复用类型安全组件,提升代码复用性和可维护性。

让我们再看一个示例。

方法级泛型声明

泛型类型可直接在方法中声明,无需在类级别定义。若某个泛型类型仅用于特定方法,可在方法签名返回类型前声明:

public class GenericMethodDemo {

    // 声明泛型类型<T>并打印指定类型数组
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        Integer[] intArray = {1, 2, 3, 4};
        printArray(intArray);

        String[] stringArray = {"Java", "Challengers"};
        printArray(stringArray);
    }
}

输出结果:

1 2 3 4
Java Challengers

原始类型与泛型对比

原始类型指未指定类型参数的泛型类或接口名称。在Java 5引入泛型前,原始类型被广泛使用。现今开发者通常仅在与遗留代码兼容或与非泛型API交互时使用原始类型。即使使用泛型,仍需了解如何识别和处理原始类型。

典型原始类型示例——未指定类型参数的List声明:

 List rawList = new ArrayList();

此处List rawList声明了一个未指定泛型参数的列表。rawList可存储任意类型对象(Integer、String、Double等)。由于未指定类型,编译器不会对添加至列表的对象类型进行检查。

使用原始类型的编译警告

Java编译器会对原始类型使用发出警告,提醒开发者可能存在的类型安全隐患。当使用泛型时,编译器会检查集合(如List、Set)中存储的对象类型、方法返回类型和参数是否匹配声明类型,从而预防如ClassCastException的常见错误。

使用原始类型时,由于未指定存储对象类型,编译器无法进行类型检查,因此会发出警告提示你绕过了泛型提供的类型安全机制。

编译警告示例

以下代码演示编译器如何对原始类型发出警告:

List list = new ArrayList(); // 警告:原始使用参数化类'List'
list.add("hello");
list.add(1);

编译时通常会显示:

注意:SomeFile.java使用了未经检查或不安全的操作。
注意:使用-Xlint:unchecked重新编译以获取详细信息。

使用-Xlint:unchecked参数编译将显示更详细警告:

warning: [unchecked] unchecked call to add(E) as a member of the raw type List
    list.add("hello");
            ^
  where E is a type-variable:
    E extends Object declared in interface List

若确信使用原始类型不会引入风险,或处理无法重构的遗留代码,可使用@SuppressWarnings("unchecked")注解抑制警告。但需谨慎使用,避免掩盖真实问题。

使用原始类型的后果

尽管原始类型有助于向后兼容,但存在两大缺陷:类型安全性缺失和维护成本增加。

  • 类型安全性缺失:泛型的核心优势是类型安全,使用原始类型将丧失这一优势。编译器不进行类型正确性检查,可能导致运行时ClassCastException。
  • 维护成本增加:使用原始类型的代码缺乏泛型提供的明确类型信息,维护难度加大,易产生仅在运行时暴露的错误。

类型安全问题示例:使用原始类型List而非泛型List时,编译器允许添加任意类型对象。当从列表检索元素并尝试强制转换为String时,若实际为其他类型将导致运行时错误。

泛型知识要点回顾

泛型以高度灵活性提供类型安全保障。以下回顾关键要点:

泛型是什么?为何使用?

  • code.Java 5引入泛型以提升代码类型安全性和灵活性
  • 主要优势在于帮助避免ClassCastException等运行时错误
  • 泛型广泛应用于Java集合框架,也见于Class、Comparable、ThreadLocal等组件
  • 通过阻止不兼容类型插入实现类型安全

Java集合中的泛型

  • List和ArrayList:List允许指定元素类型E,确保列表类型专一
  • Set和HashSet:Set限定元素为类型E,保持一致性
  • Map和HashMap:Map<K,V>定义键值类型,提升类型安全性和代码清晰度

泛型使用优势

  • 通过阻止不兼容类型插入减少错误
  • 明确类型关联提升代码可读性和可维护性
  • 便于以类型安全方式创建和管理集合等数据结构

Java Stream API:每个开发者都应该知道的 3 件事

Java Stream API

Java Stream API:每个开发者都应该知道的 3 件事

Java Stream API 通过惰性求值并行处理函数式编程简化了集合处理。使用它可以编写更简洁、高效和可扩展的代码。

时间飞逝!我记得 Java 8 曾经是一个标杆,每个人都把它当作一种全新且革命性的东西来谈论。老实说,它确实是全新且革命性的。但现在,使用 Java 8 的项目可能被称为“遗留”项目。即使 Java 8 本身已经成为遗留版本,它引入的特性仍然具有实际意义。今天,我们来聊聊其中一个特性——Stream API

如果你还不了解,Java Stream API 是一个强大的工具,它允许程序员以函数式编程风格编写 Java 代码。它通过支持过滤、转换和聚合操作,使得集合的处理更加简单。

尽管 Stream API 被广泛使用,但我仍然发现许多开发者对其深层知识的掌握存在不足。在本文中,我将探讨 Stream API 的三个关键方面,这些方面对于深入理解它至关重要:

  1. 惰性求值:帮助我们优化操作链的执行。
  2. 并行流:通过利用多核处理器,深入探讨如何增加数据处理的并行性。
  3. Lambda 变量作用域:了解在使用 Stream 时如何正确地将变量传递给 Lambda。

我希望通过本文,你能更好地理解这些概念。


1. Java Stream API 中的惰性求值

惰性求值是理解如何有效使用流的核心概念。但在深入探讨惰性求值之前,我们先来了解流管道中的两种主要操作类型:中间操作终端操作

  1. 中间操作:中间操作是将输入流转换为另一个流的操作,但不会产生不可变的结果。例如,filter()map()flatMap() 都是中间操作,因为它们接受一个输入流并返回另一个流。这些操作不会立即消耗输入流中的所有元素,而是创建一个包含所需元素的新流。

  2. 终端操作:终端操作是消耗流中元素的操作,它们要么返回一个结果,要么通过副作用修改某些状态。例如,forEach()findFirst()collect() 都是终端操作,因为它们最终会消耗流中的所有元素以产生结果。

什么是惰性求值?它是如何工作的?

在 Stream API 中,惰性求值意味着中间操作不会立即执行,直到我们调用一个终端操作。这意味着我们可以在代码的任何地方定义一个流及其所有操作,但只有在调用终端操作时才会执行。

当我们调用终端操作时,流会逐个处理数据元素,依次应用所有中间操作。这种方法通过避免不必要的计算来优化性能。

让我们通过一个实际例子来看看惰性求值如何影响执行:

import java.util.stream.Stream;

public class LazyEvaluationExample {
    public static void main(String[] args) {
        Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5)
            .filter(num -> {
                System.out.println("Filtering: " + num);
                return num % 2 == 0;
            })
            .map(num -> {
                System.out.println("Mapping: " + num);
                return num * 2;
            });

        System.out.println("Stream pipeline defined, no execution yet.");

        // 终端操作触发执行
        stream.forEach(System.out::println);
    }
}

输出:

Stream pipeline defined, no execution yet.
Filtering: 1
Filtering: 2
Mapping: 2
4
Filtering: 3
Filtering: 4
Mapping: 4
8
Filtering: 5

让我们试着理解为什么会有这样的输出。我们可以看到,filter()map() 操作是惰性的。这意味着即使我们编写了这些代码,它们也不会在调用终端操作 forEach() 之前执行。这解释了为什么我们首先看到输出 Stream pipeline defined, no execution yet

只有当调用 forEach() 终端操作时,流才会开始逐个处理元素。


2. 并行流

Java Stream API 最有用和最强大的功能之一是对并行流的支持。并行性是指通过利用多个 CPU 核心同时处理两个或多个操作的能力。在 Stream API 中,这意味着我们可以同时处理流中的多个元素的中间或终端操作。

这种功能可以显著提高计算密集型任务的性能,但为了更好地理解它以达到最佳效果,我们需要深入了解它。

什么是并行流?

并行流是一种将其元素分成多个块,然后通过不同线程并行处理的流。与普通流(逐个处理元素)不同,并行流在底层使用 ForkJoinPool 来实现并行性。

创建并行流非常简单。你可以使用以下两种方法之一:

  • 对于现有集合,可以使用 parallelStream() 方法。
  • 你可以通过在现有流上调用 parallel() 方法来使其并行。

何时使用并行流?

并行流可以在特定场景中提升性能,但它们并不总是最佳选择。以下是一些关键考虑因素:

  1. 适合的场景

    • 大数据集:当处理大量数据时,并行性效果最好。
    • CPU 密集型任务:对于大量使用 CPU 的计算任务(如数学运算或数据转换),并行流是理想选择。
  2. 避免使用并行流的场景

    • IO 密集型任务:如果你的任务涉及大量读写操作(如磁盘/网络操作),并行流可能不是最佳选择。
    • 小数据集:你需要记住,Java 虚拟机在底层仍然需要管理线程切换等操作。因此,在处理小数据集时,管理线程的开销可能会超过性能提升。

性能对比代码的解释

下面的代码将帮助我们通过求和操作来比较 Java 中顺序流和并行流的性能。我们将运行两个测试:

  • 第一个测试:范围从 1 到 1,000,000。
  • 第二个测试:范围从 1 到 100,000,000。

我们的主要目标是比较顺序流和并行流的处理时间,从而帮助我们理解使用并行流的优缺点。

int rangeLimit = 1_000_000;

long start = System.currentTimeMillis();
LongStream.rangeClosed(1, rangeLimit)
    .reduce(0L, Long::sum);
long end = System.currentTimeMillis();

System.out.println("Sequential Stream Time: " + (end - start) + " ms");

start = System.currentTimeMillis();
LongStream.rangeClosed(1, rangeLimit)
    .parallel()
    .reduce(0L, Long::sum);
end = System.currentTimeMillis();

System.out.println("Parallel Stream Time: " + (end - start) + " ms");

首先,我们创建了两个流:顺序流和并行流。并行流是通过在现有流上调用 .parallel() 方法创建的。两者都包含从 1 到 1,000,000 的数字,使用 LongStream.rangeClosed() 方法生成。

其次,我们对两个流执行了 .reduce(0L, Long::sum) 方法,该方法对输入流中的所有元素求和。由于 reduce 是一个终端操作,流会在调用该方法时立即开始处理。

我们能够测量此操作所花费的时间。这些信息通过 System.currentTimeMillis() 命令记录并存储在变量 startend 中。结果以毫秒为单位打印出来。

让我们执行代码两次,更新 rangeLimit 变量。第一次执行时,将其设置为 1,000,000,如代码所示。第二次执行时,将其设置为 100,000,000。

对于范围从 1 到 1,000,000:

Sequential Stream Time: 9 ms
Parallel Stream Time: 12 ms

我们可以看到,在这种情况下,并行流比顺序流稍慢。这是一个很好的例子,展示了对于像我们示例中使用的小数据集,管理多个线程可能会导致性能损失。

接下来,我们将范围增加到 100,000,000,结果如下:

Sequential Stream Time: 57 ms
Parallel Stream Time: 12 ms

最终,我们可以看到并行流的优势。在这里,并行流明显优于顺序流。较大的数据集能够通过利用多个 CPU 核心来加速计算过程。

重要注意事项:处理大数据集

我们需要记住一点:Java 中的 Long 类型的最大值是 2^63-1。因此,在我们的示例中,当我们测试较大的范围时,求和结果可能会超过此限制,从而导致不正确的结果。

由于本示例的主要目的是展示并行流的行为并比较效率,我们可以忽略结果可能不正确的事实。如果需要精确求和,你可能需要使用更大范围的类型,例如 BigInteger


3. Lambda 中的变量作用域

让我们简单讨论一下 Lambda。Lambda 表达式在 Stream API 中被广泛使用。老实说,我认为有很多开发者只在流中使用 Lambda。因此,我认为在本文中讨论一些与 Lambda 相关的点也是合理的。

我们应该意识到,Lambda 与变量的交互方式有其特殊性,作为 Java 开发者,理解 Lambda 如何捕获和使用变量至关重要。

让我们探讨一下变量作用域在 Lambda 表达式中是如何工作的,以及它与传统方法的区别。

在 Lambda 中捕获变量

假设你在 Lambda 的外部作用域中初始化了一个变量,并计划在 Lambda 函数中使用这个变量。你能这样做吗?

这取决于情况。我们只能使用从外部作用域捕获的变量,前提是它们是 final有效 final 的。那么,“有效 final”是什么意思呢?

简而言之,如果一个变量在初始化后其值从未改变,则它被认为是有效 final 的。因此,要在 Lambda 中使用变量,你有两种方法:

  1. 像往常一样初始化变量,并确保其值在初始化后不会改变。
  2. 在初始化时通过添加 final 关键字使变量成为 final
int factor = 2;  

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

numbers.stream()
    .map(n -> n * factor)  
    .forEach(System.out::println);

在上面的示例中,我们可以看到 factor 变量是有效 final 的,因为我们在初始化后没有更新它。这意味着这个变量可以在我们的 Lambda 中使用。你可以尝试在初始化后重新赋值 factor,看看会发生什么。


结论

Stream API 是一套强大且易于理解的工具,用于处理元素序列。如果正确使用,它可以帮助减少大量不必要的代码,使程序更具可读性,并提高应用程序的性能。但正如我所提到的,正确使用它以从性能和代码简洁性方面获得最佳结果至关重要。


【注】本文译自:Java Stream API: 3 Things Every Developer Should Know About

使用Lambda表达式和接口的简单Java 8 Predicate示例

大量的Java编程涉及到对真或假值的评估,从条件语句到迭代循环。当您使用JDK的Streams API和Lambda函数时,可以使用备受欢迎的Java Predicate接口来简化布尔条件的评估。

也被称为Java 8 Predicate(源自引入函数式编程的JDK版本),这个简单的接口定义了五个方法,尽管只有Java Predicate的test方法在Stream或Lambda调用中被评估。

img

图1:Java 8 Predicate接口的五个方法的JavaDoc列表

传统的Java 8 Predicate示例:

尽管Java 8的Predicate是一个函数式接口,但开发人员仍可以以传统方式使用它。下面是一个Java Predicate示例,它简单地创建了一个扩展Predicate接口的新类,并在其主方法中使用Predicate的单独类:

import java.util.function.*;
public class Java8PredicateTutorial {  
  public static void main(String args[]) {
    PredicateExample example = new PredicateExample();
    System.out.printf("Gretzky's number is even: %s", example.test(99));
    boolean value = example.test(66);
    System.out.printf("nLemieux's number is even: %s ", value);  
  }   
}
class PredicateExample implements Predicate<Integer> {
  public boolean test(Integer x) {
    if (x%2==0){
      return true;
    } else {
    return false;
    }
  }
}

img

图2:如何编译和运行Java 8 Predicate示例

图2展示了编译和执行这个Predicate接口教程时的结果。

Java Predicate作为内部类:

如果您是一个喜欢内部类的开发人员,您可以对此进行一些简化,减少示例的冗长性。然而,这个Java 8 Predicate示例并不完全符合函数式编程的要求。

import java.util.function.*;
public class Java8PredicateTutorial {
  public static void main(String args[]) {
    Predicate predicateExample = new Predicate<Integer>() {
    public boolean test(Integer x) {
        return (x % 2 == 0);
        }
    };
    System.out.printf("Gretzky's number is even: %s", predicateExample.test(99));
    System.out.printf("nLemieux's number is even: %s ", predicateExample.test(66));
    }
}

Java Predicate lambda 示例

当然,如果您正在学习Java 8的Predicate接口,您很可能对如何在Lambda函数中使用它感兴趣。

Lambda表达式的目标是减少Java代码的冗长性,特别是在需要覆盖只有一个功能方法的接口的情况下。以下是使用Lambda表达式创建Java Predicate的代码示例:

Predicate<Integer> lambdaPredicate = (Integer x) -> (x % 2 == 0);

与传统的接口创建方法相比,毋庸置疑,Lambda表达式更加简洁。

以下是完整的使用Lambda表达式实现的Java Predicate示例:

import java.util.function.*;
public class Java8PredicateTutorial {
  public static void main(String args[]) {             
    /* Java predicate lambda example */
    Predicate<Integer> lambdaPredicate = (Integer x) -> (x % 2 == 0);             
    System.out.printf("Gretzky's number is even: %s", lambdaPredicate.test(99));
    System.out.printf("nLemieux's number is even: %s ", lambdaPredicate.test(66));
  }   
}

Java Predicate 和 lambda 流

自从JDK 8发布以来,函数式表达式已经在Java API中广泛应用。Streams API广泛使用Lambda表达式和Java Predicate,其中过滤表达式(filter expression)就是其中之一。下面是一个使用Lambda表达式、Stream和Predicate的示例,从一个Integer对象的列表中提取出所有的偶数:

import java.util.function.*;
import java.util.*;
import java.util.stream.*;
public class LambdaPredicateStreamExample {    
  public static void main(String args[]) {          
    List<Integer> jerseys = Arrays.asList(99, 66, 88, 16);
    /* Java predicate and lambda stream example usage */
    List<Integer> evenNumbers =
          jerseys.stream()
              .filter( x -> ((x%2)==0))
                  .collect(Collectors.toList());          
    /* The following line prints: [66, 88, 16] 8 */
    System.out.println(evenNumbers);
  }
}

正如您所看到的,Java的Lambda函数、流(Streams)和Predicate接口的组合使用可以创建非常紧凑的代码,既强大又易于阅读。


【注】本文译自:
Simple Java 8 Predicate example with lambda expressions and interfaces

Java开发中不要使用受检异常

简介

Types of Java Exception

Java是唯一(主流)实现了受检异常概念的编程语言。一开始,受检异常就是争议的焦点。在当时被视为一种创新概念(Java于1996年推出),如今却被视不良实践。

本文要讨论Java中非受检异常和受检异常的动机以及它们优缺点。与大多数关注这个主题的人不同,我希望提供一个平衡的观点,而不仅仅是对受检异常概念的批评。

我们先深入探讨Java中受检异常和非受检异常的动机。Java之父詹姆斯·高斯林对这个话题有何看法?接下来,我们要看一下Java中异常的工作原理以及受检异常存在的问题。我们还将讨论在何时应该使用哪种类型的异常。最后,我们将提供一些常见的解决方法,例如使用Lombok的@SneakyThrows注解。

Java和其他编程语言中异常的历史

在软件开发中,异常处理可以追溯到20世纪60年代LISP的引入。通过异常,我们可以解决在程序错误处理过程中可能遇到的几个问题。

异常的主要思想是将正常的控制流与错误处理分离。让我们看一个不使用异常的例子:

public void handleBookingWithoutExceptions(String customer, String hotel) {

 if (isValidHotel(hotel)) {

  int hotelId = getHotelId(hotel);

  if (sendBookingToHotel(customer, hotelId)) {

   int bookingId = updateDatabase(customer, hotel);

   if (bookingId > 0) {

    if (sendConfirmationMail(customer, hotel, bookingId)) {

     logger.log(Level.INFO, "Booking confirmed");

    } else {

     logger.log(Level.INFO, "Mail failed");

    }

   } else {

    logger.log(Level.INFO, "Database couldn't be updated");

   }

  } else {

   logger.log(Level.INFO, "Request to hotel failed");

  }

 } else {

  logger.log(Level.INFO, "Invalid data");

 }

}

程序的逻辑只占据了大约5行代码,其余的代码则是用于错误处理。这样,代码不再关注主要的流程,而是被错误检查所淹没。

如果我们的编程语言没有异常机制,我们只能依赖函数的返回值。让我们使用异常来重写我们的函数:

public void handleBookingWithExceptions(String customer, String hotel) {

 try {

  validateHotel(hotel);

  sendBookingToHotel(customer, getHotelId(hotel));

  int bookingId = updateDatabase(customer, hotel);

  sendConfirmationMail(customer, hotel, bookingId);

  logger.log(Level.INFO, "Booking confirmed");

 } catch(Exception e) {

  logger.log(Level.INFO, e.getMessage());

 }

}

采用这种方法,我们不需要检查返回值,而是将控制流转移到catch块中。这样的代码更易读。我们有两个独立的流程: 正常流程和错误处理流程。

除了可读性之外,异常还解决了"半谓词问题"(semipredicate problem)。简而言之,半谓词问题发生在表示错误(或不存在值)的返回值成为有效返回值的情况下。让我们看几个示例来说明这个问题:

示例:

int index = "Hello World".indexOf("World");

int value = Integer.parseInt("123");

int freeSeats = getNumberOfAvailableSeatsOfFlight();

indexOf() 方法如果未找到子字符串,将返回 -1。当然,-1 绝对不可能是一个有效的索引,所以这里没有问题。然而,parseInt() 方法的所有可能返回值都是有效的整数。这意味着我们没有一个特殊的返回值来表示错误。最后一个方法 getNumberOfAvailableSeatsOfFlight() 可能会导致隐藏的问题。我们可以将 -1 定义为错误或没有可用信息的返回值。乍看起来这似乎是合理的。然而,后来可能发现负数表示等待名单上的人数。异常机制能更优雅地解决这个问题。

Java中异常的工作方式

在讨论是否使用受检异常之前,让我们简要回顾一下Java中异常的工作方式。下图显示了异常的类层次结构:

java-exception

RuntimeException继承自Exception,而Error继承自Throwable。RuntimeException和Error被称为非受检异常,意味着它们不需要由调用代码处理(即它们不需要被“检查”)。所有其他继承自Throwable(通常通过Exception)的类都是受检异常,这意味着编译器期望调用代码处理它们(即它们必须被“检查”)。

所有继承自Throwable的异常,无论是受检的还是非受检的,都可以在catch块中捕获。

最后,值得注意的是,受检异常和非受检异常的概念是Java编译器的特性。JVM本身并不知道这个区别,所有的异常都是非受检的。这就是为什么其他JVM语言不需要实现这个特性的原因。

在我们开始讨论是否使用受检异常之前,让我们简要回顾一下这两种异常类型之间的区别。

受检异常

受检异常需要被try-catch块包围,或者调用方法需要在其签名中声明异常。由于Scanner类的构造函数抛出一个FileNotFoundException异常,这是一个受检异常,所以下面的代码无法编译:

public void readFile(String filename) {

 Scanner scanner = new Scanner(new File(filename));

}

我们会得到一个编译错误:

Unhandled exception: java.io.FileNotFoundException

我们有两种选项来解决这个问题。我们可以将异常添加到方法的签名中:

public void readFile(String filename) throws FileNotFoundException {

 Scanner scanner = new Scanner(new File(filename));

}

或者我们可以使用try-catch块在现场处理异常:

public void readFile(String filename) {

 try {

  Scanner scanner = new Scanner(new File(filename));

 } catch (FileNotFoundException e) {

  // handle exception

 }

}

非受检异常

对于非受检异常,我们不需要做任何处理。由Integer.parseInt引发的NumberFormatException是一个运行时异常,所以下面的代码可以编译通过:

public int readNumber(String number) {

 return Integer.parseInt(callEndpoint(number));

}

然而,我们仍然可以选择处理异常,因此以下代码也可以编译通过:

public int readNumber(String number) {

 try {

  return Integer.parseInt(callEndpoint(number));

 } catch (NumberFormatException e) {

  // handle exception

  return 0;

 }

}

为什么我们要使用受检异常?

如果我们想了解受检异常背后的动机,我们需要看一下Java的历史。该语言的创建是以强调健壮性和网络功能为重点的。

最好用Java创始人詹姆斯·高斯林(James Gosling)自己的一句话来表达:“你不能无意地说,‘我不在乎。’你必须明确地说,‘我不在乎。’”这句话摘自一篇与詹姆斯·高斯林进行的有趣的采访,在采访中他详细讨论了受检异常。

在《编程之父》这本书中,詹姆斯也谈到了异常。他说:“人们往往忽略了检查返回代码。”

这再次强调了受检异常的动机。通常情况下,当错误是由于编程错误或错误的输入时,应该使用非受检异常。如果在编写代码时程序员无法做任何处理,应该使用受检异常。后一种情况的一个很好的例子是网络问题。开发人员无法解决这个问题,但程序应该适当地处理这种情况,可以是终止程序、重试操作或简单地显示错误消息。

受检异常存在的问题

了解了受检异常和非受检异常背后的动机,我们再来看看受异常在代码库中可能引入的一些问题。

受检异常不适应规模化

一个主要反对受异常的观点是代码的可扩展性和可维护性。当一个方法的异常列表发生变化时,会打破调用链中从调用方法开始一直到最终实现try-catch来处理异常的方法的所有方法调用。举个例子,假设我们调用一个名为libraryMethod()的方法,它是外部库的一部分:

public void retrieveContent() throws IOException {

 libraryMethod();

}

在这里,方法libraryMethod()本身来自一个依赖项,例如,一个处理对外部系统进行REST调用的库。其实现可能如下所示:

public void libraryMethod() throws IOException {

 // some code

}

在将来,我们决定使用库的新版本,甚至用另一个库替换它。尽管功能相似,但新库中的方法会抛出两个异常:

public void otherSdkCall() throws IOException, MalformedURLException {

 // call method from SDK

}

由于有两个受检异常,我们的方法声明也需要更改:

public void retrieveContent() throws IOException, MalformedURLException {

 sdkCall();

}

对于小型代码库来说,这可能不是一个大问题,但对于大型代码库来说,这将需要进行相当多的重构。当然,我们也可以直接在方法内部处理异常:

public void retrieveContent() throws IOException {

 try {

  otherSdkCall();

 } catch (MalformedURLException e) {

  // do something with the exception

 }

}

使用这种方法,我们在代码库中引入了一种不一致性,因为我们立即处理了一个异常,而推迟了另一个异常的处理。

异常传播

一个与可扩展性非常相似的论点是受检异常如何在调用堆栈中传播。如果我们遵循“尽早抛出,尽晚捕获”的原则,我们需要在每个调用方法上添加一个throws子句(a):

异常传播

相反,非受检异常(b)只需要在实际发生异常的地方声明一次,并在我们希望处理异常的地方再次声明。它们会在调用堆栈中自动传播,直到达到实际处理异常的位置。

不必要的依赖关系

受检异常还会引入与非受检异常不必要的依赖关系。让我们再次看看在场景(a)中我们在三个不同的位置添加了IOException。如果methodA()、methodB()和methodC()位于不同的类中,那么所有相关类都将对异常类有一个依赖关系。如果我们使用了非受检异常,我们只需要在methodA()和methodC()中有这个依赖关系。甚至methodB()所在的类或模块都不需要知道异常的存在。

让我们用一个例子来说明这个想法。假设你从度假回家。你在酒店前台退房,乘坐公共汽车去火车站,然后换乘一次火车,在回到家乡后,你又乘坐另一辆公共汽车从车站回家。回到家后,你意识到你把手机忘在了酒店里。在你开始整理行李之前,你进入了“异常”流程,乘坐公共汽车和火车回到酒店取手机。在这种情况下,你按照之前相反的顺序做了所有的事情(就像在Java中发生异常时向上移动堆栈跟踪一样),直到你到达酒店。显然,公共汽车司机和火车操作员不需要知道“异常”,他们只需要按照他们的工作进行。只有在前台,也就是“回家”流程的起点,我们需要询问是否有人找到了手机。

糟糕的编码实践

当然,作为专业的软件开发人员,我们绝不能在良好的编码实践上选择方便。然而,当涉及到受检异常时,往往会诱使我们快速引入以下三种模式。通常的想法是以后再处理。我们都知道这样的结果。另一个常见的说法是“我想为正常流程编写代码,不想被异常打扰”。我经常见到以下三种模式。

第一种模式是捕获所有异常(catch-all exception):

public void retrieveInteger(String endpoint) {

 try {

  URL url = new URL(endpoint);

  int result = Integer.parseInt(callEndpoint(endpoint));

 } catch (Exception e) {

  // do something with the exception

 }

}

我们只是捕获所有可能的异常,而不是单独处理不同的异常:

public void retrieveInteger(String endpoint) {

 try {

  URL url = new URL(endpoint);

  int result = Integer.parseInt(callEndpoint(endpoint));

 } catch (MalformedURLException e) {

  // do something with the exception

 } catch (NumberFormatException e) {

  // do something with the exception

 }

}

当然,在一般情况下,这并不一定是一种糟糕的实践。如果我们只想记录异常,或者在Spring Boot的@ExceptionHandler中作为最后的安全机制,这是一种适当的做法。

第二种模式是空的catch块:

public void myMethod() {

 try {

  URL url = new URL("malformed url");

 } catch (MalformedURLException e) {}

}

这种方法显然绕过了受检异常的整个概念。它完全隐藏了异常,使我们的程序在没有提供任何关于发生了什么的信息的情况下继续执行。

第三种模式是简单地打印堆栈跟踪并继续执行,就好像什么都没有发生一样:

public void consumeAndForgetAllExceptions(){

 try {

  // some code that can throw an exception

 } catch (Exception ex){

  ex.printStacktrace();

 }

}

为了满足方法签名而添加额外的代码

有时我们可以确定除非出现编程错误,否则不会抛出异常。让我们考虑以下示例:

public void readFromUrl(String endpoint) {

 try {

  URL url = new URL(endpoint);

 } catch (MalformedURLException e) {

  // do something with the exception

 }

}

MalformedURLException是一个受检异常,当给定的字符串不符合有效的URL格式时,会抛出该异常。需要注意的重要事项是,如果URL格式不正确,就会抛出异常,这并不意味着URL实际上存在并且可以访问。

即使我们在之前验证了格式:

public void readFromUrl(@ValidUrl String endpoint)

或者我们已经将其硬编码:

public static final String endpoint = "http://www.example.com";

编译器仍然强制我们处理异常。我们需要写两行“无用”的代码,只是因为有一个受检异常。

如果我们无法编写代码来触发某个异常的抛出,就无法对其进行测试,因此测试覆盖率将会降低。

有趣的是,当我们想将字符串解析为整数时,并不强制我们处理异常:

Integer.parseInt("123");

parseInt方法在提供的字符串不是有效整数时会抛出NumberFormatException,这是一个非受检异常。

Lambda表达式和异常

受检异常并不总是与Lambda表达式很好地配合使用。让我们来看一个例子:

public class CheckedExceptions {

 public static String readFirstLine(String filename) throws FileNotFoundException {

  Scanner scanner = new Scanner(new File(filename));

  return scanner.next();

 }

 public void readFile() {

  List<String> fileNames = new ArrayList<>();

  List<String> lines = fileNames.stream().map(CheckedExceptions::readFirstLine).toList();

 }

}

由于我们的readFirstLine()方法抛出了一个受检异常,所以会导致编译错误:

Unhandled exception: java.io.FileNotFoundException in line 8.

如果我们尝试使用try-catch块来修正代码:

public void readFile() {

 List<String> fileNames = new ArrayList<>();

 try {

  List<String> lines = fileNames.stream()

    .map(CheckedExceptions::readFirstLine)

    .toList();

 } catch (FileNotFoundException e) {

   // handle exception

 }

}

我们仍然会得到一个编译错误,因为我们无法在lambda内部将受检异常传播到外部。我们必须在lambda表达式内部处理异常并抛出一个运行时异常:

public void readFile() {

 List<String> lines = fileNames.stream()

  .map(filename -> {

   try{

    return readFirstLine(filename);

   } catch(FileNotFoundException e) {

    throw new RuntimeException("File not found", e);

   }

  }).toList();

}

不幸的是,如果静态方法引用抛出受检异常,这种方式将变得不可行。或者,我们可以让lambda表达式返回一个错误消息,然后将其添加到结果中:

public void readFile() {

 List<String> lines = fileNames.stream()

  .map(filename -> {

   try{

    return readFirstLine(filename);

   } catch(FileNotFoundException e) {

    return "default value";

   }

  }).toList();

}

然而,代码看起来仍然有些杂乱。

我们可以在lambda内部传递一个非受检异常,并在调用方法中捕获它:

public class UncheckedExceptions {

 public static int parseValue(String input) throws NumberFormatException {

  return Integer.parseInt(input);

 }

 public void readNumber() {

  try {

   List<String> values = new ArrayList<>();

   List<Integers> numbers = values.stream()

       .map(UncheckedExceptions::parseValue)

       .toList();

  } catch(NumberFormatException e) {

   // handle exception

  }

 }

}

在这里,我们需要注意之前使用受检异常和使用非受检异常的例子之间的一个关键区别。对于非受检异常,流的处理将继续到下一个元素,而对于受检异常,处理将结束,并且不会处理更多的元素。显然,我们想要哪种行为取决于我们的用例。

处理受检异常的替代方法

将受检异常包装为非受检异常

我们可以通过将受检异常包装为非受检异常来避免在调用堆栈中的所有方法中添加throws子句。而不是让我们的方法抛出一个受检异常:

public void myMethod() throws IOException{}

我们可以将其包装在一个非受检异常中:

public void myMethod(){

 try {

  // some logic

 } catch(IOException e) {

  throw new MyUnchckedException("A problem occurred", e);

 }

}

理想情况下,我们应用异常链。这样可以确保原始异常不会被隐藏。我们可以在第5行看到异常链的应用,原始异常作为参数传递给新的异常。这种技术在早期版本的Java中几乎适用于所有核心Java异常。

异常链是许多流行框架(如Spring或Hibernate)中常见的一种方法。这两个框架从受检异常转向非受检异常,并将不属于框架的受检异常包装在自己的运行时异常中。一个很好的例子是Spring的JDBC模板,它将所有与JDBC相关的异常转换为Spring框架的非受检异常。

Lombok @SneakyThrows

Project Lombok为我们提供了一个注解,可以消除异常链的需要。而不是在我们的方法中添加throws子句:

public void beSneaky() throws MalformedURLException {

 URL url = new URL("http://test.example.org");

}

我们可以添加@SneakyThrows 注解,这样我们的代码就可以编译通过:

@SneakyThrows

public void beSneaky() {

 URL url = new URL("http://test.example.org");

}

然而,重要的是要理解,@SneakyThrows并不会使MalformedURLException的行为完全像运行时异常一样。我们将无法再捕获它,并且以下代码将无法编译:

public void callSneaky() {

 try {

  beSneaky();

 } catch (MalformedURLException e) {

  // handle exception

 }

}

由于@SneakyThrows移除了异常,而MalformedURLException仍然被视为受检异常,因此我们将在第4行得到编译器错误:

Exception 'java.net.MalformedURLException' is never thrown in the corresponding try block

性能

在我的研究过程中,我遇到了一些关于异常性能的讨论。在受检异常和非受检异常之间是否存在性能差异?实际上,它们之间没有性能差异。这是一个在编译时决定的特性。

然而,是否在异常中包含完整的堆栈跟踪会导致显着的性能差异:

public class MyException extends RuntimeException {

 public MyException(String message, boolean includeStacktrace) {

  super(message, null, !includeStacktrace, includeStacktrace);

 }

}

在这里,我们在自定义异常的构造函数中添加了一个标志。该标志指定是否要包含完整的堆栈跟踪。在抛出异常的情况下,构建堆栈跟踪会导致程序变慢。因此,如果性能至关重要,则应排除堆栈跟踪。

一些指南

如何处理软件中的异常是我们工作的一部分,它高度依赖于具体的用例。在我们结束讨论之前,这里有三个高级指南,我相信它们(几乎)总是正确的。

  • 如果不是编程错误,或者程序可以执行一些有用的恢复操作,请使用受检异常。
  • 如果是编程错误,或者程序无法进行任何恢复操作,请使用运行时异常。
  • 避免空的catch块。

结论

本文深入探讨了Java中的异常。我们讲了为什么要引入异常到语言中,何时应该使用受检异常和非受检异常。我们还讨论了受检异常的缺点以及为什么它们现在被认为是不良实践 – 尽管也有一些例外情况。


【注】本文译自: Don’t Use Checked Exceptions (reflectoring.io)