package com.ctrip.framework.apollo.configservice.controller;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.RemovalListener;
import com.google.common.cache.RemovalNotification;
import com.google.common.cache.Weigher;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import com.google.gson.Gson;
import com.ctrip.framework.apollo.biz.entity.ReleaseMessage;
import com.ctrip.framework.apollo.biz.grayReleaseRule.GrayReleaseRulesHolder;
import com.ctrip.framework.apollo.biz.message.ReleaseMessageListener;
import com.ctrip.framework.apollo.biz.message.Topics;
import com.ctrip.framework.apollo.configservice.util.NamespaceUtil;
import com.ctrip.framework.apollo.configservice.util.WatchKeysUtil;
import com.ctrip.framework.apollo.core.ConfigConsts;
import com.ctrip.framework.apollo.core.dto.ApolloConfig;
import com.ctrip.framework.apollo.core.utils.PropertiesUtil;
import com.ctrip.framework.apollo.tracer.Tracer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author Jason Song(song_s@ctrip.com)
*/
@RestController
@RequestMapping("/configfiles")
public class ConfigFileController implements ReleaseMessageListener {
private static final Logger logger = LoggerFactory.getLogger(ConfigFileController.class);
private static final Joiner STRING_JOINER = Joiner.on(ConfigConsts.CLUSTER_NAMESPACE_SEPARATOR);
private static final Splitter X_FORWARDED_FOR_SPLITTER = Splitter.on(",").omitEmptyStrings()
.trimResults();
private static final long MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50MB
private static final long EXPIRE_AFTER_WRITE = 30;
private final HttpHeaders propertiesResponseHeaders;
private final HttpHeaders jsonResponseHeaders;
private final ResponseEntity<String> NOT_FOUND_RESPONSE;
private Cache<String, String> localCache;
private final Multimap<String, String>
watchedKeys2CacheKey = Multimaps.synchronizedSetMultimap(HashMultimap.create());
private final Multimap<String, String>
cacheKey2WatchedKeys = Multimaps.synchronizedSetMultimap(HashMultimap.create());
private static final Gson gson = new Gson();
@Autowired
private ConfigController configController;
@Autowired
private NamespaceUtil namespaceUtil;
@Autowired
private WatchKeysUtil watchKeysUtil;
@Autowired
private GrayReleaseRulesHolder grayReleaseRulesHolder;
public ConfigFileController() {
localCache = CacheBuilder.newBuilder()
.expireAfterWrite(EXPIRE_AFTER_WRITE, TimeUnit.MINUTES)
.weigher(new Weigher<String, String>() {
@Override
public int weigh(String key, String value) {
return value == null ? 0 : value.length();
}
})
.maximumWeight(MAX_CACHE_SIZE)
.removalListener(new RemovalListener<String, String>() {
@Override
public void onRemoval(RemovalNotification<String, String> notification) {
String cacheKey = notification.getKey();
logger.debug("removing cache key: {}", cacheKey);
if (!cacheKey2WatchedKeys.containsKey(cacheKey)) {
return;
}
//create a new list to avoid ConcurrentModificationException
List<String> watchedKeys = new ArrayList<>(cacheKey2WatchedKeys.get(cacheKey));
for (String watchedKey : watchedKeys) {
watchedKeys2CacheKey.remove(watchedKey, cacheKey);
}
cacheKey2WatchedKeys.removeAll(cacheKey);
logger.debug("removed cache key: {}", cacheKey);
}
})
.build();
propertiesResponseHeaders = new HttpHeaders();
propertiesResponseHeaders.add("Content-Type", "text/plain;charset=UTF-8");
jsonResponseHeaders = new HttpHeaders();
jsonResponseHeaders.add("Content-Type", "application/json;charset=UTF-8");
NOT_FOUND_RESPONSE = new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
@RequestMapping(value = "/{appId}/{clusterName}/{namespace:.+}", method = RequestMethod.GET)
public ResponseEntity<String> queryConfigAsProperties(@PathVariable String appId,
@PathVariable String clusterName,
@PathVariable String namespace,
@RequestParam(value = "dataCenter", required = false) String dataCenter,
@RequestParam(value = "ip", required = false) String clientIp,
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
String result =
queryConfig(ConfigFileOutputFormat.PROPERTIES, appId, clusterName, namespace, dataCenter,
clientIp, request, response);
if (result == null) {
return NOT_FOUND_RESPONSE;
}
return new ResponseEntity<>(result, propertiesResponseHeaders, HttpStatus.OK);
}
@RequestMapping(value = "/json/{appId}/{clusterName}/{namespace:.+}", method = RequestMethod.GET)
public ResponseEntity<String> queryConfigAsJson(@PathVariable String appId,
@PathVariable String clusterName,
@PathVariable String namespace,
@RequestParam(value = "dataCenter", required = false) String dataCenter,
@RequestParam(value = "ip", required = false) String clientIp,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
String result =
queryConfig(ConfigFileOutputFormat.JSON, appId, clusterName, namespace, dataCenter,
clientIp, request, response);
if (result == null) {
return NOT_FOUND_RESPONSE;
}
return new ResponseEntity<>(result, jsonResponseHeaders, HttpStatus.OK);
}
String queryConfig(ConfigFileOutputFormat outputFormat, String appId, String clusterName,
String namespace, String dataCenter, String clientIp,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
//strip out .properties suffix
namespace = namespaceUtil.filterNamespaceName(namespace);
if (Strings.isNullOrEmpty(clientIp)) {
clientIp = tryToGetClientIp(request);
}
//1. check whether this client has gray release rules
boolean hasGrayReleaseRule = grayReleaseRulesHolder.hasGrayReleaseRule(appId, clientIp,
namespace);
String cacheKey = assembleCacheKey(outputFormat, appId, clusterName, namespace, dataCenter);
//2. try to load gray release and return
if (hasGrayReleaseRule) {
Tracer.logEvent("ConfigFile.Cache.GrayRelease", cacheKey);
return loadConfig(outputFormat, appId, clusterName, namespace, dataCenter, clientIp,
request, response);
}
//3. if not gray release, check weather cache exists, if exists, return
String result = localCache.getIfPresent(cacheKey);
//4. if not exists, load from ConfigController
if (Strings.isNullOrEmpty(result)) {
Tracer.logEvent("ConfigFile.Cache.Miss", cacheKey);
result = loadConfig(outputFormat, appId, clusterName, namespace, dataCenter, clientIp,
request, response);
if (result == null) {
return null;
}
//5. Double check if this client needs to load gray release, if yes, load from db again
//This step is mainly to avoid cache pollution
if (grayReleaseRulesHolder.hasGrayReleaseRule(appId, clientIp, namespace)) {
Tracer.logEvent("ConfigFile.Cache.GrayReleaseConflict", cacheKey);
return loadConfig(outputFormat, appId, clusterName, namespace, dataCenter, clientIp,
request, response);
}
localCache.put(cacheKey, result);
logger.debug("adding cache for key: {}", cacheKey);
Set<String> watchedKeys =
watchKeysUtil.assembleAllWatchKeys(appId, clusterName, namespace, dataCenter);
for (String watchedKey : watchedKeys) {
watchedKeys2CacheKey.put(watchedKey, cacheKey);
}
cacheKey2WatchedKeys.putAll(cacheKey, watchedKeys);
logger.debug("added cache for key: {}", cacheKey);
} else {
Tracer.logEvent("ConfigFile.Cache.Hit", cacheKey);
}
return result;
}
private String loadConfig(ConfigFileOutputFormat outputFormat, String appId, String clusterName,
String namespace, String dataCenter, String clientIp,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
ApolloConfig apolloConfig = configController.queryConfig(appId, clusterName, namespace,
dataCenter, "-1", clientIp, request, response);
if (apolloConfig == null || apolloConfig.getConfigurations() == null) {
return null;
}
String result = null;
switch (outputFormat) {
case PROPERTIES:
Properties properties = new Properties();
properties.putAll(apolloConfig.getConfigurations());
result = PropertiesUtil.toString(properties);
break;
case JSON:
result = gson.toJson(apolloConfig.getConfigurations());
break;
}
return result;
}
String assembleCacheKey(ConfigFileOutputFormat outputFormat, String appId, String clusterName,
String namespace,
String dataCenter) {
List<String> keyParts =
Lists.newArrayList(outputFormat.getValue(), appId, clusterName, namespace);
if (!Strings.isNullOrEmpty(dataCenter)) {
keyParts.add(dataCenter);
}
return STRING_JOINER.join(keyParts);
}
@Override
public void handleMessage(ReleaseMessage message, String channel) {
logger.info("message received - channel: {}, message: {}", channel, message);
String content = message.getMessage();
if (!Topics.APOLLO_RELEASE_TOPIC.equals(channel) || Strings.isNullOrEmpty(content)) {
return;
}
if (!watchedKeys2CacheKey.containsKey(content)) {
return;
}
//create a new list to avoid ConcurrentModificationException
List<String> cacheKeys = new ArrayList<>(watchedKeys2CacheKey.get(content));
for (String cacheKey : cacheKeys) {
logger.debug("invalidate cache key: {}", cacheKey);
localCache.invalidate(cacheKey);
}
}
enum ConfigFileOutputFormat {
PROPERTIES("properties"), JSON("json");
private String value;
ConfigFileOutputFormat(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
private String tryToGetClientIp(HttpServletRequest request) {
String forwardedFor = request.getHeader("X-FORWARDED-FOR");
if (!Strings.isNullOrEmpty(forwardedFor)) {
return X_FORWARDED_FOR_SPLITTER.splitToList(forwardedFor).get(0);
}
return request.getRemoteAddr();
}
}