【中】地址解析和逆地址解析【写个漂亮的代码一】
需求描述
对接一个第三方API,提供地址解析和逆地址解析的功能的Dubbo接口
注:
- 地址解析:传入中文地址,返回经纬度、省、市
- 逆地址解析:传入经纬度,返回省、市
- 第三方服务名 Nominatim
一、接口设计
1-1、原设计
1-1-1、对接第三方接口
- NominatimMapService
- NominatimMapServiceImpl
NominatimMapService 里面提供2个方法用来地址解析和逆地址解析,NominatimResult 就是第三方API返回的结果,不做任何改动,最原始的
public interface NominatimMapService {
/**
* 地址解析
*/
NominatimResult search(NominatimSearchQuery nominatimSearchQuery) throws IllegalAccessException;
/**
* 逆地址解析
*/
NominatimResult reverse(NominatimReverseQuery nominatimReverseQuery) throws IllegalAccessException;
}
NominatimMapServiceImpl 就是去实现NominatimMapService 去调第三方的HTTP接口
1-1-2、Dubbo 接口实现
- MapApi
- MapDubboImpl
MapApi 就是Dubbo对外提供的服务
public interface MapApi {
/**
* 地址解析
* @param address 查询地址
* @return 返回该地址的经纬度信息
*/
Result<GeocodingDTO> geocoding(String address);
/**
* 逆地址解析
* @param reverseGeocodingQuery 经纬度
* @return 返回改经纬度对应的 省、市
*/
Result<ReverseGeocodingDTO> reverseGeocoding(ReverseGeocodingQuery reverseGeocodingQuery);
}
MapDubboImpl 调用NominatimMapService来获取数据,但NominatimMapService返回的数据是最原始的数据,所以MapDubboImpl得到数据之后,还会对数据进行一些七七八八的处理,最终返回Dubbo需要的数据
1-2-3、小结
这个就是我最初的设计,很简单也很清晰,但同样它也存在很多的问题
原本我是考虑抽一层的,不直接让别人使用 NominatimMapService,应该使用一个 MapService,或者弄一个策略模式,让调用者选择一个地图的实现
但考虑到这个地方大概率变的可能性也不大,后面如果需要对接新的第三方,那就再搞一个 xxxMapService ,虽然要修改Dubbo里面的调用者,但感觉不是很麻烦就没做了
1-2、新设计
后续可能做的事有两个
- Nominatim 提供的接口有变动(需要在对接Nominatim的代码进行调整)
- 不用 Nominatim了,换了一个新的第三方
如果换了一个新的第三方,之前的设计对于使用者来说就很伤了,需要把所有用到NominatimMapService的地方换成xxxMapService,而且新的第三方和旧的第三方大概率是返回的不同的数据结构,名称也大概率不一样。
之前在MapDubboImpl做了很多的对NominatimMapService数据的处理,要重新对xxxMapService的数据在做一次处理。
1-2-1、对外提供地址解析和逆地址解析的服务
MapService
public interface MapService {
/**
* 地址解析
*/
GeoAddress searchAddress(String address);
/**
* 逆地址解析
*/
GeoAddress reverseAddress(ReverseAddressQuery reverseGeocodingQuery);
}
1-2-2、对接第三方接口
- NominatimSdk
- NominatimMapServiceImpl
NominatimSdk 就是对接第三方接口的并返回原始的数据,做上面 NominatimMapServiceImpl 的事情
public class NominatimSdk {
public NominatimResult search(NominatimSearchQuery searchQuery) {
logger.info("Nominatim地址解析: {}", searchQuery);
}
public NominatimResult reverse(NominatimReverseQuery reverseQuery) {
logger.info("Nominatim逆地址解析: {}", reverseQuery);
}
}
NominatimMapServiceImpl 去实现 MapService,调用 NominatimSdk 对返回的数据进行处理,返回满足 MapService定义的数据
1-2-3、Dubbo 接口实现
- MapApi
- MapDubboImpl
MapApi 还是一样对外提供的服务, MapDubboImpl 就不一样了,原本是调用NominatimMapService,现在是调用MapService,MapService的数据其实已经很标准了,只需要简单处理或不处理就可以返回了
1-2-4、小结
经过设计的改变,如果以后Nominatim 接口变了,只需要去修改 NominatimSdk。如果接口改的太多那只需要修改 NominatimSdk和NominatimMapServiceImpl
如果服务的提供商修改了,那只需要再重写两个 xxxSdk和xxxMapServiceImpl 就可以了,然后不注入 NominatimMapServiceImpl,Dubbo层面的代码不需要改动
如果后续需要多个供应商选择使用,那需要再包装一层策略,就可实现,改动依然很小
扩展有一个原则就是不修改之前的代码,这就完美的实现了
二、代码可读性
第三方接口返回的省市区,并不是在固定的字段上,而是一个json对象,类似下面这种,所以需要写一个方法来获取省、市
{
"level4": "广东省",
"level5": "深圳市",
"level6": "南山区"
}
2-1、原设计
/**
* 解析 NominatimResult 返回的 省、市
* @param result
* @return
*/
private List<String> parseNominatimResult(NominatimResult result) {
List<String> resultList = new ArrayList<>(2);
List<Map<String, String>> admins = result.getAdmin();
if (CollectionUtils.isNotEmpty(admins)) {
// 优化行政区域解析逻辑
Map<String, String> firstMap = admins.get(0);
String firstLevel = firstMap.values().iterator().next();
resultList.add(firstLevel);
if (admins.size() > 1 && !MUNICIPALITY.contains(firstLevel)) {
Map<String, String> secondMap = admins.get(1);
resultList.add(secondMap.values().iterator().next());
} else {
resultList.add(firstLevel);
}
} else {
resultList.addAll(Lists.newArrayList("",""));
}
return resultList;
}
2-2、新设计
/**
* PROVINCE 和 CITY 解析省、市时候用作KEY存储数据
*/
private static final String PROVINCE = "province";
private static final String CITY = "city";
/**
* 解析 NominatimResult 返回的 省、市
*
* admin返回的集合里面已经按照等级从大到小排好序了
* 取List的第一个元素作为省,第二个元素作为市,如果只有一个元素或第一个元素等于直辖市就让第二个元素等于第一个元素
* 如果admin为空,就直接返回空字符串
*/
private Map<String, String> calculateAdministrativeRegions(NominatimResult result) {
String province = "";
String city = "";
List<Map<String, String>> admins = result.getAdmin();
if (CollectionUtils.isEmpty(admins)) {
return buildProvinceAndCityMap(province, city);
}
province = getProvince(admins);
city = getCity(admins, province);
return buildProvinceAndCityMap(province, city);
}
/**
* admins 数据结构
*{
* "level4": "广东省",
* "level5": "深圳市",
* "level6": "南山区"
* }
*/
private static String getProvince(List<Map<String, String>> admins) {
Map<String, String> firstMap = admins.get(0);
return firstMap.values().iterator().next();
}
private static String getCity(List<Map<String, String>> admins, String province) {
String city;
if (admins.size() > 1 && !MUNICIPALITY.contains(province)) {
Map<String, String> secondMap = admins.get(1);
city = secondMap.values().iterator().next();
} else {
city = province;
}
return city;
}
private static Map<String, String> buildProvinceAndCityMap(String province, String city) {
Map<String, String> administrativeMap = new HashMap<>();
administrativeMap.put(PROVINCE, province);
administrativeMap.put(CITY, city);
return administrativeMap;
}
2-3、小结
组长: firstMap.values().iterator().next(); 这段代码写的没人看得懂 我:这是为了取Map里面的第一个元素,我知道它里面有且只有一个元素,我不想用for取,没有其它办法了 组长:问题就在这,只有你知道这个Map里面有且只有一个元素,我看的时候想半天不知道为啥这样。如果提供 getProvince 和 getCity 方法是不是就好理解了呢 我:这段代码也就十来行,真的有必要抽那么多个小的方法吗?不会过度设计吗? 组长:并不会,抽小了读的人直接就理解了 我:的确如果从读的角度来讲,确实如此(说实话只是读的话,我也更愿意看第二种写法)
三、对使用方友好
这次的开发的最终体现其实是提供2个Dubbo接口出去,降低服务使用方的负担也是应当考虑的
## 3-1、原设计
public interface MapApi {
/**
* 地址解析
* @param address 查询地址
* @return 返回该地址的经纬度信息
*/
Result<GeocodingDTO> geocoding(String address);
/**
* 逆地址解析
* @param reverseGeocodingQuery 经纬度
* @return 返回改经纬度对应的 省、市
*/
Result<ReverseGeocodingDTO> reverseGeocoding(ReverseGeocodingQuery reverseGeocodingQuery);
}
3-2、新设计
public interface MapApi {
/**
* 地址解析
* @param address 查询地址
* @return 返回该地址的经纬度信息
*/
Result<GeoAddressDTO> searchAddress(String address);
/**
* 逆地址解析
* @param reverseAddressQuery 经纬度
* @return 返回改经纬度对应的 省、市
*/
Result<GeoAddressDTO> reverseAddress(ReverseAddressQuery reverseAddressQuery);
}
3-3、小结
不管是地址解析还是逆地址解析,其实返回的都是数据库中的某条数据,称之为一个资源。所以对于这两个方法其实可以用一个对象做返回值,且这个对象里面最好不要有这个“资源”没有字段。(关于这点我暂时也不是很能理解) 一个对象的话,调用方也不需要去理解两个对象,也减少了理解的成本
geocoding 是地址编码,毫无疑问它没有searchAddress 好理解
四、更优雅的实现
本质上和第三方对接,大多数时候就是HTTP调用
4-1、原设计
接口地址定义
private static final String SEARCH_URL = "/search?1=1";
private static final String REVERSE_URL = "/reverse?1=1";
地址参数拼接
private String buildParamsUrl(String url, Object params) throws IllegalAccessException {
StringBuilder stringBuilder = new StringBuilder(nominatimRequestPrefix);
stringBuilder.append(url);
Class<?> clazz = params.getClass();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
String fieldName = field.getName();
Object fieldValue = field.get(params);
if (fieldValue != null) {
if (fieldName.equals("acceptLanguage")) {
stringBuilder.append("&").append("accept-language").append("=").append(fieldValue);
} else {
stringBuilder.append("&").append(fieldName).append("=").append(fieldValue);
}
}
}
return stringBuilder.toString();
}
发起请求
private NominatimResult baseRequest(String url) {
long startTime = System.currentTimeMillis();
logger.info("nominatim请求url: {}", url);
String response;
try {
response = HttpUtils.get(url, null);
logger.info("nominatim耗时:{} : response: {}", System.currentTimeMillis() - startTime, response);
}catch (MalformedURLException e) {
logger.error("nominatim请求异常", e);
throw new BizException(ErrorMessage.NOMINATIM_REQUEST_ERROR);
}
if (response == null) {
logger.error("nominatim请求异常");
throw new BizException(ErrorMessage.NOMINATIM_REQUEST_ERROR);
}
if (response.contains("error")) {
return null;
}
return NominatimResult.toObjectByJson(response);
}
这里代码的修改,争议了很久,大家可以先看我写的【原设计】看看有什么问题
4-2、新设计
接口地址定义
private static final String SEARCH_URL = "/search";
private static final String REVERSE_URL = "/reverse";
基于充血模式的构建参数
public class NominatimSearchQuery {
// 省略构造方法、字段、get、set 等
public MultiValueMap<String, String> toMultiValueMap() {
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();
multiValueMap.add("format", getFormat());
multiValueMap.add("addressdetails", String.valueOf(getAddressdetails()));
multiValueMap.add("limit", String.valueOf(getLimit()));
multiValueMap.add("accept-language", getAcceptLanguage());
if (StringUtils.isNotBlank(getQ())) {
multiValueMap.add("q", getQ());
}
return multiValueMap;
}
}
发起请求
private NominatimResult doRequest(String url, MultiValueMap<String, String> params) {
long startTime = System.currentTimeMillis();
String response;
try {
String uriString = UriComponentsBuilder
.fromHttpUrl(nominatimHost + url)
.queryParams(params)
.encode(StandardCharsets.US_ASCII)
.toUriString();
logger.info("nominatim请求url: {}", uriString);
response = restTemplate.getForObject(uriString, String.class);
logger.info("nominatim耗时:{} : response: {}", System.currentTimeMillis() - startTime, response);
}catch (RestClientException e) {
logger.error("nominatim请求异常", e);
throw new BizException(ErrorMessage.NOMINATIM_REQUEST_ERROR);
}
if (response == null) {
logger.error("nominatim请求异常");
throw new BizException(ErrorMessage.NOMINATIM_REQUEST_ERROR);
}
if (response.contains("error")) {
return null;
}
return NominatimResult.toObjectByJson(response);
}
4-3、小结
我使用反射的目的是为了让方法通用性更强一点,这样后续不管新增什么参数都不用改这块的代码 组长的意思是反射使用 getDeclaredFields 获取参数如果有人后续用来继承就会丢失参数,而且反射的方法也很不优雅,自己拼装参数还要考虑第一个参数的 ? 和 & 的问题 (上面 1=1 就是为了解决这个)
后面找了很久,只有在查询里面提供一个 toMultiValueMap 才解决这个问题