# API規範 ###### tags: `開發風格` ## 前言和目標 規範裡面大部分是 不要做的項多,要做的比較少,落地比較容易。 * 接口定義常見問題-錯誤範例 統一的接口規範,能幫忙規避很多無用的返工修改和可能出現的問題。能使代碼可讀性更加好,利於進行aop和自動化測試這些額外工作。大家一定要重視。 * Controller規範 Contorller只做參數格式轉換,如果沒有參數需要轉換的,那麼就一行代碼。日誌/參數校驗/權限判斷建議放到service裡面,畢竟controller基本無法重用,而service重用較多。而我們的單元測試也不需要測試controller,直接測試service即可。 * AOP實現 有統一的接口定義規範,然後有AOP實現,先有思想再有技術。技術不是關鍵,AOP技術也很簡單,這個帖子的關鍵點不是技術,而是習慣和思想,不要撿了芝麻丟了西瓜。網絡上講技術的貼多,講習慣、風格的少,這些都是我工作多年的行之有效的經驗之談,望有緣人珍惜。 * 日誌打印 新手建議 日誌這個東西,更多是靠自覺,項目組這麼多人,我也不可能一個一個給大家看代碼,然後叫你加日誌。我分析了一下,為什麼有些人沒有打印日誌的習慣,說了多次都改不過來。我建議大家養成下面的習慣,這樣你的日誌就會改善多了! 不要依賴debug,多依賴日誌。 別人面對對象編程,你面對debug編程。有些人無論什麼語言,最後都變成了面對debug編程。哈哈。這個習慣非常非常不好! debug會讓你寫代碼的時候偷懶不打日誌,而且很浪費時間。改掉這個惡習。 代碼開發測試完成之後不要急著提交,先跑一遍看看日誌是否看得懂。 日誌是給人看的,只要熱愛編程的人才能成為合格程序員,不要匆匆忙忙寫完功能測試ok就提交代碼,日誌也是功能的一部分。要有精益求精的工匠精神! * 工具類編寫 幾乎所有人都知道面向對象的思想有抽象封裝,但幾個人真正能做到,其實有心的話,處處都能體現出這些思想。編寫工具類的時候需要注意參數的優化,而且大型項目裡面不要在業務代碼裡面直接調用第三方的工具類,然後就是多想一步多走一步,考慮各種類型的入參,這樣你也能編寫出專業靈活的工具類! ### 錯誤示範 ```java= @PostMapping("/delete") public Map<String, Object> delete(long id, String lang) { Map<String, Object> data = new HashMap<String, Object>(); boolean result = false; try { // 语言(中英文提示不同) Locale local = "zh".equalsIgnoreCase(lang) ? Locale.CHINESE : Locale.ENGLISH; result = configService.delete(id, local); data.put("code", 0); } catch (CheckException e) { // 参数等校验出错,这类异常属于已知异常,不需要打印堆栈,返回码为-1 data.put("code", -1); data.put("msg", e.getMessage()); } catch (Exception e) { // 其他未知异常,需要打印堆栈分析用,返回码为99 log.error(e); data.put("code", 99); data.put("msg", e.toString()); } data.put("result", result); return data; } ``` ### 目標 ```java= @PostMapping("/delete") public ResultBean<Boolean> delete(long id) { return new ResultBean<Boolean>(configService.delete(id)); } ``` ## 接口定義常見問題-錯誤範例 ### 返回格式不統一 同一個接口,有時候返回數組,有時候返回單個;成功的時候返回對象,失敗的時候返回錯誤信息字符串。工作中有個系統集成就是這樣定義的接口,真是辣眼睛。這個對應代碼上,返回的類型是map,json,object,都是不應該的。 實際工作中,我們會定義一個統一的格式,就是ResultBean,分頁的有另外一個PageResultBean * 返回map可讀性不好,不知道裡面是什麼 ```java= @PostMapping("/delete") public Map<String, Object> delete(long id, String lang) { } ``` * 成功返回boolean,失敗返回string, ```java= @PostMapping("/delete") public Object delete(long id, String lang) { try { boolean result = configService.delete(id, local); return result; } catch (Exception e) { log.error(e); return e.toString(); } } ``` ### 沒有考慮失敗情況 一開始只考慮成功場景,等後面測試發現有錯誤情況,怎麼辦,改接口唄,前後台都改,勞民傷財無用功。 * 不返回任何數據,沒有考慮失敗場景 ```java= @PostMapping("/update") public void update(long id, xxx) { } ``` ### 出現和業務無關的輸入參數 如lang語言,當前用戶信息 都不應該出現參數里面,應該從當前會話裡面獲取。 * 當前用戶刪除數據,參數出現lang和userid,尤其是userid。 ```java= @PostMapping("/delete") public Map<String, Object> delete(long id, String lang, String userId) { ...... } ``` :::warning 後面講ThreadLocal會說到怎麼樣去掉。除了代碼可讀性不好問題外,尤其是參數出現當前用戶信息的,這是個嚴重問題。 ::: ### 出現複雜的參數 一般情況下,不允許出現例如json字符串這樣的參數,這種參數可讀性極差,應該定義對應的bean。 * 參數出現json格式,可讀性不好,代碼也難看 ```java= @PostMapping("/update") public Map<String, Object> update(long id, String jsonStr) { } ``` ### 沒有返回應該返回的數據 例如,新增接口一般情況下應該返回新對象的id標識,這需要編程經驗。新手定義的時候因為前台沒有用就不返回數據或者只返回true,這都是不恰當的。別人要不要是別人的事情,你該返回的還是應該返回。 * 新建應該返回新對象的信息(對像或者ID),只返回boolean ```java= @PostMapping("/add") public boolean add(xxx) { //xxx return configService.add(); } ``` ## Controller規範 第一篇文章中,我貼了2段代碼,第一個是原生態的,第2段是我指定了接口定義規範,使用AOP技術之後最終交付的代碼,從15行到1行,自己感受一下。今天來說說大家關注的AOP如何實現。 先說說Controller規範,主要的內容是就是接口定義裡面的內容,你只要遵循裡面的規範,controller就問題不大,除了這些,還有另外的幾點: ### 統一返回ResultBean對象 所有函數返回統一的ResultBean/PageResultBean格式,原因見我的接口定義這個貼。沒有統一格式,AOP無法玩,更加重要的是前台代碼很不好寫。當然類名你可以按照自己喜好隨便定義,如就叫Result。 大家都知道,前台代碼很難寫好做到重用,而我們返回相同數據結構後,前台代碼可以這樣寫(方法handlerResult的重用): ```java= // 查詢所有配置項記錄 function fetchAllConfigs() { $.getJSON('config/all', function(result) { handlerResult(result, renderConfigs); }); } // 根據id刪除配置項 function deleteConfig(id) { $.post('config/delete', { id : id }, function(result) { console.log('delete result', result); handlerResult(result, fetchAllConfigs); }); } /** * 後台返回相同的數據結構,前台的代碼才好寫才能重用 * @param result: ajax返回的結果 * @param fn: 成功的處理函數(傳入data) */ function handlerResult(result, fn) { // 成功執行操作,失敗提示原因 if (result.code == 0) { fn(result.data); } // 沒有登陸異常,重定向到登陸頁面 else if (result.code == -1) { showError("沒有登錄"); } // 參數校驗出錯,直接提示 else if (result.code == 1) { showError(result.msg); } // 沒有權限,顯示申請權限電子流 else if (result.code == 2) { showError("沒有權限"); } else { // 不應該出現的異常,應該重點關注 showError(result.msg); } } ``` ### ResultBean不允許往後傳 ResultBean/PageResultBean是controller專用的,不允許往後傳!往其他地方傳之後,可讀性立馬下降,和傳map,json好不了多少。 ### Controller只做參數格式的轉換 Controller做參數格式的轉換,不允許把json,map這類對像傳到services去,也不允許services返回json、map。寫過代碼都知道,map,json這種格式靈活,但是可讀性差( 編碼一時爽,重構火葬場)。如果放業務數據,每次閱讀起來都十分困難,需要從頭到尾看完才知道裡面有什麼,是什麼格式。定義一個bean看著工作量多了,但代碼清晰多了。 ### 參數不允許出現Request,Response 這些對象 和json/map一樣,主要是可讀性差的問題。 :::warning 一般情況下不允許出現這些參數,除非要操作流。 ::: ### 不需要打印日誌 日誌在AOP裡面會打印,而且我的建議是大部分日誌在Services這層打印。 ## AOP實現 我們需要在AOP裡面統一處理異常,包裝成相同的對象ResultBean給前台。 ### ResultBean定義 ResultBean定義帶泛型,使用了lombok。 ```java= @Data public class ResultBean<T> implements Serializable { private static final long serialVersionUID = 1L; public static final int NO_LOGIN = -1; public static final int SUCCESS = 0; public static final int FAIL = 1; public static final int NO_PERMISSION = 2; private String msg = "success"; private int code = SUCCESS; private T data; public ResultBean() { super(); } public ResultBean(T data) { super(); this.data = data; } public ResultBean(Throwable e) { super(); this.msg = e.toString(); this.code = FAIL; } } ``` ### AOP實現 AOP代碼,主要就是打印日誌和捕獲異常,異常要區分已知異常和未知異常,其中未知的異常是我們重點關注的,可以做一些郵件通知啥的,已知異常可以再細分一下,可以不同的異常返回不同的返回碼: ```java= /** * 處理和包裝異常 */ public class ControllerAOP { private static final Logger logger = LoggerFactory.getLogger(ControllerAOP.class); public Object handlerControllerMethod(ProceedingJoinPoint pjp) { long startTime = System.currentTimeMillis(); ResultBean<?> result; try { result = (ResultBean<?>) pjp.proceed(); logger.info(pjp.getSignature() + "use time:" + (System.currentTimeMillis() - startTime)); } catch (Throwable e) { result = handlerException(pjp, e); } return result; } /** * 封裝異常信息,注意區分已知異常(自己拋出的)和未知異常 */ private ResultBean<?> handlerException(ProceedingJoinPoint pjp, Throwable e) { ResultBean<?> result = new ResultBean(); // 已知異常 if (e instanceof CheckException) { result.setMsg(e.getLocalizedMessage()); result.setCode(ResultBean.FAIL); } else if (e instanceof UnloginException) { result.setMsg("Unlogin"); result.setCode(ResultBean.NO_LOGIN); } else { logger.error(pjp.getSignature() + " error ", e); //TODO 未知的異常,應該格外注意,可以發送郵件通知等 result.setMsg(e.toString()); result.setCode(ResultBean.FAIL); } return result; } } ``` :::info 建議 對於未知異常,給相關責任人發送郵件通知,第一時間知道異常,實際工作中非常有意義。 ::: ### 返回碼定義 關於怎麼樣定義返回碼,個人經驗是 粗分異常,不能太細。如沒有登陸返回-1,沒有權限返回-2,參數校驗錯誤返回1,其他未知異常返回-99等。需要注意的是,定義的時候,需要調用方單獨處理的異常需要和其他區分開來,比如沒有登陸這種異常,調用方不需要單獨處理,前台調用請求的工具類統一處理即可。而參數校驗異常或者沒有權限異常需要調用方提示給用戶,沒有權限可能除了提示還會附上申請權限鏈接等,這就是異常的粗分。 WARNING 返回碼不要太細,千萬不要標題為空返回1,描述為空返回2,字段X非法返回3,這種定義看上去很專業,實際上會把前台和自己累死。 ### AOP配置 關於用java代碼還是xml配置,這裡我傾向於xml配置,因為這個會不定期改動 ```xml= <!-- aop --> <aop:aspectj-autoproxy /> <beans:bean id="controllerAop" class="xxx.common.aop.ControllerAOP" /> <aop:config> <aop:aspect id="myAop" ref="controllerAop"> <aop:pointcut id="target" expression="execution(public xxx.common.beans.ResultBean *(..))" /> <aop:around method="handlerControllerMethod" pointcut-ref="target" /> </aop:aspect> </aop:config> ``` 現在知道為什麼要返回統一的一個ResultBean了: 為了統一格式 為了應用AOP 為了包裝異常信息 分頁的PageResultBean大同小異。 ### 簡單示例 ```java= /** * 配置對象處理器 * * @author 曉風輕 https://github.com/xwjie/PLMCodeTemplate */ @RequestMapping("/config") @RestController public class ConfigController { private final ConfigService configService; public ConfigController(ConfigService configService) { this.configService = configService; } @GetMapping("/all") public ResultBean<Collection<Config>> getAll() { return new ResultBean<Collection<Config>>(configService.getAll()); } /** * 新增數據, 返回新對象的id * * @param config * @return */ @PostMapping("/add") public ResultBean<Long> add(Config config) { return new ResultBean<Long>(configService.add(config)); } /** * 根據id刪除對象 * * @param id * @return */ @PostMapping("/delete") public ResultBean<Boolean> delete(long id) { return new ResultBean<Boolean>(configService.delete(id)); } @PostMapping("/update") public ResultBean<Boolean> update(Config config) { configService.update(config); return new ResultBean<Boolean>(true); } } ``` ### 為什麼不用ExceptionHandler 這是我發帖後問的最多的一個問題,很多人說為什麼不用 ControllerAdvice + ExceptionHandler 來處理異常?覺得是我在重複發明輪子。首先,這2這都是AOP,本質上沒有啥區別。而最重要的是ExceptionHandler只能處理異常,而我們的AOP除了處理異常,還有一個很重要的作用是打印日誌,統計每一個controller方法的耗時,這在實際工作中也非常重要和有用的特性! TIP 就算你使用ExceptionHandler,也不要成功和失敗的時候返回不一樣的數據格式,否則前台很難寫好代碼。 ### 為什麼不用Restful風格 這也是問的比較多的一個問題。如果你提供的接口是給前台調用的,而你又在實際工作中前後台開發都負責的話,我覺得你應該不會問這個問題。誠然,restful風格的定義很優雅,但是在前台調用起來卻非常的麻煩,前台通過返回的ResultBean的code來判斷成功失敗顯然比通過http狀態碼來判斷方便太多。第2個原因,使用http狀態碼返回出錯信息也值得商榷。系統出錯了返回400我覺得沒有問題,但一個參數校驗不通過也返回400,我個人覺得是很不合理的,是無法接受的。 ## 日誌打印 開發中日誌這個問題,每個公司都強調,也制定了一大堆規範,但根據實際情況看,效果不是很明顯,主要是這個東西不好測試和考核,沒有日誌功能一樣跑啊。 但編程活久見,開發久了,總會遇到“這個問題生產環境上能重現,但是沒有日誌,業務很複雜,不知道哪一步出錯了?” 這個時候,怎麼辦?還能怎麼辦,發個版本,就是把所有地方加上日誌,沒有任何新功能,然後在讓用戶重現一遍,拿下日誌來看,哦,原來是這個問題。 有沒有很熟悉的感覺? 還有一種情況,我們系統有3*5=15個節點,出了問題找日誌真是痛苦,一個一個機器翻,N分鐘後終於找到了,找到了後發現好多相似日誌,一個一個排查;日誌有了,發現邏輯很複雜,不知道走到那個分支,只能根據邏輯分析,半天過去了,終於找到了原因。 。 。一個問題定位就過去了2個小時,變更時間過去了一半。 。 。 ### 日誌要求 所以我對日誌的最少有以下2點要求: 1 能找到那個機器 2 能找到用戶做了什麼 ### 配置nginx 針對第一點,我修改了一下nginx的配置文件,讓返回頭里面返回是那個機器處理的。 nginx的基本配置,大家查閱一下資料就知道。簡單配置如下(生產環境比這個完善) nginx配置 ```cpp= upstream serverlist{ server localhost:8080; server localhost:8081; } server { listen 80; server_name a.com location / { proxy_pass http//serverlist/; add_header X-Slave $upstream; } } ``` 效果如圖,返回了處理的節點: 效果圖 第二點,要知道用戶做了什麼。用戶信息是很重要的一個信息,能幫助海量日誌裡面能快速找到目標日誌。一開始要求開發人員打印的時候帶上用戶,但是發現這個落地不容易,開發人員打印日誌都經常忘記,更加不用說日誌上加上用戶信息,我也不可能天天看代碼。所以找了一下log4j的配置,果然log4j有個叫MDC(Mapped Diagnostic Context)的類(技術上使用了ThreadLocal實現,重點技術)。具體使用方法請自行查詢。具體使用如下: ### UserFilter filter中得到用戶信息,並放入MDC,記住filter後要清理掉(因為tomcat線程池線程重用的原因)。 ```java= /** * * 用戶信息相關的filter * */ public class UserFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 得到用戶個人相關的信息(登陸的用戶,用戶的語言) fillUserInfo((HttpServletRequest) request); try { chain.doFilter(request, response); } finally { // 由於tomcat線程重用,記得清空 clearAllUserInfo(); } } private void clearAllUserInfo() { UserUtil.clearAllUserInfo(); } private void fillUserInfo(HttpServletRequest request) { // 用戶信息 String user = getUserFromSession(request); if (user != null) { UserUtil.setUser(user); } // 語言信息 String locale = getLocaleFromCookies(request); // 放入到threadlocal,同一個線程任何地方都可以拿出來 if (locale != null) { UserUtil.setLocale(locale); } } private String getLocaleFromCookies(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null) { return null; } for (int i = 0; i < cookies.length; i++) { if (UserUtil.KEY_LANG.equals(cookies[i].getName())) { return cookies[i].getValue(); } } return null; } private String getUserFromSession(HttpServletRequest request) { HttpSession session = request.getSession(false); if (session == null) { return null; } // 從session中獲取用戶信息放到工具類中 return (String) session.getAttribute(UserUtil.KEY_USER); } @Override public void destroy() { } } ``` ### 用戶工具類 用戶信息放入MDC: ```java= /** * * 用戶工具類 * */ public class UserUtil { private final static ThreadLocal<String> tlUser = new ThreadLocal<String>(); private final static ThreadLocal<Locale> tlLocale = new ThreadLocal<Locale>() { protected Locale initialValue() { // 語言的默認值 return Locale.CHINESE; }; }; public static final String KEY_LANG = "lang"; public static final String KEY_USER = "user"; public static void setUser(String userid) { tlUser.set(userid); // 把用戶信息放到log4j MDC.put(KEY_USER, userid); } /** * 如果沒有登錄,返回null * * @return */ public static String getUserIfLogin() { return tlUser.get(); } /** * 如果沒有登錄會拋出異常 * * @return */ public static String getUser() { String user = tlUser.get(); if (user == null) { throw new UnloginException(); } return user; } public static void setLocale(String locale) { setLocale(new Locale(locale)); } public static void setLocale(Locale locale) { tlLocale.set(locale); } public static Locale getLocale() { return tlLocale.get(); } public static void clearAllUserInfo() { tlUser.remove(); tlLocale.remove(); MDC.remove(KEY_USER); } } ``` ### log4j配置 增加用戶信息變量,%X{user} ```xml= <!-- Appenders --> <appender name="console" class="org.apache.log4j.ConsoleAppender"> <param name="Target" value="System.out" /> <layout class="org.apache.log4j.PatternLayout"> <param name="ConversionPattern" value="[%t]%-d{MM-dd HH:mm:ss,SSS} %-5p:%X{user} - %c - %m%n" /> </layout> </appender> ``` ### 日誌要求 我做好上面2步後,對開發人員的日誌只有3點要求: 1. 修改(包括新增)操作必須打印日誌 大部分問題都是修改導致的。數據修改必須有據可查。 ```java= ``` 2. 條件分支必須打印條件值,重要參數必須打印 尤其是分支條件的參數,打印後就不用分析和猜測走那個分支了,很重要!如下面代碼裡面的userType,一定要打印值,因為他決定了代碼走那個分支。 ```java= ``` 3. 數據量大的時候需要打印數據量 前後打印日誌和最後的數據量,主要用於分析性能,能從日誌中知道查詢了多少數據用了多久。這點是建議。自己視情況而決定是否打印,我一般建議打印。 ### 日誌效果圖 加上 我的編碼習慣 - Controller規範 這篇文章的AOP,最後的日誌如下: log 其實日誌的級別我到不是很關注,還沒有到關注這步到時候。開發組長需要做好後勤工作(前面2步),然後製定簡單規則,規則太多太能落實了。 ## 異常處理 對於大型IT系統,最怕的事情第一是系統出現了異常我不知道,等問題鬧大了用戶投訴了才知道出問題了。第二就是出了問題之後無法找到出錯原因。針對這2個問題,說說我們項目組是怎麼樣規定異常處理的。 再次聲明我的觀點,我這系列貼裡面,沒有什麼技術點,都是一些編程的經驗之談,而且是建立在項目背景是大部分代碼都是簡單的CRUD、開發人員流動大水平一般的情況下。希望讀者的重點不要再關注技術點。大部分工作中不需要什麼技術,你只要把代碼寫好,足夠你輕鬆面對! 言歸正傳,說回第一個問題,系統出異常了我不知道,等問題鬧大了用戶投訴了才知道。這個問題出現非常多,而且非常嚴重。我不知道其他公司有沒有這種場景,對我們公司而言,經常會出現用戶反饋、投訴過來說某個功能不可用,開發人員定位分析之後,才發現之前的某一步出錯了。公司業務流程非常複雜,和周邊系統一堆集成,一堆的後台隊列任務,任何一部都可能出問題。 舉幾個今年真實的案例: 1. 某系統銷戶無法成功,最後定位發現前段時間ldap密碼修改沒有更新 2. 某個流程失敗,最後發現集成的系統新增加了NAS盤,防火牆不通無法訪問導致報錯。 3. 某個功能無法使用,查看日誌發現後台定時任務已經停了好幾天。 針對這些功能,在流程上當然可以採取相對的策略來保證,但從開發的角度來說,任何規定都無法保證一定不會發生錯誤,老虎也有打盹的時候,我只相信代碼。 ### 錯誤範例 貼一段非常常見的代碼,大家覺得這段代碼有沒有問題? ```java= private void updateDocInfo(long id){ Document doc = null; try { doc = getDocById(id); } catch (Exception e){ log.error("get document error", e); } if (doc != null){ log.info("update doc, id" + id); // dosomething doUpdateDoc(doc); } } ``` 在我看來,這段代碼很多時候問題特別大! * 丟掉了異常 異常就算打印了堆棧,也不會有人去看的!除非用戶告訴你出問題了,你才會去找日誌!所以,看著好像很嚴謹的代碼,其實作用並不大。 * 空判斷隱藏了錯誤 異常處理再加上框框2處的空判斷,天衣無縫的避開了所有正確答案。本來需要更新文檔,結果什麼錯誤沒有報,什麼也沒有做。你後台就算打了日誌堆棧又怎麼樣? 所以,我對開發人員的要求就是,絕大部分場景, 1. 不允許捕獲異常, 2. 不要亂加空判斷。 只有明顯不需要關心的異常,如關閉資源的時候的io異常,可以捕獲然後什麼都不干,其他時候,不允許捕獲異常,都拋出去,到controller處理。空判斷大部分時候不需要,你如果寫了空判斷,你就必須測試為空和不為空二種場景,要么就不要寫空判斷。 :::info 強調, 1. 有些空判斷是要的,如:參數是用戶輸入的情況下。 2. 但是,大部分場景是不需要的(我們的IT系統裡面,一半以上不需要),如參數是其它系統傳過來,或者其他地方獲取的傳過來的,99.99%都不會為空,你判斷來幹嘛?就拋一個空指針到前台怎麼啦?何況基本上不會出現。 ::: 新手最容易犯的錯誤,到處捕獲異常,到處加空判斷,自以為寫出了“健壯”的代碼,實際上完全相反。導致的問題,第一代碼可讀性很差,你如果工作了看到一半代碼是try-catch和空判斷你會同意我的觀點的,第二更加重要的掩蓋了很多錯誤,如上面圖片的例子!日誌是不會有人看的,我們的目的是儘早讓錯誤拋出來,還有,你加了空判斷,那你測試過為空的場景嗎? web請求上的異常,不允許開發人員捕獲,直接拋到前台,會有controller處理!見之前的Controller規範 所以上面的代碼,我來寫的話是這樣的,清晰明了。 ```java= private void updateDocInfo(long id){ Document doc = getDocById(id); log.info("update doc, id" + id); // dosomething doUpdateDoc(doc); } ``` :::info 另外一種後台定時任務隊列的異常,其實思路是一樣的,有個統一的地方處理異常,裡面的代碼同樣不准捕獲異常!然後異常的時候郵件通知到我和開發人員,開發組長必須知道後台的任何異常,不要等用戶投訴了才知道系統出問題了。 另外,開發組長需要自己定義好系統裡面的異常,其實能定義的沒有幾種,太細了很難落地,還有,異常不要繼承Exception,而是繼承RuntimeException,否則到時候從頭改到尾就為了加個異常聲明你就覺得很無聊。 ::: ### 異常處理要求 開發組長定義好異常,異常繼承RuntimeException。 不允許開發人員捕獲異常。 異常上對開發人員就這點要求! 1. Api常都拋出到controller上用AOP處理。 2. 後台(如隊列等)異常一定要有通知機制,要第一時間知道異常。 :::info 少加空判斷,加了空判斷就要測試為空的場景! 這篇文章,我估計一定有很多爭議,這些規則都和常見的認識相反,我在公司裡面推廣和寫貼分享的時候也有人反對。但是,你要知道你遇到的是什麼問題,要解決的是什麼問題?我遇到是很多異常本來很簡單,但由於一堆“健壯”的try-catch和空判斷,導致問題發現很晚,可能很小一個問題最後變成了一個大事件,在一些IT系統裡面,尤其常見。大家不要理解為不能加空判斷,大家見仁見智吧。反正我是這樣寫代碼的,我發現效果很好,我很少花時間在調試代碼和改bug上,更加不會出現前台返回成功,後台有異常什麼也沒有做的場景。 最後對新手說一句,不要養成到處try-catch和加空判斷的惡習,你這樣會掩蓋掉很多錯誤,給人埋很多坑的! ::: ## 參數校驗和國際化 今天我們說說參數校驗和國際化,這些代碼沒有什麼技術含量,卻大量充斥在業務代碼上,很可能業務代碼只有幾行,參數校驗代碼卻有十幾行,非常影響代碼閱讀,所以很有必要把這塊的代碼量減下去。 今天的目的主要是把之前例子裡面的和業務無關的國際化參數隱藏掉,以及如何封裝好校驗函數。 ### 修改前代碼 * controller代碼 ```java= /** * ! ! !錯誤範例 * * 根據id刪除對象 * * @param id * @param lang * @return */ @PostMapping("/delete") public Map<String, Object> delete(long id, String lang) { Map<String, Object> data = new HashMap<String, Object>(); boolean result = false; try { // 語言(中英文提示不同) Locale local = "zh".equalsIgnoreCase(lang) ? Locale.CHINESE : Locale.ENGLISH; result = configService.delete(id, local); data.put("code", 0); } catch (CheckException e) { // 參數等校驗出錯,已知異常,不需要打印堆棧,返回碼為-1 data.put("code", -1); data.put("msg", e.getMessage()); } catch (Exception e) { // 其他未知異常,需要打印堆棧分析用,返回碼為99 log.error("delete config error", e); data.put("code", 99); data.put("msg", e.toString()); } data.put("result", result); return data; } ``` 其中的lang參數我們需要去掉 * service代碼 ```java= /** * ! ! !錯誤示範 * * 出現和業務無關的參數local * * @param id * @param locale * @return */ public boolean delete(long id, Locale locale) { // 參數校驗 if (id <= 0L) { if (locale.equals(Locale.CHINESE)) { throw new CheckException("非法的ID:" + id); } else { throw new CheckException("Illegal ID:" + id); } } boolean result = dao.delete(id); // 修改操作需要打印操作結果 logger.info("delete config success, id:" + id + ", result:" + result); return dao.delete(id); } ``` ### 修改後代碼 * controller代碼 ```java= /** * 根據id刪除對象 * * @param id * @return */ @PostMapping("/delete") public ResultBean<Boolean> delete(long id) { return new ResultBean<Boolean>(configService.delete(id)); } ``` * service代碼 ```java= public boolean delete(long id) { // 參數校驗 check(id > 0L, "id.error", id); boolean result = dao.delete(id); // 修改操作需要打印操作結果 logger.info("delete config success, id: {}, result: {}", id, result); return result; } ``` Controll的非業務代碼如何去掉參考 Controller規範,下面說說去掉Local參數。 TIP 業務代碼裡面不要出現和業務無關的東西,如local,MessageSource 。 去掉國際化參數還是使用的技術還是ThreadLocal。國際化信息可以放好幾個地方,但建議不要放在每一個url上,除了比較low還容易出很多其他問題。這裡演示的是放在cookie上面的例子: ### 用戶工具類UserUtil 需要保存用戶的國際化信息。 ```java= public class UserUtil { private final static ThreadLocal<String> tlUser = new ThreadLocal<String>(); private final static ThreadLocal<Locale> tlLocale = new ThreadLocal<Locale>() { protected Locale initialValue() { // 語言的默認值 return Locale.CHINESE; }; }; public static final String KEY_LANG = "lang"; public static final String KEY_USER = "user"; public static void setUser(String userid) { tlUser.set(userid); // 把用戶信息放到log4j MDC.put(KEY_USER, userid); } public static String getUser() { return tlUser.get(); } public static void setLocale(String locale) { setLocale(new Locale(locale)); } public static void setLocale(Locale locale) { tlLocale.set(locale); } public static Locale getLocale() { return tlLocale.get(); } public static void clearAllUserInfo() { tlUser.remove(); tlLocale.remove(); MDC.remove(KEY_USER); } } ``` ### 校驗工具類CheckUtil 這裡需要調用用戶工具類得到用戶的語言。還有就是提示信息裡面,需要支持傳入變量。 ```java= package plm.common.utils; import org.springframework.context.MessageSource; import plm.common.exceptions.CheckException; public class CheckUtil { private static MessageSource resources; public static void setResources(MessageSource resources) { CheckUtil.resources = resources; } public static void check(boolean condition, String msgKey, Object... args) { if (!condition) { fail(msgKey, args); } } public static void notEmpty(String str, String msgKey, Object... args) { if (str == null || str.isEmpty()) { fail(msgKey, args); } } public static void notNull(Object obj, String msgKey, Object... args) { if (obj == null) { fail(msgKey, args); } } private static void fail(String msgKey, Object... args) { throw new CheckException(resources.getMessage(msgKey, args, UserUtil.getLocale())); } } ``` 這裡有幾個小技術點: ### spring的靜態方法注入 工具類裡面使用spring的bean,使用了MethodInvokingFactoryBean的靜態方法注入: ```xml= <!-- 國際化 --> <bean id="messageSource" class="org.springframework.context.support.ResourceBundleMessageSource"> <property name="basenames"> <list> <value>format</value> <value>exceptions</value> <value>windows</value> </list> </property> </bean> <bean class="org.springframework.beans.factory.config.MethodInvokingFactoryBean"> <property name="staticMethod" value="plm.common.utils.CheckUtil.setResources" /> <!-- 這裡配置參數 --> <property name="arguments" ref="messageSource"> </property> </bean> ``` ### jdk 的 import static server裡面調用 check 方法的時候沒有出現類名。這裡使用的jdk的import static 特性,可以在ide上配置,請自行google。 import static plm.common.utils.CheckUtil.*; 還有一小點注意,我建議參數非法的時候,把非法值打印出來,否則你又要浪費時間看是沒有傳呢還是傳錯了,時間就是這樣一點點浪費的。 check(id > 0L, "id.error", id); // 當前非法的id也傳入提示出去 另外有些項目用valid來校驗,從我實際接觸來看,用的不多,可能是有短木板吧。如果你的項目valid就能滿足,那就更加好了,不需要看了。但是大部分場景,校驗比例子復雜N多,提示也千變萬化,所以我們還是自己調用函數校驗。 做了這幾步之後,代碼會漂亮很多,記住,代碼最主要的不是性能,而是可讀性,有了可讀性才有才維護性。而去掉無關的代碼後的代碼,和之前的代碼對比一下,自己看吧。 ## 工具類編寫 一個項目不可能沒有工具類,工具類的初衷是良好的,代碼重用,但到了後面工具類越來越亂,有些項目工具類有幾十個,看的眼花繚亂,還有不少重複。如何編寫出好的工具類,我有幾點建議: ### 隱藏實現 就是要定義自己的工具類,盡量不要在業務代碼裡面直接調用第三方的工具類。這也是解耦的一種體現。 如果我們不定義自己的工具類而是直接使用第三方的工具類有2個不好的地方: * 不同的人會使用不同的第三方工具庫,會比較亂。 * 將來萬一要修改工具類的實現邏輯會很痛苦。 以最簡單的字符串判空為例,很多工具庫都有 StringUtils工具類,如果我們使用 commons 的工具類,一開始我們直接使用 StringUtils.isEmpty,字符串為空或者空串的時候會返回為true,後面業務改動,需要改成如果全部是空格的時候也會返回true,怎麼辦?我們可以改成使用 StringUtils.isBlank 。看上去很簡單,對吧?如果你有幾十個文件都調用了,那我們要改幾十個文件,是不是有點噁心?再後面發現,不只是英文空格,如果是全角的空格,也要返回為true,怎麼辦? StringUtils上的方法已經不能滿足我們的需求了,真不好改了。 。 。 所以我的建議是,一開始就自己定義一個自己項目的 StringUtil,裡面如果不想自己寫實現,可以直接調用 commons 的方法,如下: ```java= public static boolean isEmpty(String str) { return org.apache.commons.lang3.StringUtils.isEmpty(str); } ``` 後面全部空格也返回true的時候,我們只需要把isEmpty改成isBlank;再後面全部全角空格的時候也返回true的話,我們增加自己的邏輯即可。我們只需要改動和測試一個地方。 再舉一個真實一點的例子,如復制對象的屬性方法。 一開始,如果我們自己不定義工具類方法,那麼我們可以使用 org.springframework.beans.BeanUtils.copyProperties(source, dest) 這個工具類來實現,就一行代碼,和調用自己的工具類沒有什麼區別。看上去很OK,對吧? 隨著業務發展,我們發現這個方式的性能或者某些特性不符合我們要求,我們需要修改改成 commons-beanutils包裡面的方法,org.apache.commons.beanutils.BeanUtils.copyProperties(dest,source),這個時候問題來了,第一個問題,它的方法的參數順序和之前spring的工具類是相反的,改起來非常容易出錯!第二個問題,這個方法有異常拋出,必須聲明,這個改起來可要命了!結果你發現,一個看上去很小的改動,改了幾十個文件,每個改動還得測試一次,風險不是那麼得小。有一點小崩潰了,是不是? 等你改完之後測試完了,突然有一天需要改成,複製參數的時候,有些特殊字段需要保留(如對象id)或者需要過濾掉(如密碼)不復制,怎麼辦?這個時候我估計你要崩潰了吧?不要覺得我是憑空想像,編程活久見,你總會遇到的一天! 所以,我們需要定義自己的工具類函數,一開始我定義成這樣子。 ```java= public static void copyAttribute(Object source, Object dest) { org.springframework.beans.BeanUtils.copyProperties(source, dest); } ``` 後面需要修改為 commons-beanutis 的時候,我們改成這樣即可,把參數順序掉過來,然後處理了一下異常,我使用的是 Lombok 的 @SneakyThrows 來保證異常,你也可以捕獲掉拋出運行時異常,個人喜好。 ```java= @SneakyThrows public static void copyAttribute(Object source, Object dest) { org.apache.commons.beanutils.BeanUtils.copyProperties(dest, source); } ``` 再後面,複製屬性的時候需要保留某些字段或者過濾掉某些字段,我們自己參考其他庫實現一次即可,只改動和測試一個文件一個方法,風險非常可控。 還記得我之前的帖子裡說的需求變更嗎?你可以認為這算需求變更,但同樣的需求變更,我一個小時改完測試(因為我只改一個文件),沒有任何風險輕輕鬆鬆上線,你可能滿頭大汗加班加點還擔心出問題。 。 。 ### 使用父類/接口 上面那點隱藏實現,說到底是封裝/解耦的思想,而現在說的這點是抽象的思想,做好了這點,我們就能編寫出看上去很專業的工具類。這點很好理解也很容易做到,但是我們容易忽略。 舉例,假設我們寫了一個判斷arraylist是否為空的函數,一開始是這樣的。 ```java= public static boolean isEmpty(ArrayList<?> list) { return list == null || list.size() == 0; } ``` 這個時候,我們需要思考一下參數的類型能不能使用父類。我們看到我們只用了size方法,我們可以知道size方法再list接口上有,於是我們修改成這樣。 ```java= public static boolean isEmpty(List<?> list) { return list == null || list.size() == 0; } ``` 後面發現,size方法再list的父類/接口Collection上也有,那麼我們可以修改為最終這樣。 ```java= public static boolean isEmpty(Collection<?> list) { return list == null || list.size() == 0; } ``` 到了這部,Collection沒有父類/接口有size方法了,修改就結束了。最後我們需要把參數名字改一下,不要再使用list。改完後,所有實現了Collection都對像都可以用,最終版本如下: ```java= public static boolean isEmpty(Collection<?> collection) { return collection == null || collection.size() == 0; } ``` 是不是看上去通用多了 ,看上去也專業多了?上面的string相關的工具類方法,使用相同的思路,我們最終修改一下,把參數類類型由String修改為CharSequence,參數名str修改為cs。如下: ```java= public static boolean isEmpty(CharSequence cs) { return org.apache.commons.lang3.StringUtils.isEmpty(cs); } ``` 思路和方法很簡單,但效果很好,寫出來的工具類也顯得很專業!總結一下,思路是抽象的思想,主要是修改參數類型,方法就是往上找父類/接口,一直找到頂為止,記得修改參數名。 使用重載編寫衍生函數組 開發過的兄弟都知道,有一些工具庫,有一堆的重載函數,調用起來非常方便,經常能直接調用,不需要做參數轉換。這些是怎麼樣編寫出來的呢?我們舉例說明。 現在需要編寫一個方法,輸入是一個utf-8格式的文件的文件名,把裡面內容輸出到一個list< String>。我們剛剛開始編寫的時候,是這個樣子的 ```java= public static List<String> readFile2List(String filename) throws IOException { List<String> list = new ArrayList<String>(); File file = new File(filename); FileInputStream fileInputStream = new FileInputStream(file); BufferedReader br = new BufferedReader(new InputStreamReader(fileInputStream, "UTF-8")); // XXX操作 return list; } ``` 我們先實現,實現完之後我們做第一個修改,很明顯,utf-8格式是很可能要改的,所以我們先把它做為參數提取出去,方法一拆為二,就變成這樣。 ```java= public static List<String> readFile2List(String filename) throws IOException { return readFile2List(filename, "UTF-8"); } public static List<String> readFile2List(String filename, String charset) throws IOException { List<String> list = new ArrayList<String>(); File file = new File(filename); FileInputStream fileInputStream = new FileInputStream(file); BufferedReader br = new BufferedReader(new InputStreamReader(fileInputStream, charset)); // XXX操作 return list; } ``` 多了一個方法,直接調用之前的方法主體,主要的代碼還是只有一份,之前的調用地方不需要做任何修改!可以放心修改。 然後我們在看裡面的實現,下面這2行代碼裡面,String類型的filename會變化為File類型,然後在變化為FileInputStream 類型之後才使用。 ```java= File file = new File(filename); FileInputStream fileInputStream = new FileInputStream(file); ``` 這裡我們就應該想到,用戶可能直接傳如File類型,也可能直接傳入FileInputStream類型,我們應該都需要支持,而不需要用戶自己做類型的處理!在結合上一點的使用父類,把FileInputStream改成父類InputStream,我們最終的方法組如下: ```java= package plm.common.utils; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.List; import org.apache.commons.io.IOUtils; /** * 工具類編寫範例,使用重載編寫不同參數類型的函數組 * * @author 曉風輕 https://github.com/xwjie/PLMCodeTemplate * */ public class FileUtil { private static final String DEFAULT_CHARSET = "UTF-8"; public static List<String> readFile2List(String filename) throws IOException { return readFile2List(filename, DEFAULT_CHARSET); } public static List<String> readFile2List(String filename, String charset) throws IOException { FileInputStream fileInputStream = new FileInputStream(filename); return readFile2List(fileInputStream, charset); } public static List<String> readFile2List(File file) throws IOException { return readFile2List(file, DEFAULT_CHARSET); } public static List<String> readFile2List(File file, String charset) throws IOException { FileInputStream fileInputStream = new FileInputStream(file); return readFile2List(fileInputStream, charset); } public static List<String> readFile2List(InputStream fileInputStream) throws IOException { return readFile2List(fileInputStream, DEFAULT_CHARSET); } public static List<String> readFile2List(InputStream inputStream, String charset) throws IOException { List<String> list = new ArrayList<String>(); BufferedReader br = null; try { br = new BufferedReader(new InputStreamReader(inputStream, charset)); String s = null; while ((s = br.readLine()) != null) { list.add(s); } } finally { IOUtils.closeQuietly(br); } return list; } } ``` 怎麼樣? 6個方法,實際上代碼主體只有一份,但提供各種類型的入參,調用起來很方便。開發組長編寫的時候,多費一點點時間,就能寫來看上去很專業調用起來很方便的代碼。如果開發組長不寫好,開發人員發現現有的方法只能傳String,她要傳的是InputStream,她又不敢改原來的代碼,就會copy一份然後修改一下,就多了一份重複代碼。代碼就是這樣爛下去了。 建議 多想一步,根據參數變化編寫各種類型的入參函數,需要保證函數主要代碼只有一份。 ### 使用靜態引入 工具類的一個問題就是容易氾濫,主要原因是開發人員找不到自己要用的方法,就自己寫一個,開發人員很難記住類名,你也不可能天天代碼評審。 所以要讓開發人員容易找到,我們可以使用靜態引入,在Eclipse裡面這樣導入: 靜態引入(img) 這樣,任何地方開發人員只要一敲就可以出來,然後再約定一下項目組方法名規範,這樣工具類的使用就會簡單很多! ### 物理上獨立存放 這點是我的習慣,我習慣把和業務無關的代碼放到獨立的工程或者目錄,在物理上要分開,專人維護。不是所有人都有能力寫工具類,獨立存放專門維護,專門的權限控制有助於保證代碼的純潔和質量。這樣普通的開發人員就不會隨意修改。 例如我的範例工程裡面,專門建立了一個source目錄存放框架代碼,工具類也在裡面,這裡的代碼,只有我一個人會去修改: 工程結構(img) ## 函數編寫建議 在我看來,編寫簡單的函數是一件簡單又困難的事情。簡單是因為這沒有什麼技術難點,困難是因為這是一種思維習慣,很難養成,不寫個幾年代碼,很難寫出像樣的代碼。 大部分的程序員寫的都是CRUD、一些業務邏輯的代碼,誰實現不了?對於我來說,如果業務邏輯的代碼評審,需要人來講每一個代碼做了什麼,這樣的代碼就是不合格的,合格的代碼寫出來應該像人說話那麼簡單有條理,基本上是業務怎麼樣描述需求,寫出來的代碼就是怎麼樣的。編寫出非開發人員都能看懂的代碼,才是我們追求的目標。不要以寫出了一些非常複雜的代碼而沾沾自喜。好的代碼應該是看起來平淡無奇覺得很簡單自然,而不是看得人云裡霧裡的覺得很高深很有技術含量。 如果你做好了我前面幾篇文章的要求,編寫簡單的函數就容易的多,如果你覺得我之前說的去掉local,去掉用戶參數這些沒有什麼必要是小題大做,那麼我覺得你寫不出簡單的函數。從個人經驗來說,函數編寫的建議有以下幾點: ### 不要出現和業務無關的參數 參考我之前的帖子,參數校驗和國際化規範,函數參數里面不要出現local,messagesource,request,Response這些參數,第一非常乾擾閱讀,一堆無關的參數把業務代碼都遮掩住了,第二導致你的函數不好測試,如你要構建一個request參數來測試,還是有一定難度的。 ### 避免使用Map,Json這些複雜對像作為參數和結果 這類參數看著靈活方便,但是靈活的同義詞(代價)就是複雜,最終的結果是可變數多bug多質量差。就好比刻板的同義詞就是嚴謹,最終的結果就是高質量。千萬不要為了偷懶少幾行代碼,就到處把map,json傳來傳去。其實定義一個bean也相當簡單,加上lombok之後,代碼量也沒有幾行,但代碼可讀性就不可同日而語了。做過開發的人應該很容易體會,你如果接手一個項目,到處的輸入輸出都是map的話,request從頭傳到尾,看到這樣的代碼你會哭的,我相信你會馬上崩潰很快離職的。 還有人說用bean的話後面加字段改起來麻煩,你用map還不是一樣要加一個key,不是更加麻煩嗎?說到底就是懶! 如果一個項目的所有代碼都如下面這樣,我是會崩潰的! ```java= /** * ! ! !錯誤代碼示例 * 1. 和業務無關的參數locale,messagesource * 2. 輸入輸出都是map,根本不知道輸入了什麼,返回了什麼 * * @param params * @param local * @param messageSource * @return */ public Map<String, Object> addConfig(Map<String, Object> params, Locale locale, MessageSource messageSource) { Map<String, Object> data = new HashMap<String, Object>(); try { String name = (String) params.get("name"); String value = (String) params.get("value"); //示例代碼,省略其他代碼 } catch (Exception e) { logger.error("add config error", e); data.put("code", 99); data.put("msg", messageSource.getMessage("SYSTEMERROR", null, locale)); } return data; } ``` ### 有明確的輸入輸出和方法名 盡量有清晰的輸入輸出參數,使人一看就知道函數做了啥。舉例: ```java= public void updateUser(Map<String, Object> params){ long userId = (Long) params.get("id"); String nickname = (String) params.get("nickname"); //更新代碼 } ``` 上面的函數,看函數定義你只知道更新了用戶對象,但你不知道更新了用戶的什麼信息。建議寫成下面這樣: ```java= public void updateUserNickName(long userId, String nickname){ //更新代碼 } ``` 你就算不看方法名,只看參數就能知道這個函數只更新了nickname一個字段。多好啊!這是一種思路,但並不是說每一個方法都要寫成這樣。 ### 把可能變化的地方封裝成函數 編寫函數的總體指導思想是抽象和封裝,你要把代碼的邏輯抽像出來封裝成為一個函數,以應對將來可能的變化。以後代碼邏輯有變更的時候,單獨修改和測試這個函數即可。 這一點相當重要,否則你會覺得怎麼需求老變?改代碼煩死了。 如何識別可能變的地方,多思考一下就知道了,工作久了就知道了。比如,開發初期,業務說只有管理員才可以刪除某個對象,你就應該考慮到後面可能除了管理員,其他角色也可能可以刪除,或者說對象的創建者也可以刪除,這就是將來潛在的變化,你寫代碼的時候就要埋下伏筆,把是否能刪除做成一個函數。後面需求變更的時候,你就只需要改一個函數。 舉例,刪除配置項的邏輯,判斷一下只有是自己創建的配置項才可以刪除,一開始代碼是這樣的: ```java= /** * 刪除配置項 */ @Override public boolean delete(long id) { Config config = configs.get(id); if(config == null){ return false; } // 只有自己創建的可以刪除 if (UserUtil.getUser().equals(config.getCreator())) { return configs.remove(id) != null; } return false; } ``` 這裡我會識別一下,是否可以刪除這個地方就有可能會變化,很有可能以後管理員就可以刪除任何人的,那麼這裡就抽成一個函數: ```java= /** * 刪除配置項 */ @Override public boolean delete(long id) { Config config = configs.get(id); if(config == null){ return false; } // 判斷是否可以刪除 if (canDelete(config)) { return configs.remove(id) != null; } return false; } /** * 判斷邏輯變化可能性大,抽取一個函數 * * @param config * @return */ private boolean canDelete(Config config) { return UserUtil.getUser().equals(config.getCreator()); } ``` 後來想了一下,沒有權限應該拋出異常,再次修改為: ```java= /** * 刪除配置項 */ @Override public boolean delete(long id) { Config config = configs.get(id); if (config == null) { return false; } // 判斷是否可以刪除 check(canDelete(config), "no.permission"); return configs.remove(id) != null; } ``` 這就是簡單的抽象和封裝的藝術。看這些代碼,參數多麼的簡單,很容易理解吧。 這一點非常重要,做好了這點,大部分的小的需求變更對程序員的傷害就會降到最低了!畢竟需求變更大部分都是這些小邏輯的變更。 ### 編寫能測試的函數 程序猿不招妹子們喜愛的根本原因在於追求了錯誤的目標:更短、更小、更快。 這個非常重要,當然很難實現,很多人做技術之前都覺得代碼都會做單元測試,實際上和業務相關的代碼單元測試是很難做的。 我覺得要編寫能測試的函數主要有以下幾點: 1. 不要出現亂七八糟的參數,如參數里面有request,response就不好測試, 2. 你要把函數寫小一點。如果一個功能你service代碼只有一個函數,那麼你想做單元測試是很難做到的。我的習慣是盡量寫小一點,力求每一個函數都可以單獨測試(用junit測試或者main函數測試都沒有關係)。這樣會節約大量的時間,尤其是代碼頻繁改動的時候。我們應用重啟一次需要15分鐘以上。新手可以寫一個功能可能需要重啟10幾次,我可能只需要重啟幾次,節約的時候的很可觀的。 3. 你要有單獨測試每一個函數的習慣。不要一上來就測試整個功能,應該一行一行代碼、一個一個函數測試,有了這個習慣,自然就會寫出能測試的小函數。所以說,只有喜歡編碼的人才能寫出好代碼。 如我的編碼習慣 - 配置規範這篇文章了,我的配置相關代碼,都是可以單獨測試的,所以配置項的改動不需要測試業務功能,應用都不需要重啟。 ## 配置規範 工作中少不了要製定各種各樣的配置文件,這里和大家分享一下工作中我是如何制定配置文件的,這是個人習慣,結合強大的spring,效果很不錯。 ### 需求 如我們現在有一個這樣的配置需求,頂層是Server,有port和shutdown2個屬性,包含一個service集合,service對像有name一個屬性,並包含一個connector集合,connector對像有port和protocol2個屬性。 我一上來不會去考慮是用xml還是json還是數據庫配置,我會第一步寫好對應的配置bean。如上面的需求,就寫3個bean。 bean和bean之間的包含關係要體現出來。 (使用了lombok) ```java= @Data public class ServerCfg { private int port = 8005; private String shutDown = "SHUTDOWN"; private List<ServiceCfg> services; } @Data public class ServiceCfg { private String name; private List<ConnectorCfg> connectors; } @Data public class ConnectorCfg { private int port = 8080; private String protocol = "HTTP/1.1"; } ``` 然後找一個地方先用代碼產生這個bean: ```java= @Configuration public class Configs { @Bean public ServerCfg createTestBean() { ServerCfg server = new ServerCfg(); // List<ServiceCfg> services = new ArrayList<ServiceCfg>(); server.setServices(services); // ServiceCfg service = new ServiceCfg(); services.add(service); service.setName("Kitty"); // List<ConnectorCfg> connectors = new ArrayList<ConnectorCfg>(); service.setConnectors(connectors); // ConnectorCfg connectorhttp11 = new ConnectorCfg(); connectorhttp11.setPort(8088); connectorhttp11.setProtocol("HTTP/1.1"); connectors.add(connectorhttp11); // ConnectorCfg connectorAJP = new ConnectorCfg(); connectorAJP.setPort(8089); connectorAJP.setProtocol("AJP"); connectors.add(connectorAJP); return server; } } ``` 然後先測試,看看是否ok。為了演示,我就直接在controller裡面調用一下 ```java= @Autowired ServerCfg cfg; @GetMapping(value = "/configTest") @ResponseBody public ResultBean<ServerCfg> configTest() { return new ResultBean<ServerCfg>(cfg); } ``` 測試一下,工作正常。 ```json= { "port": 8005, "shutDown": "SHUTDOWN", "services": [ { "name": "Kitty", "connectors": [ { "port": 8088, "protocol": "HTTP/1.1", "executor": null }, { "port": 8089, "protocol": "AJP", "executor": null } ] } ] } ``` 然後進行業務代碼編寫,等到所有功能測試完畢,就是【開發後期】,再來定義配置文件。中途當然少不了修改格式,字段等各種修改,對於我們來說只是修改bean定義,so easy。 都ok了,再決定使用哪種配置文件。如果是json,我們這樣: ### JSON格式 把上面接口調用的json複製下來,報存到配置文件。 src/main/resources/config/tomcat.json json內容 ```java= { "port": 8005, "shutDown": "SHUTDOWN", "services": [ { "name": "Kitty", "connectors": [ { "port": 8088, "protocol": "HTTP/1.1", "executor": null }, { "port": 8089, "protocol": "AJP", "executor": null } ] } ] } ``` 然後修改config的bean生成的代碼為: ```java= import com.fasterxml.jackson.databind.ObjectMapper; @Configuration public class Configs { @Value("classpath:config/tomcat.json") File serverConfigJson; @Bean public ServerCfg readServerConfig() throws IOException { return new ObjectMapper().readValue(serverConfigJson, ServerCfg.class); } } ``` 代碼太簡潔了,有沒有? ! ### XML格式 如果使用XML,麻煩一點,我這裡使用XStream序列化和反序列化xml。 首先在bean上增加XStream相關註解 ```java= @Data @XStreamAlias("Server") public class ServerCfg { @XStreamAsAttribute private int port = 8005; @XStreamAsAttribute private String shutDown = "SHUTDOWN"; private List<ServiceCfg> services; } @Data @XStreamAlias("Service") public class ServiceCfg { @XStreamAsAttribute private String name; private List<ConnectorCfg> connectors; } @Data @XStreamAlias("Connector") public class ConnectorCfg { @XStreamAsAttribute private int port = 8080; @XStreamAsAttribute private String protocol = "HTTP/1.1"; } ``` 然後修改產品文件的bean代碼如下: ```java= @Configuration public class Configs { @Value("classpath:config/tomcat.xml") File serverConfigXML; @Bean public ServerCfg readServerConfig() throws IOException { return XMLConfig.toBean(serverConfigXML, ServerCfg.class); } } ``` XMLConfig工具類相關代碼: ```java= public class XMLConfig { public static String toXML(Object obj) { XStream xstream = new XStream(); xstream.autodetectAnnotations(true); // xstream.processAnnotations(Server.class); return xstream.toXML(obj); } public static <T> T toBean(String xml, Class<T> cls) { XStream xstream = new XStream(); xstream.processAnnotations(cls); T obj = (T) xstream.fromXML(xml); return obj; } public static <T> T toBean(File file, Class<T> cls) { XStream xstream = new XStream(); xstream.processAnnotations(cls); T obj = (T) xstream.fromXML(file); return obj; } } ``` XStream庫需要增加以下依賴: ```xml= <dependency> <groupId>com.thoughtworks.xstream</groupId> <artifactId>xstream</artifactId> <version>1.4.10</version> </dependency> ``` 所以個人愛好,格式推薦json格式配置。 ### 配置文件編碼禁忌 讀取配置的代碼和業務代碼耦合在一起 大忌!千萬千萬不要!如下,業務代碼裡面出現了json的配置代碼。 ```java= public void someServiceCode() { // 使用json配置,這裡讀取到了配置文件,返回的是json格式 JSONObject config = readJsonConfig(); // 如果某個配置了 if(config.getBoolean("somekey")){ // dosomething } else{ } } ``` 開發初期就定配置文件 毫無意義,還導致頻繁改動!先定義bean,改bean簡單多了。我的習慣是轉測試前一天才生成配置文件。 手工編寫配置文件 應該先寫完代碼,根據代碼生成配置序列化成對應的格式,而不是自己編寫配置文件然後用代碼讀出來。不要做反了。 ### 重要思想 最主要的思想是,不要直接和配置文件發生關係,一定要有第三者(這裡是配置的bean)。 你可以說是中間件,中介都行。否則,一開始說用xml配置,後面說用json配置,再後面說配置放數據庫?這算不算需求變更?你們說算不算?算嗎?不算嗎?何必這麼認真呢?只是1,2行代碼的問題,這裡使用xml還是json,代碼修改量是2行。而且改了測試的話,寫個main函數或者junit測試即可,不需要測試業務,工程都不用起,你自己算算節約多少時間。 另外,代碼裡面是使用spring的習慣,沒有spring也是一樣的,或者配置的bean你不用spring注入,而用工具類獲取也是一樣,區別不大。 ## 參考 [晓风轻的Spring开发代码模板](https://github.com/xwjie/PLMCodeTemplate) [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html) [alibaba/p3c](https://github.com/alibaba/p3c)