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

前幾天寫了篇關於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的一個問題呢?

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

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