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(); } }