微服務框架Spring Cloud介紹 Part4: 使用Eureka, Ribbon, Feign實現REST服務客戶端

原文地址:http://skaka.me/blog/2016/08/25/springcloud4/

上一篇文章中我們開發了一個用戶註冊服務. 這篇文章我將介紹如何開發mysteam訂單服務中的下單功能, 下單功能會涉及服務之間的交互與事件的處理, 並且我會對開發過程中用到的框架和類庫進行簡單地講解. 開始寫代碼之前, 我們先來看看下單的處理流程: 

其中1,2,3,4,11步的黑色箭頭代表是同步操作, 5,6,7,8,9,10步是異步操作. 下單接口接收要訂購的產品ID, 數量和要使用的優惠券ID, 然後調用產品服務的接口查詢產品信息, 調用優惠券接口校驗優惠券是否有效, 以及調用賬戶接口判斷賬戶金額是否足夠(mysteam是一個虛擬物品商城, 採用先充值後購買的形式). 如果這些校驗都成功, 訂單服務會發送賬戶扣款事件和優惠券使用事件到MQ, 賬戶服務和優惠券服務會從MQ讀取事件進行處理, 如果處理成功, 訂單服務將能接收到結果, 並且將訂單狀態置爲下單成功, 如果處理失敗或超時, 訂單狀態會被置爲下單失敗.

1. 實現Model

流程清楚了, 現在我們來看代碼. 訂單類是$YOUR_PATH/mysteam/order/core/src/main/java/com/akkafun/order/domain/Order.java, 訂單類和前面的用戶類類似, 其中有兩個字段需要注意一下:

1
2
3
4
5
6
@Column
@Enumerated(value = EnumType.STRING)
private OrderStatus status;

@OneToMany(fetch = FetchType.LAZY, mappedBy = "order")
private List<OrderItem> orderItemList = new ArrayList<>(0);

OrderStatus是一個枚舉, 表示訂單狀態. OrderItem是訂單項.

2. 實現DAO

DAO層基本沒有實際的代碼, 就不貼了.

3. 實現Service

下單的業務邏輯都在service內, 打開$YOUR_PATH/mysteam/order/core/src/main/java/com/akkafun/order/service/OrderService.java, 找到placeOrder方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
 * 下訂單
 *
 * @param placeOrderDto
 * @return
 */
@Transactional
public Order placeOrder(PlaceOrderDto placeOrderDto) {

    //...

    //#1
    //查詢產品信息
    List<Long> productIds = placeOrderDto.getPlaceOrderItemList().stream()
            .map(PlaceOrderItemDto::getProductId)
            .collect(Collectors.toList());

    List<ProductDto> productDtoList = productGateway.findProducts(productIds);

    //...

    //#2
    //查詢優惠券信息
    List<OrderCoupon> orderCouponList = new ArrayList<>();
    Set<Long> couponIdSet = new HashSet<>(placeOrderDto.getCouponIdList());
    if (!couponIdSet.isEmpty()) {

        //#2
        List<CouponDto> couponDtoList = couponGateway.findCoupons(new ArrayList<>(couponIdSet));

        orderCouponList = couponDtoList.stream().map(couponDto -> {
            OrderCoupon orderCoupon = new OrderCoupon();
            orderCoupon.setCouponAmount(couponDto.getAmount());
            orderCoupon.setCouponCode(couponDto.getCode());
            orderCoupon.setCouponId(couponDto.getId());
            return orderCoupon;
        }).collect(Collectors.toList());

    }

    //#3
    //計算訂單金額
    long couponAmount = orderCouponList.stream().mapToLong(OrderCoupon::getCouponAmount).sum();
    order.setPayAmount(order.calcPayAmount(order.getTotalAmount(), couponAmount));

    //檢驗賬戶餘額是否足夠
    if (order.getPayAmount() > 0L) {

        boolean balanceEnough = accountGateway.isBalanceEnough(placeOrderDto.getUserId(), order.getPayAmount());
        if(!balanceEnough) {
            throw new AppBusinessException(CommonErrorCode.BAD_REQUEST, "下單失敗, 賬戶餘額不足");
        }
    }

    //...

    //#4
    eventBus.ask(
            AskParameterBuilder.askOptional(askReduceBalance, askUseCoupon)
                    .callbackClass(OrderCreateCallback.class)
                    .addParam("orderId", String.valueOf(order.getId()))
                    .build()
    );

    return order;
}

代碼略長, 我略過了其中一部分代碼. placeOrder方法主要做的事情有:
1.根據訂購的產品ID, 向產品服務查詢產品信息, 並計算訂單金額.
2.如果有使用優惠券, 向優惠券服務查詢優惠券是否有效(請求REST接口).
3.根據訂單金額, 向賬戶服務查詢用戶餘額是否足夠(請求REST接口).
4.如果上述步驟都成功完成, 發送賬戶扣款事件以及優惠券使用事件(如果有優惠券), 並註冊回調方法等待事件結果.
如果事件處理成功會調用markCreateSuccess方法, 處理失敗會調用markCreateFailmarkCreateSuccess方法的代碼如下:

1
2
3
4
5
6
7
@Transactional
public void markCreateSuccess(Long orderId) {
    Order order = checkOrderBeforeMarkSuccessOrFail(orderId);
    order.setStatus(OrderStatus.CREATED);

    orderRepository.save(order);
}

這個方法只是將訂單狀態置爲下單成功, 流程就完成了. markCreateFail處理過程類似, 只不過是將訂單狀態改爲下單失敗.

看到這裏, 先不去管服務調用的實現細節, 細心的你可能會產生一些疑問:
1.第4步爲什麼要使用事件的形式去扣款和處理優惠券, 不能和前面的查詢操作一樣使用REST接口來處理嗎?
2.爲什麼要先查詢用戶餘額是否足夠, 再發送扣款事件, 直接發送扣款事件不好嗎? 如果查詢餘額返回成功之後, 其他業務修改了餘額, 處理扣款事件的時候餘額不足怎麼辦?
3.第4步同時發送了扣款事件和優惠券使用事件, 如果扣款成功了, 但是優惠券使用失敗了怎麼處理?

其實這些問題都指向同一個問題域: 分佈式事務. 分佈式事務是開發微服務首先要解決的問題. 分佈式事務是一個很大的話題, 這裏我只簡單介紹一下eBay的Dan Pritchard提出的BASE原則:
基本可用(Basically Available)
軟狀態(Soft state)
最終一致(Eventually consistent)

BASE其實和傳統數據庫的ACID是兩個不同的思想, 以我們上面的訂單系統爲例. 訂單服務向賬戶服務發送扣款事件, 賬戶服務接收到事件並且處理成功, 但是還沒有將處理結果發送到訂單服務, 這時候系統數據就處於短暫的不一致狀態: 用戶的賬戶餘額已經被扣減掉了, 但是訂單狀態還是正在下單. 過了一段時間, 訂單服務獲取到扣款事件的處理結果並且將訂單狀態置爲下單成功. 這個時候系統才達到最終一致的狀態. 這種事務處理方法並不是適用於所有業務, 如果需要強一致性, 還是得使用2PC或者3PC來完成.

瞭解了mysteam的事務處理原則, 我們回頭看看剛纔提出的3個問題:
1.mysteam是使用事件的方式來進行事務處理的, REST接口一般只用來實現查詢或者其他不需要事務的操作. 所以只要涉及到數據修改, 一般都通過事件來完成.
2.發送扣款之前先查詢餘額是爲了減少不必要的事件操作, 因爲如果事件處理失敗會涉及到事件撤銷, 是比較耗時的操作, 先進行餘額查詢, 餘額不足直接流程就中止了. 根據我們的經驗, 一般來說查詢餘額成功後續扣款失敗的機率比較小, 所以收益大於付出. 3.這涉及到事件的撤銷處理. 在mysteam的訂單服務中, 如果接收到了扣款成功和優惠券使用失敗這兩個事件結果, 訂單服務會啓動事件撤銷流程, 向賬戶服務發送扣款撤銷事件, 並且將訂單狀態置爲下單失敗.

綜上, mysteam的事務處理遵循BASE, 實現方式是使用事件. 關於事務的其他細節以及事件如何實現, 我後面會用單獨的文章來介紹. 這裏我們先回到本篇的主題, 如何調用REST接口. 在這裏, 我先簡單介紹一下Eureka, Ribbon和Feign這三個組件.
Eureka: 服務註冊中心. 我們的REST服務在啓動的時候會將自己的地址註冊到Eureka, 其他需要該服務的應用會請求Eureka進行服務尋址, 得到目標服務的ip地址之後就會使用該地址直連目標服務.
Ribbon: 客戶端負載均衡類庫. 當客戶端請求的目標服務存在多個實例時, Ribbon會將請求分散到各個實例. 一般會結合Eureka一起使用.
Feign: HTTP客戶端類庫. 我們使用Feign提供的註解編寫HTTP接口的客戶端代碼非常簡單, 只需要聲明一個Java接口加上少量註解就完成了.

接下來我們看代碼實例. 以賬戶服務的接口爲例, 之前我們在placeOrder方法內查詢賬戶餘額的代碼如下:

1
boolean balanceEnough = accountGateway.isBalanceEnough(placeOrderDto.getUserId(), order.getPayAmount());

打開$YOUR_PATH/mysteam/order/core/src/main/java/com/akkafun/order/service/gateway/AccountGateway.java, 代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class AccountGateway {

    protected Logger logger = LoggerFactory.getLogger(AccountGateway.class);

    @Autowired
    AccountClient accountClient;

    @HystrixCommand(ignoreExceptions = RemoteCallException.class)
    public boolean isBalanceEnough(Long userId, Long amount) {
        return accountClient.checkEnoughBalance(userId, amount).isSuccess();
    }

}

AccountClient是一個加了Feign註解的接口:

1
2
3
4
5
6
7
@FeignClient(AccountUrl.SERVICE_HOSTNAME)
public interface AccountClient {

    @RequestMapping(method = RequestMethod.GET, value = AccountUrl.CHECK_ENOUGH_BALANCE)
    BooleanWrapper checkEnoughBalance(@PathVariable("userId") Long userId, @RequestParam("balance") Long balance);

}

@FeignClient註解需要聲明一個service id, 這個service id就是我們在YAML配置文件中配的spring.application.name的值, 比如account.yml中的spring.application.name值是account. 我們請求的REST接口需要一個url路徑參數userId, 以及一個查詢參數balance. 我們在代碼中不需要直接調用Ribbon的代碼, Feign會幫我們處理好一切. 根據我們的AccountClient接口聲明, Feign會在Spring容器啓動之後, 將生成的代理類注入AccountGateway, 所以我們不需要寫HTTP調用的實現代碼就能完成REST接口的調用.

到這裏下單的邏輯就完成了. 我們知道在分佈式環境下, 服務之間的依賴都是脆弱而且不穩定的, 極有可能因爲一個服務實例的延遲或宕機造成所有服務不可用. 所以mysteam中引入了hystrix. 細心的同學可能已經在AccountGateway中發現@HystrixCommand註解了, 下篇文章我將介紹hystrix的基本用法, 以及如何使用hystrix board和turbine來監控hystrix服務.


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章