以下所有代码均是伪代码, 并不保证运行,如果需要完整代码私聊我
异常处理由来
- 前端给用户显示提示信息
- 后端记录异常日志,查错,并保证程序正常运行
前端提示信息
前端代码
- 前端根据后端给的errorCode判断,然后前端给出提示信息
// 发起http请求
result = this.axios.get("xxxx");
// 如果成功直接返回结果
if (result.status == 200) {
return result;
}
// 不成功根据errorCode 弹出提示信息
errorCode = result.errorCode;
if (errorCode == 100000) {
alert("用户未登录");
} else if (errorCode == 100001) {
alert("用户未认证");
} else if() {}
...........等等一系列判断
- 前端直接显示后端直接给的提示信息
// 发起http请求
result = this.axios.get("xxxx");
if (result.status != 200) {
alert(result.msg);
}
根据上面前端的代码来看,前端肯定喜欢第二种方式。
而且如果我们要做国际化,前端要维护的东西就更多,更复杂了。
后端代码
设计要素:
- 要记录日志,出的什么错,错误栈,相关的输入参数。
- 返回提示信息,可以支持国际化
- 错误信息存储在内存中提高效率,同时是否可以支持运行时更新提示信息
初步设计
//自定义异常类型枚举
public enum ExceptionTypeEnum {
USER_NOT_EXIST(100002),
USER_NOT_AUTH(100001),
USER_NOT_LOGIN(100000);
final int code;
ExceptionTypeEnum(int code) { this.code = code; }
public int getCode() { return code; }
public String getName() { return name(); }
}
// 自定义异常类
public class ServiceException extends Exception {
// 要记录在日志里面的相关输入参数
private final String logDataString;
// 前端提示信息
private final String tipMessage;
// 出错的类型,这里使用枚举类型,定义出所有的可能出错类型
private final ExceptionTypeEnum exceptionTypeEnum;
// 省略掉getter , setter 和构造方法
// 返回异常类型的名字
public String getExceptionTypeEnumName() {
return exceptionTypeEnum.name();
}
}
抛出异常
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/exception/i18n")
public void testExceptionI18n(@RequestParam String username) throws ServiceException {
throw new ServiceException(
ExceptionTypeEnum.USER_NOT_LOGIN,
"logDataString username = " + username,
"tipMessage 用户未登录"
);
}
}
SpringBoot 全局异常处理
@ControllerAdvice
@RestController
public class GlobalExceptionHandler {
@Autowired
private MessageSource messageSource;
@ExceptionHandler(ServiceException.class)
public final String serviceExceptionHandler(ServiceException serviceException, WebRequest request) {
//记录出错异常类型名
log.info(serviceException.getExceptionTypeEnumName());
// 记录出错时相关输入数据到日志里
log.info(serviceException.getLogDataString());
// 这里直接返回提示信息,理论上应该包装成一个统一返回格式的
return serviceException.getTipMessage();
}
}
初步设计是可以满足后端记录出错类型和相关参数的。
前端也可以直接将返回的提示信息输出。
缺点:
- 代码中出现大量中文,污染代码
- 不能支持国际化,如果要支持,得修改每一处抛出异常的地方,不利于扩展
- 不能运行期间修改提示信息
进阶设计
考虑到springboot本身支持国际化,我们利用这个改造
在resource文件下新建i18n文件夹
这里需要支持几国语言就建立几个对应文件
在i18n下新建exception.properties文件
USER_NOT_EXIST = 用户不存在
USER_NOT_LOGIN = 用户未登录,请先登录
在i18n下新建exception_en_US.properties文件
USER_NOT_EXIST = User not exist
USER_NOT_LOGIN = User not login
在application.properties文件中添加如下配置
spring.messages.basename=i18n/exception
spring.messages.encoding=UTF-8
抛出异常
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/exception/i18n")
public void testExceptionI18n(@RequestParam String username) throws ServiceException {
throw new ServiceException(
ExceptionTypeEnum.USER_NOT_LOGIN,
"logDataString username = " + username
);
}
}
全局异常处理返回提示信息支持国际化
@ControllerAdvice
@RestController
public class GlobalExceptionHandler {
@Autowired
private MessageSource messageSource;
@ExceptionHandler(ServiceException.class)
public final String serviceExceptionHandler(ServiceException serviceException, WebRequest request) {
// 获取当前国家
Locale locale = LocaleContextHolder.getLocale();
String message;
try {
//通过messageSource获取对应异常类型名的提示信息
message = messageSource.getMessage(serviceException.getName(), null, locale);
} catch (Exception e) {
e.printStackTrace();
// 如果不存在, 就直接返回异常类型名
message = serviceException.getName();
}
return message;
}
}
国际化支持完毕,同时我们将提示语从代码中摘除了,放到了配置文件中,
方便统一管理,代码也干净了许多
运行期间修改提示信息怎么搞呢?
很自然就能想到配置中心
这里使用nacos存储配置文件。
下面是项目启动后从nacos下载配置文件,并存储到classpath下给国际化MessageSource使用。
同时添加监听器,监听nacos配置中心是否更改了内容,
如果内容修改了,就更新到本地
import com.alibaba.nacos.api.NacosFactory;
import com.alibaba.nacos.api.PropertyKeyConst;
import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.config.listener.Listener;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.ResourceUtils;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Locale;
import java.util.Properties;
import java.util.concurrent.Executor;
@Slf4j
@Component
public class NacosConfig {
private final String baseFolder = "i18n";
private final String baseName = "exception";
/**
* 应用名称
*/
@Value("${spring.application.name}")
private String applicationName;
private String serverAddr;
private String dNamespace;
@Autowired
public void init() {
serverAddr = "ip:8848";
dNamespace = DEFAULT_NAMESPACE;
initTip(null);
initTip(Locale.CHINA);
initTip(Locale.US);
log.info("初始化系统参数成功!应用名称:{},Nacos地址:{},提示语命名空间:{}", applicationName, serverAddr, dNamespace);
}
private void initTip(Locale locale) {
String content = null;
String dataId = null;
ConfigService configService = null;
try {
if (locale == null) {
dataId = baseName + ".properties";
} else {
dataId = baseName + "_" + locale.getLanguage() + "_" + locale.getCountry() + ".properties";
}
Properties properties = new Properties();
properties.put(PropertyKeyConst.SERVER_ADDR, serverAddr);
properties.put(PropertyKeyConst.NAMESPACE, dNamespace);
configService = NacosFactory.createConfigService(properties);
content = configService.getConfig(dataId, DEFAULT_GROUP, 5000);
if (StringUtils.isEmpty(content)) {
log.warn("配置内容为空,跳过初始化!dataId:{}", dataId);
return;
}
log.info("初始化国际化配置!配置内容:{}", content);
saveAsFileWriter(dataId, content);
setListener(configService, dataId, locale);
} catch (Exception e) {
log.error("初始化国际化配置异常!异常信息:{}", e);
}
}
private void setListener(ConfigService configService, String dataId, Locale locale) throws com.alibaba.nacos.api.exception.NacosException {
configService.addListener(dataId, DEFAULT_GROUP, new Listener() {
@Override
public void receiveConfigInfo(String configInfo) {
log.info("接收到新的国际化配置!配置内容:{}", configInfo);
try {
initTip(locale);
} catch (Exception e) {
log.error("初始化国际化配置异常!异常信息:{}", e);
}
}
@Override
public Executor getExecutor() {
return null;
}
});
}
private void saveAsFileWriter(String fileName, String content) throws FileNotFoundException {
String projectPath = ResourceUtils.getURL("classpath:").getPath();
String oldPath = System.getProperty("user.dir");
log.info("============================");
log.info(projectPath);
log.info(oldPath);
log.info("============================");
String path = projectPath + File.separator + baseFolder;
try {
fileName = path + File.separator + fileName;
File file = new File(fileName);
FileUtils.writeStringToFile(file, content);
log.info("国际化配置已更新!本地文件路径:{}", fileName);
} catch (IOException e) {
log.error("初始化国际化配置异常!本地文件路径:{}异常信息:{}", fileName, e);
}
}
@Autowired
private ConfigurableApplicationContext applicationContext;
private static final String DEFAULT_GROUP = "DEFAULT_GROUP";
private static final String DEFAULT_NAMESPACE = "758eb78f-aae6-4393-96e4-a3ed5e8111c2";
}
nacos这里是参考了一篇博客
Nacos实现SpringBoot国际化的增强
这篇博客里面的messageSource 使用的是ResourceBundleMessageSource,但是据说这个类不能更新配置文件。
同时,这个博客存储配置文件的时候有个坑,path这里得设置成classpath:i18n/exception
最后膜拜一下大佬。
顺便吐槽一下,nacos是真的好用,但是文档太简略了,也可能是我太菜了,汗。
我用的是ReloadableResourceBundleMessageSource ,配置文件缓存设置了一秒,
如果线上使用,考虑效率要设置的久一点。
@Configuration
public class SpringConfig {
@Bean
protected MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:i18n/exception");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setCacheSeconds(1);
return messageSource;
}
}
至此,我们修改nacos相关的配置文件。NacosConfig类里面设置的监听器就会接收到信号。
然后将最新的文件从nacos配置中心读取到,再覆盖掉本地的配置文件。
等到messageSource设置的缓存时间到期,就会把新的配置文件读取到缓存中。
完美!!!