JHipster 中的設計(1)RESTful API Response 與異常處理的設計

一、 Response 設計

在JHipster生成的項目中,RESTful API的Response相比一些傳統的方式,特別的依賴了Response.header來傳輸一些附加信息,比如分頁請求結果中的總數、執行的方法代碼等。下面以用戶相關接口爲例:

name method uri body
Get User GET /users/{userId}
List Users GET /users?page={page}
Create User POST /users user info
Update User PUT /users user info
Delete User DELETE /users/{userId}

響應成功

根據以上示例,請求成功的Response如下:

1. Get user,將用戶對象直接放入Response Body

// GET /users/{userId}
Response {
    "status": 200,
    "body": {
        // user info
    }
}

2. List Users,將用戶列表數據放入Response Body,並將總的用戶數放入Header中:

// GET /users?page={page}
Response {
    "status": 200,
    "header": {
         X-Total-Count: 40
        // more info
    },
    "body": {
        // user list data
    }
}

3. Create User,將新創建的用戶對象放入Response Body,並將狀態碼更改爲201:

// POST /users, body: user info
Response {
    "status": 201,
    "body": {
        // user info
    }
}

4. Update User,與Create User類似。

// PUT /users, body: user info
Response {
    "status": 200,
    "body": {
        // user info
    }
}

5. Delete User,無Body,狀態碼爲200則意味着刪除成功。

// DELETE /users/{userId}
Response {
    "status": 200,
    "body": null
}

注:除了以上有說明的header信息之外,JHipster可能還會將其他一些額外的信息放入header中,比如:X-Application-ContextX-Content-Type-Options等。

通過以上示例可以看出,JHipster 將一些附加的信息放入了Response.header中,比如用戶列表總數。而如果是將返回結果統一封裝,並將主要結果放入Response.body.data屬性中,附加信息放入Response.body中,那麼則必須考慮如果只存在單個對象時如何處理,比如可能產生的如下兩種response結構:

// GET /users/{userId}
Response {
    "status": 200,
    "body": {
        "data": {
            // user info
        }
    }
}
// GET /users?page={page}
Response {
    "status": 200,
    "body": {
        "data": {
            // user list data
        },
        "total": 40
    }
}

當然,使用JHipster的方式,可能讓人忽視Response.header中的附加信息,因爲不管是開發人員還是譬如Postman這樣的接口測試工具,都更關注的是Response.body中的內容。

響應異常

那麼如果是發生異常,不管是客戶端請求錯誤還是服務器異常,返回的都是統一的數據結構,JHipster遵從RFC7807,其數據結構示例如下:

Response {
    "status": 400
    "body": {
        "entityName" : "userManagement",
        "errorKey" : "userexists",
        "type" : "http://www.JHipster.tech/problem/login-already-used",
        "title" : "Login already in use",
        "status" : 400,
        "message" : "error.userexists",
        "params" : "userManagement"
    }

    // 其它信息,不重要
    "X-Application-Context": "ex_5:swagger,dev:8081",
    "X-Content-Type-Options: "nosniff",
    "X-ex5App-error": "error.userexists",
    "X-ex5App-params": "userManagement",
    "X-XSS-Protection": "1; mode=block",
    // ...
}

異常時的錯誤信息結構,根據項目或開發人員的不同會有所不同,但是統一的結構卻是必須的。而在服務端,根據異常的不同需要填充不同的返回信息。實現的方式主要分爲兩種:一種方式是在controller方法中,捕獲異常並將其轉換成友好的響應結果返回;另外一種方式是在controller中拋出異常,由全局的異常捕獲器將異常捕獲,然後再將其轉換成友好的響應結果返回。個人更傾向於第二種方式,當然這種方式的最終實現也有多種,JHipster主要的實現方式如下節。

二、 全局異常處理

在Controller類的方法中,非必要時不捕獲Service層異常和參數效驗後直接拋出異常的方式能夠使方法更簡潔,也能更直觀的體現業務意義。同時統一捕獲異常處理,也有便於修改和維護等優點。

JHipster在實現全局異常處理時,除了使用spring提供的類和方法之外,還使用了problem-spring-web庫。problem-spring-web是一個用於處理Spring Web MVC中問題的庫,它可以很容易地從Spring應用程序生成一個application/problem+json的響應。

更多介紹請參考Github地址:https://github.com/zalando/problem-spring-web

JHipster在實現全局異常處理時,有以下幾個特點:

  • 幾乎所有的自定義異常都是org.zalando.problem.AbstractThrowableProblem的直接或間接子類;
  • 返回的內容都被轉譯爲org.zalando.problem.Problem對象結構數據;
  • 全局異常處理類繼承自org.zalando.problem.spring.web.advice.ProblemHandling,它已經包含了默認的異常處理,所以即使不使用@ExceptionHandler捕獲異常,只需要重載process方法,也能處理異常。

實現

實現一個最簡單的全局異常類,只需要添加@ControllerAdvice註解,並實現org.zalando.problem.spring.web.advice.ProblemHandling接口即可。如果你需要自定義處理,那麼重載process(...)方法即可,示例如下:


@ControllerAdvice
public class ExceptionTranslator implements ProblemHandling {

    @Override
    public ResponseEntity<Problem> process(@Nullable ResponseEntity<Problem> entity, NativeWebRequest request) {
        // do something
    }

}

不過JHipster預先做了一些額外的處理,其代碼如下:

@ControllerAdvice
public class ExceptionTranslator implements ProblemHandling {

    /**
     * Post-process Problem payload to add the message key for front-end if needed
     */
    @Override
    public ResponseEntity<Problem> process(@Nullable ResponseEntity<Problem> entity, NativeWebRequest request) {
        if (entity == null || entity.getBody() == null) {
            return entity;
        }
        Problem problem = entity.getBody();
        if (!(problem instanceof ConstraintViolationProblem || problem instanceof DefaultProblem)) {
            return entity;
        }
        ProblemBuilder builder = Problem.builder()
            .withType(Problem.DEFAULT_TYPE.equals(problem.getType()) ? ErrorConstants.DEFAULT_TYPE : problem.getType())
            .withStatus(problem.getStatus())
            .withTitle(problem.getTitle())
            .with("path", request.getNativeRequest(HttpServletRequest.class).getRequestURI());

        if (problem instanceof ConstraintViolationProblem) {
            builder
                .with("violations", ((ConstraintViolationProblem) problem).getViolations())
                .with("message", ErrorConstants.ERR_VALIDATION);
            return new ResponseEntity<>(builder.build(), entity.getHeaders(), entity.getStatusCode());
        } else {
            builder
                .withCause(((DefaultProblem) problem).getCause())
                .withDetail(problem.getDetail())
                .withInstance(problem.getInstance());
            problem.getParameters().forEach(builder::with);
            if (!problem.getParameters().containsKey("message") && problem.getStatus() != null) {
                builder.with("message", "error.http." + problem.getStatus().getStatusCode());
            }
            return new ResponseEntity<>(builder.build(), entity.getHeaders(), entity.getStatusCode());
        }
    }

    @Override
    public ResponseEntity<Problem> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, @Nonnull NativeWebRequest request) {
        BindingResult result = ex.getBindingResult();
        List<FieldErrorVM> fieldErrors = result.getFieldErrors().stream()
            .map(f -> new FieldErrorVM(f.getObjectName(), f.getField(), f.getCode()))
            .collect(Collectors.toList());

        Problem problem = Problem.builder()
            .withType(ErrorConstants.CONSTRAINT_VIOLATION_TYPE)
            .withTitle("Method argument not valid")
            .withStatus(defaultConstraintViolationStatus())
            .with("message", ErrorConstants.ERR_VALIDATION)
            .with("fieldErrors", fieldErrors)
            .build();
        return create(ex, problem, request);
    }

    @ExceptionHandler(BadRequestAlertException.class)
    public ResponseEntity<Problem> handleBadRequestAlertException(BadRequestAlertException ex, NativeWebRequest request) {
        return create(ex, request, HeaderUtil.createFailureAlert(ex.getEntityName(), ex.getErrorKey(), ex.getMessage()));
    }

    @ExceptionHandler(ConcurrencyFailureException.class)
    public ResponseEntity<Problem> handleConcurrencyFailure(ConcurrencyFailureException ex, NativeWebRequest request) {
        Problem problem = Problem.builder()
            .withStatus(Status.CONFLICT)
            .with("message", ErrorConstants.ERR_CONCURRENCY_FAILURE)
            .build();
        return create(ex, problem, request);
    }
}

源碼分析

在上面的代碼中,ExceptionTranslator類捕獲了BadRequestAlertException異常,並作出了自定義的處理,HeaderUtil.createFailureAlert(...)方法主要是將額外的信息放入Response.header中,其源碼如下:

public static HttpHeaders createFailureAlert(String entityName, String errorKey, String defaultMessage) {
    log.error("Entity processing failed, {}", defaultMessage);
    HttpHeaders headers = new HttpHeaders();
    headers.add("X-ex5App-error", "error." + errorKey);
    headers.add("X-ex5App-params", entityName);
    return headers;
}

兩個參數的意義:

  • X-ex5App-error:表示錯誤的key,通常一類操作使用同一個key,比如用戶相關操作對應的key爲’userManagement’;
  • X-ex5App-params:具體錯誤代號,比如創建用戶時,賬號已經存在對應’error.userexists’。

當然以上參數實際上也會體現在Response.body中:

Response.body {
  "entityName" : "userManagement",
  "errorKey" : "userexists",
  "type" : "http://www.JHipster.tech/problem/login-already-used",
  "title" : "Login already in use",
  "status" : 400,
  "message" : "error.userexists",
  "params" : "userManagement"
}

自定義異常

一個好的異常類,根據其名稱便能快速的理解其業務意義,再封裝一些詳細的錯誤信息,使用起來也更爲便捷。JHipster便這樣做了,以客戶端相關異常爲例,其定義瞭如下結構的一系列異常:

org.zalando.problem.AbstractThrowableProblem
    - BadRequestAlertException
        - EmailAlreadyUsedException
        - EmailNotFoundException
        - LoginAlreadyUsedException

這是個讓人一眼就能看分辨其異常原因的異常定義。同時在最後的具體異常裏封裝了異常原因、狀態碼等信息,便於使用,比如:

public class EmailNotFoundException extends AbstractThrowableProblem {

    public EmailNotFoundException() {
        super(ErrorConstants.EMAIL_NOT_FOUND_TYPE, "Email address not registered", Status.BAD_REQUEST);
    }

}

這樣我們只需要throw new EmailNotFoundException(),便能拋出帶有詳細信息的一個異常。

三、總結

通過對以上的分析以及其它的一些經驗,得出以下幾個可參考的特點或建議:

1. Response 設計

  • 通過將附加信息放入Response.header中,簡化了Response.body的數據結構;
  • 所有的異常都轉譯爲結構統一且對用戶友好的數據返回,用戶不需要關心具體的堆棧信息;
  • 發生異常時,將Response.status設置成4xx或5xx,以表示客戶端或服務端錯誤。這樣也易於前端通過try-cache捕獲異常,而不是對body中status進行判斷來捕獲異常。如果有具體的錯誤代碼,再根據body.status來判斷。

2. 異常處理

  • 自定義貼合業務意義的異常有易於代碼的可讀性以及使用效率,比如封裝相同的詳細的錯誤信息和狀態碼等;
  • 全局的異常處理可以使Controller層的代碼更簡潔且更具有可讀性,也更容易修改和維護;
  • Controller類對於用戶請求的方法中,應該主要體現處理用戶請求的業務意義,而不是處理一大堆異常並將其轉譯爲友好的返回結果;
  • 儘量使用HTTP標準的狀態碼和詳細的錯誤信息來描述一個異常。因爲自定義太多的狀態碼相對更難維護,而且設計起來也會很容易崩潰。想一想你需要爲很多的異常都單獨設計一個特定的狀態碼,那麼至少還需要維護一份文檔,對吧?

以上僅爲參考,並非標準。

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