來自jackson的靈魂一擊:@ControllerAdvice就能保證萬無一失嗎?

是奉壹呀發表於2023-04-03

前幾天寫了篇關於fastjson的文章,《fastjson很好,但不適合我》。裡面探討到關於物件迴圈引用的序列化問題。作為spring序列化的最大競品,在討論fastjson的時候肯定要對比一下jackson的。所以我也去測試了一下Jackson在物件迴圈引用的序列化的功用,然後有了一點意外的小發現,在這裡跟大家討論一下。


首先還得解釋一下,jackson的序列化是怎麼跟@ControllerAdvice關聯上的呢?
前篇文章裡說過,對於物件迴圈引用的序列化問題,fastjson和jackson分別採取了兩種態度,fastjson是預設處理了,而jackson是預設丟擲異常。後者把主動權交給了使用者。
既然這裡丟擲了異常,就涉及到異常的全域性處理,跟事務一樣,我們不可能以硬編碼的方式在每個方法裡分別處理異常,而是透過統一全域性異常處理。


@ControllerAdvice 全域性異常捕獲

這裡簡單的做一下介紹,嫌棄囉嗦的朋友可直接略過,跳到第2部份。

Spring家族中,透過註解@ControllerAdvice或者 @RestControllerAdvice 即可開啟全域性異常處理,使用該註解表示開啟了全域性異常的捕獲,我們只需在自定義一個方法使用@ExceptionHandler註解然後定義捕獲異常的型別即可對這些捕獲的異常進行統一的處理。

只要異常最終能夠到達controller層,且與@ExceptionHandler定義異常型別相匹配,就能被捕獲。

@RestControllerAdvice
public class GlobalExceptionHandler {

    Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(value = Exception.class)
    public Result exceptionHandler(Exception e){
        logger.error(e.getMessage(), e);
        return Result.error(e.getMessage());
    }

    @ExceptionHandler(value = RuntimeException.class)
    public Result exceptionHandlerRuntimeException(Exception e){
        logger.error(e.getMessage(), e);
        return Result.error(e.getMessage());
    }

    // 或者其它自定義異常
}

再定義一個統一的介面返回物件:

點選檢視程式碼
public class Result<T> implements Serializable {
    private String code;
    private Boolean success;
    private T data;
    private String msg;

    public Result(String code, Boolean success, String msg) {
        this.code = code;
        this.success = success;
        this.msg = msg;
    }

    public Result(String code, String msg, T data) {
        this.code = code;
        this.data = data;
        this.msg = msg;
    }

    public Result() {
        this.code = ReturnCodeEnum.OK.getCode();
        this.success = true;
        this.msg = ReturnCodeEnum.OK.getMsg();
    }

    public void serverFailed() {
        this.serverFailed((Exception)null);
    }

    public void serverFailed(Exception e) {
        this.code = ReturnCodeEnum.SERVER_FAILED.getCode();
        this.success = false;
        if (e == null) {
            this.msg = ReturnCodeEnum.SERVER_FAILED.getMsg();
        } else {
            this.msg = e.getMessage();
        }

    }

    public static <T> Result<T> success(T data) {
        Result<T> success = new Result();
        success.setData(data);
        return success;
    }

    public static <T> Result<T> success() {
        return new Result();
    }

    public static <T> Result<T> error() {
        return new Result(ReturnCodeEnum.SERVER_FAILED.getCode(), false, ReturnCodeEnum.SERVER_FAILED.getMsg());
    }

    public static <T> Result<T> error(String message) {
        return new Result(ReturnCodeEnum.SERVER_FAILED.getCode(), false, message);
    }

    public static <T> Result<T> error(String code, String message) {
        return new Result(code, false, message);
    }

    public void resetWithoutData(Result result) {
        this.success = result.getSuccess();
        this.code = result.getCode();
        this.msg = result.getMsg();
    }

    public void resetResult(ReturnCodeEnum returnCodeEnum, boolean isSuccess) {
        this.code = returnCodeEnum.getCode();
        this.success = isSuccess;
        this.msg = returnCodeEnum.getMsg();
    }

    public static <T> Result<T> error(ReturnCodeEnum returnCodeEnum) {
        Result<T> error = new Result();
        error.code = returnCodeEnum.getCode();
        error.success = false;
        error.msg = returnCodeEnum.getMsg();
        return error;
    }

    public String getCode() {
        return this.code;
    }

    public Boolean getSuccess() {
        return this.success;
    }

    public T getData() {
        return this.data;
    }

    public String getMsg() {
        return this.msg;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public void setSuccess(Boolean success) {
        this.success = success;
    }

    public void setData(T data) {
        this.data = data;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public boolean equals(Object o) {
        if (o == this) {
            return true;
        } else if (!(o instanceof Result)) {
            return false;
        } else {
            Result<?> other = (Result)o;
            if (!other.canEqual(this)) {
                return false;
            } else {
                label59: {
                    Object this$code = this.getCode();
                    Object other$code = other.getCode();
                    if (this$code == null) {
                        if (other$code == null) {
                            break label59;
                        }
                    } else if (this$code.equals(other$code)) {
                        break label59;
                    }

                    return false;
                }

                Object this$success = this.getSuccess();
                Object other$success = other.getSuccess();
                if (this$success == null) {
                    if (other$success != null) {
                        return false;
                    }
                } else if (!this$success.equals(other$success)) {
                    return false;
                }

                Object this$data = this.getData();
                Object other$data = other.getData();
                if (this$data == null) {
                    if (other$data != null) {
                        return false;
                    }
                } else if (!this$data.equals(other$data)) {
                    return false;
                }

                Object this$msg = this.getMsg();
                Object other$msg = other.getMsg();
                if (this$msg == null) {
                    if (other$msg != null) {
                        return false;
                    }
                } else if (!this$msg.equals(other$msg)) {
                    return false;
                }

                return true;
            }
        }
    }

    protected boolean canEqual(Object other) {
        return other instanceof Result;
    }

    public int hashCode() {
        int PRIME = true;
        int result = 1;
        Object $code = this.getCode();
        int result = result * 59 + ($code == null ? 43 : $code.hashCode());
        Object $success = this.getSuccess();
        result = result * 59 + ($success == null ? 43 : $success.hashCode());
        Object $data = this.getData();
        result = result * 59 + ($data == null ? 43 : $data.hashCode());
        Object $msg = this.getMsg();
        result = result * 59 + ($msg == null ? 43 : $msg.hashCode());
        return result;
    }

    public String toString() {
        return "Result(code=" + this.getCode() + ", success=" + this.getSuccess() + ", data=" + this.getData() + ", msg=" + this.getMsg() + ")";
    }

    public Result(String code, Boolean success, T data, String msg) {
        this.code = code;
        this.success = success;
        this.data = data;
        this.msg = msg;
    }

統一狀態碼:

點選檢視程式碼
public enum ReturnCodeEnum {
    OK("200", "success"),
    OPERATION_FAILED("202", "操作失敗"),
    PARAMETER_ERROR("203", "引數錯誤"),
    UNIMPLEMENTED_INTERFACE_ERROR("204", "未實現的介面"),
    INTERNAL_SYSTEM_ERROR("205", "系統內部錯誤"),
    THIRD_PARTY_INTERFACE_ERROR("206", "第三方介面錯誤"),
    CRS_TOKEN_INVALID("401", "token無效"),
    PERMISSIONS_ERROR("402", "業務許可權認證失敗"),
    AUTHENTICATION_FAILED("403", "登陸超時,請重新登陸"),
    SERVER_FAILED("500", "server failed 500 !!!"),
    DATA_ERROR("10001", "資料獲取失敗"),
    UPDATE_ERROR("10002", "操作失敗"),
    SIGN_ERROR("10010", "簽名錯誤"),
    ACCOUNT_OR_PASSWORD_ERROR("4011", "使用者名稱或密碼錯誤"),
    ILLEGAL_PERMISSION("405", "許可權不足"),
    FORBIDDON("410", "已被禁止"),
    TOKEN_TIME_OUT("4012", "session過期,需重新登入");

    private String code;
    private String msg;

    public String getCode() {
        return this.code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getMsg() {
        return this.msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    private ReturnCodeEnum(String code, String msg) {
        this.code = code;
        this.msg = msg;
    }
}

再定義一個測試物件:

@Getter
@Setter
//@ToString
//@AllArgsConstructor
//@NoArgsConstructor
public class Person {
    private String name;
    private Integer age;
    private Person father;

    public Person(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
}

寫一個測試介面,模擬迴圈依賴的物件,使用fastjson進行序列化返回。

public Result test2 (){
        List<Person> list = new ArrayList<>();
        Person obj1 = new Person("張三", 48);
        Person obj2 = new Person("李四", 23);
        obj1.setFather(obj2);
        obj2.setFather(obj1);

        list.add(obj1);
        list.add(obj2);

        Person obj3 = new Person("王麻子", 17);
        list.add(obj3);

        List<Person> young = list.stream().filter(e -> e.getAge() <= 45).collect(Collectors.toList());
        List<Person> children =  list.stream().filter(e -> e.getAge()< 18).collect(Collectors.toList());

        HashMap map = new HashMap();
        map.put("young", young);
        map.put("children", children);
        return Result.success(map);
    }

開啟fastjson的SerializerFeature.DisableCircularReferenceDetect禁用迴圈依賴檢測,使其丟擲異常。
訪問測試介面,後臺列印日誌

ERROR 21360 [http-nio-8657-exec-1] [com.nyp.test.config.GlobalExceptionHandler] : Handler dispatch failed; nested exception is java.lang.StackOverflowError

介面返回

{
	"code":"500",
	"data":null,
	"msg":"Handler dispatch failed; nested exception is java.lang.StackOverflowError",
	"success":false
}

證明異常在全域性異常捕獲處被成功捕獲。且返回了500狀態碼,證明服務端出現了異常。

jackson的問題

我們現在換掉fastjson,使用springboot自帶的jackson進行序列化。同樣還是上面的程式碼。
後臺列印了日誌:

[2023-04-01 15:27:42.230] ERROR 17156 [http-nio-8657-exec-2] [com.nyp.test.config.GlobalExceptionHandler] : Could not write JSON: Infinite recursion (StackOverflowError); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError) (through reference chain: com.nyp.test.model.Person["father"]->com.nyp.test.model.Person["father"]....

日誌資訊略有不同,是兩種不同的序列化框架的差異,總之全域性異常捕獲也成功了。

再來看返回的結果如下:

這就很明顯不對勁,後臺已經丟擲異常,併成功捕獲了異常,前端怎麼還接收到了200狀態碼呢?而且 data裡面還有迴圈巢狀的資料!

返回的報文很長,仔細觀察最後面,發現後面同時也返回了500狀態碼及異常資訊。


長話短說,相當使用jackson,在預設情況下,對於迴圈物件引用,在新增了全域性異常處理情況下,介面同時返回了兩段相反的報文:

{
	"code":"200",
	"data":{"young":[{"name":"李四","age":23,"father":{"name":"張三","age":48}]}"
	"success":true
}
{
	"code":"500",
	"data":null,
	"msg":"Handler dispatch failed; nested exception is java.lang.StackOverflowError",
	"success":false
}

小朋友你是否有很多問號??

這種現象是在return後面丟擲異常引起?

這就有點意思了。
造成這種現象的原因,我初步懷疑是在方法return返回過後再丟擲異常導致的。

我這懷疑也不是毫無理由,具體請看我的另一篇文章 當transcational遇上synchronized ,裡面提到過,
spring使用動態代理加AOP實現事務管理。那麼一個加了註解事務的方法實際上需要簡化成至少3個步驟:

void begin();

@Transactional
public synchronized void test(){
    // 
}

void commit();
// void rollback();

如果在讀已提交及以上的事務隔離級別下,test方法執行完畢,更新了資料但這時候還沒到commit事務,但已經釋放了鎖,另一個事務進來讀到的還是舊資料。

類似地,這裡的test方法實際上是一樣的,jackson在做序列化操作在return之前,那麼會不會return返回了一次200,在return過後再丟擲異常後再返回了一次500狀態碼?

那就使用TransactionSynchronization模擬一次在return後面的異常看返回給前端什麼資訊。

@Transactional
    @RequestMapping( "/clone")
    public Result test2 (){
        List<Person> list = new ArrayList<>();
        Person obj1 = new Person("張三", 48);
        Person obj2 = new Person("李四", 23);
        obj1.setFather(obj2);
        obj2.setFather(obj1);

        list.add(obj1);
        list.add(obj2);

        Person obj3 = new Person("王麻子", 17);
        list.add(obj3);

        List<Person> young = list.stream().filter(e -> e.getAge() <= 45).collect(Collectors.toList());
        List<Person> children =  list.stream().filter(e -> e.getAge()< 18).collect(Collectors.toList());

        HashMap map = new HashMap();
        map.put("young", young);
        map.put("children", children);

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                if (1 == 1) {
                    throw new HttpMessageNotWritableException("test exception after return");
                }
                TransactionSynchronization.super.afterCommit();
            }
        });
        return Result.success(map);
    }

重啟呼叫測試介面,後臺列印日誌

[http-nio-8657-exec-1] [com.nyp.test.config.GlobalExceptionHandler] : test exception after return

返回客戶端資訊:

{"code":"500","success":false,"data":null,"msg":"test exception after return"}

測試表明,並不是這個原因造成的。


到這裡,可能細心的朋友也發現了,對於前面的猜想,關於jackson在做序列化操作在return之前,那麼會不會return返回了一次200,在return過後再丟擲異常後再返回了一次500狀態碼?其實是不合理的。
我們在最開始接觸java web開發的時候肯定是先學servlet,再學spring,springmvc,springboot這些框架,現在再回到最初的美好,想想servlet是怎樣返回資料給客戶端的?

透過HttpServletResponse獲取一個輸出流,不管是OutputStream還是PrintWriter,將我們手動序列化的json串輸出到客戶端。

@WebServlet(urlPatterns = "/testServlet")
public class TestServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
        // 透過PrintWriter 或者 OutputStream 建立一個輸出流
        // OutputStream outputStream = response.getOutputStream();
        PrintWriter out = response.getWriter();
        try {
            // 模擬獲取一個返回物件
            Person person = new Person("張三", 23);
            out.println("start!");
            // 手動序列化,並輸出到客戶端
            Gson gson = new Gson();
            out.println(Result.success(gson.toJson(person)));
            // outputStream.write();
            out.println("end");
        } finally {
            out.println("成功!");
            out.close();
        }
        super.doGet(request, response);
    }
}

我沒看過springmvc這塊的原始碼,想來也是同樣的邏輯處理對吧。
在dispatchServlet裡面invoke完畢目標controller獲得了返回物件以後,再呼叫序列化框架jackson或者fastjson得到一個json物件,再透過輸出流輸出前端,最後一步操作可能是在servlet裡也可能直接在序列化框架裡面直接操作。
總之不管是在哪步,都有點不合理,如果是在序列化的時候,序列化框架直接異常了,也不應該輸出200和500兩段報文。


不管怎樣,這裡也算是驗證了@ControolerAdvice能不能捕獲目標controller方法在Return以後丟擲的異常,答案是可以。

現在我們可以再來看看Fastjson在return以後進行序列化發生異常的時候,為什麼不會輸出200和500兩段報文。


fastjson為什麼沒有問題


透過前文我們知道,在同樣的情況下,fastjson序列化是可以正常返回給客戶端500異常的報文。

我們現在將springmvc的序列化框架切換到fastjson。透過斷點走一遍原始碼。觀察為什麼fastjson可以正常丟擲異常。

透過呼叫棧資訊,我們可以很明顯的觀察到我們很熟悉的distpatchServlet,再到handleReturnValue呼叫完成目標controller拿到返回物件,現到AbstractMessageConverterMethodProcessor.writeWithMessageConverters,最終到達GenericHttpMessageConverter.write()透過註釋,哪怕是方法名和引數名,我們也知道這裡就是開始呼叫具體的序列化框架重寫這個方法輸出返回報文到客戶端了。

那麼在這裡開始打個斷點,這是個介面方法,它有很多實現類,這裡打斷點會直接進入到具體實現類的方法。
最終來到了FastJsonHttpMessageConverter.writeInternal()

重點來了,如上圖所示,執行到line 314行,也就是標記為1的地方就丟擲異常,然後到了finally裡面去了,跳過了line 337即2處真正執行write輸出到客戶端的操作
我們不用去管line 314處所呼叫方法內部的序列化具體操作,我們只需要知道,它在序列化準備階段直接異常了,並沒有真正執行向客戶端進行write的操作。

然後異常最終被@RestControllerAdvice所捕獲,輸出到客戶端500。


jackson的輸出流程


現在作為對比,再回過頭來看看jackson是怎樣完成上述的操作的。


打到與上小節fastjson一樣的斷點,最終進入了jackson的序列化方法,透過右邊inline watches可以看到將要被序列化的value從物件的迴圈引用變成了具體的若干層巢狀迴圈了。

再一路斷點,來到UTF8JsonGenerator,可以觀察到,jackson不是將整個返回值value一起進行序列化,而是一個物件一個field順序進行序列化。

這些值將臨時進入了一個buffer緩衝區,在大於outputend=8000,就flush直接輸出到客戶端。

這裡的_outputstream就是java.io.OutputStream物件。


小結

這裡可以做一個小結了。

jackson為什麼會在物件迴圈引用的時候同時向客戶端輸出200和500兩段報文?

因為jackson的序列化是分階段進行的,它使用了一種類似於fail-safe機制,延遲到後面再失敗,而在失敗之前,已經將200狀態碼的報文輸出到客戶端。

fastjson為什麼能正常的只輸出500報文?

因為Fastjson的序列化有一種fail-fast機制,它判斷到有物件迴圈引用時可以直接丟擲異常,然後被全域性異常處理,最終只會向客戶端輸出500狀態碼報文。

@ControllerAdvice失效的場景

透過註釋,我們知道@ControllerAdvice預設作用於全部的controller類方法。也可以手動設定package.

@RestControllerAdvice("com.nyp.test.controller")
或者
@RestControllerAdvice(basePackages = "com.nyp.test.controller")

那麼讓它失效的場景就是
1.異常到不了controller層,比如在service層裡透過try-catch把異常吞了。又比如到達了controller層也丟擲了,但在其它AOP切面通知裡透過try-catch處理了。
2.或者不指向controller層或部份controller層,比如透過@RestControllerAdvice(basePackages = "com.nyp.test.else")

等等。

其它只要不觸碰到以上情況,正確的配置了,即使是在return後面丟擲異常也可以正確處理。
具體到本文jackson的這種情況,嚴格意義上來講,@ControllerAdvice也是起了作用的。只不過是jackson在序列化的過程中本身出的問題。

總結

  1. @ControllerAdvice完全安全嗎?
    只要正確配置,它是完全安全的。本文屬於jackson這種特殊情況,它造成的異常情況不是@ControllerAdvice的問題。

2.造成同時返回200和500報文的原因是什麼?

因為jackson的序列化是分階段進行的,它使用了一種類似於fail-safe機制,延遲到後面再失敗,而在失敗之前,將200狀態碼的報文輸出到客戶端,失敗之後,又將500狀態碼的報文輸出到客戶端。
而Fastjson的序列化因為有一種fail-fast機制,它判斷到有物件迴圈引用時可以直接丟擲異常,然後被全域性異常處理,最終只會向客戶端輸出500狀態碼報文。


3. 怎麼解決這種問題?

這本質上是一個jackson迴圈依賴的問題。透過註解
@JsonBackReference
@JsonManagedReference
@JsonIgnore
@JsonIdentityInfo
可以部份解決。


比如:

@JsonIdentityInfo(generator= ObjectIdGenerators.IntSequenceGenerator.class, property="name")
private Person father;

返回:

{
	"code": "200",
	"success": true,
	"data": {
		"young": [{
			"name": "李四",
			"age": 23,
			"father": {
				"name": 1,
				"name": "張三",
				"age": 48,
				"father": {
					"name": 2,
					"name": "李四",
					"age": 23,
					"father": 1
				}
			}
		}, {
			"name": "王麻子",
			"age": 17,
			"father": null
		}],
		"children": [{
			"name": "王麻子",
			"age": 17,
			"father": null
		}]
	},
	"msg": "success"
}

同時,對於物件迴圈引用這種情況,在程式碼中就應該儘量去避免。
就像spring處理依賴注入的情況,一開始使用@lazy註解解決,後面spring官方透過三層快取來解決,再到後面springboot官方預設不支援依賴注入,如果有依賴注入預設啟動就會報錯。


一言以蔽之,本文說的是,關於spring mvc&spring boot使用jackson做序列化輸出的時候,如果沒有處理好迴圈依賴的問題,那麼前端不能正確感知到伺服器異常這個問題。

但是迴圈依賴並不常見,遇到了也能有解決方案,所以看起來本文好像並沒有什麼卵用。

不過,沒人規定必須要解決吧,當我還是一個新手的時候,我沒解決迴圈依賴,而同時前端又沒有接收到正確的服務端異常時,總是會有疑惑的。

而且如果擴充套件開來的話,jackson在序列化中途導致失敗,都有可能發生這種情況。

從這個角度來說,算不算是jackson的一個問題呢?

不管怎樣,希望本文對你能夠有所啟發。

相關文章