1. 定义规范
1.1. GreenTextResult
package com.test.green;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class GreenTextResult {
private final boolean pass;
private final String message;
private final Map<String, List<String>> detail;
public static GreenTextResult pass(String message) {
return new GreenTextResult(true, message, null);
}
public static GreenTextResult notPass(String message) {
return new GreenTextResult(false, message, null);
}
public static GreenTextResult notPass(Map<String, List<String>> detail) {
return new GreenTextResult(false, "检测到敏感词", detail);
}
}
1.2. GreenTextService
package com.test.green;
import org.springframework.data.util.Pair;
import reactor.core.publisher.Mono;
public interface GreenTextService {
GreenTextResult check(String text) throws Exception;
default Mono<Pair<String, GreenTextResult>> checkReactively(String text) {
return Mono.fromSupplier(() -> {
try {
return Pair.of(text, check(text));
} catch (Exception e) {
return Pair.of(text, GreenTextResult.notPass(e.getMessage()));
}
});
}
}
1.3. GreenTextExtractor
package com.test.green;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.http.server.reactive.ServerHttpRequest;
import reactor.core.publisher.Mono;
import javax.annotation.Nullable;
public interface GreenTextExtractor {
@Nullable
String getTextToCheck(Route route, ServerHttpRequest request, @Nullable Object requestBody);
default Mono<String> getTextToCheckReactively(Route route, ServerHttpRequest request, @Nullable Object requestBody) {
return Mono.fromSupplier(() -> getTextToCheck(route, request, requestBody));
}
}
2. 使用阿里云作为敏感词检测服务
2.1. 引入依赖
<dependencies>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.1.1</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-green</artifactId>
<version>3.6.2</version>
</dependency>
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>2.8.3</version>
</dependency>
</dependencies>
2.2. AliGreenTextConfig
package com.test.green.ali;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "green.text.ali")
public class AliGreenTextConfig {
private String accessKeyId;
private String secret;
private String bizType = "default";
private List<String> scenes = Collections.singletonList("antispam");
private Integer connectTimeout = 3000;
private Integer readTimeout = 6000;
private String regionId = "cn-shanghai";
private String product = "Green";
private String endpoint = "green.cn-shanghai.aliyuncs.com";
}
2.3. AliGreenTextServiceImpl
package com.test.green.ali;
import com.alibaba.fastjson.JSONObject;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.green.model.v20180509.TextScanRequest;
import com.aliyuncs.http.FormatType;
import com.aliyuncs.http.HttpResponse;
import com.aliyuncs.profile.DefaultProfile;
import com.test.green.GreenTextResult;
import com.test.green.GreenTextService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Component
public class AliGreenTextServiceImpl implements GreenTextService {
private static final int MAX_TEXT_LENGTH = 10000;
private static final String UTF_8 = "UTF-8";
private static final GreenTextResult PASS = GreenTextResult.pass("阿里云检测通过");
private static final GreenTextResult EMPTY_TEXT = GreenTextResult.pass("检测文本内容为空");
private static final GreenTextResult TEXT_TOO_LONG = GreenTextResult.notPass("文本长度超出限制");
private static final GreenTextResult ALI_PROCESS_FAIL = GreenTextResult.notPass("阿里云处理请求失败");
@Autowired
private AliGreenTextConfig config;
private IAcsClient client;
@PostConstruct
private void init() {
DefaultProfile dp = DefaultProfile.getProfile(config.getRegionId(), config.getAccessKeyId(), config.getSecret());
DefaultProfile.addEndpoint(config.getRegionId(), config.getProduct(), config.getEndpoint());
this.client = new DefaultAcsClient(dp);
}
@Override
public GreenTextResult check(String text) throws ClientException {
log.debug("待检测文本:{}", text);
if (text.isEmpty()) {
return EMPTY_TEXT;
}
if (text.length() > MAX_TEXT_LENGTH) {
return TEXT_TOO_LONG;
}
HttpResponse httpResponse;
try {
httpResponse = callAliInterface(Collections.singletonList(text));
} catch (ClientException e) {
log.error("阿里云服务调用失败!", e);
throw e;
}
List<TaskResult> taskResults = getTaskResults(httpResponse);
if (taskResults.isEmpty()) {
log.debug("阿里云处理请求失败!");
return ALI_PROCESS_FAIL;
}
return parseTaskResult(taskResults.get(0));
}
private HttpResponse callAliInterface(List<String> texts) throws ClientException {
List<Map<String, Object>> tasks = new ArrayList<>(texts.size());
for (String text : texts) {
Map<String, Object> task = new HashMap<>(2);
task.put("dataId", UUID.randomUUID().toString());
task.put("content", text);
tasks.add(task);
}
JSONObject body = new JSONObject();
body.put("bizType", config.getBizType());
body.put("scenes", config.getScenes());
body.put("tasks", tasks);
TextScanRequest textScanRequest = new TextScanRequest();
textScanRequest.setAcceptFormat(FormatType.JSON);
textScanRequest.setMethod(com.aliyuncs.http.MethodType.POST);
textScanRequest.setEncoding(UTF_8);
textScanRequest.setRegionId(config.getRegionId());
textScanRequest.setConnectTimeout(config.getConnectTimeout());
textScanRequest.setReadTimeout(config.getReadTimeout());
textScanRequest.setHttpContent(body.toJSONString().getBytes(StandardCharsets.UTF_8), UTF_8, FormatType.JSON);
return client.doAction(textScanRequest);
}
private List<TaskResult> getTaskResults(HttpResponse httpResponse) {
return Optional.of(httpResponse)
.filter(HttpResponse::isSuccess)
.map(response -> {
String responseBodyStr = new String(response.getHttpContent(), StandardCharsets.UTF_8);
log.debug("阿里云检测结果:{}", responseBodyStr);
return JsonUtil.parseJson(responseBodyStr, AliResponseBody.class);
})
.filter(responseBody -> responseBody.code == 200)
.map(responseBody -> responseBody.data)
.orElse(Collections.emptyList());
}
private GreenTextResult parseTaskResult(TaskResult taskResult) {
if (taskResult.code != 200) {
return ALI_PROCESS_FAIL;
}
List<SceneResult> invalidSceneResults = taskResult.getResults().stream()
.filter(sceneResult -> !"pass".equals(sceneResult.suggestion))
.collect(Collectors.toList());
if (invalidSceneResults.isEmpty()) {
return PASS;
}
Map<String, List<String>> invalidWordsMap = new HashMap<>();
invalidSceneResults.stream()
.flatMap(invalidSceneResult -> invalidSceneResult.details.stream())
.forEach(labelResult -> {
String label = labelResult.label;
List<String> invalidWords = invalidWordsMap.computeIfAbsent(label, k -> new ArrayList<>());
if (labelResult.contexts != null) {
labelResult.contexts.forEach(context -> invalidWords.add(context.context));
}
});
return GreenTextResult.notPass(invalidWordsMap);
}
@Data
private static class AliResponseBody {
private int code;
private String msg;
private String requestId;
private List<TaskResult> data;
}
@Data
private static class TaskResult {
private int code;
private String msg;
private String dataId;
private String taskId;
private String content;
private String filteredContent;
private List<SceneResult> results;
}
@Data
private static class SceneResult {
private String scene;
private String label;
private double rate;
private String suggestion;
private List<LabelResult> details;
}
@Data
private static class LabelResult {
private String label;
private List<Context> contexts;
}
@Data
private static class Context {
private String context;
private String libCode;
private String libName;
private List<Position> positions;
}
@Data
private static class Position {
private int startPos;
private int endPos;
}
}
3. 实现其它接口
3.1. UrlEncodedBodyUtil
package com.test.util;
import org.springframework.util.MultiValueMap;
import org.springframework.util.MultiValueMapAdapter;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.HashMap;
public class UrlEncodedBodyUtil {
private static final String UTF_8 = "UTF-8";
private static final String PARAM_SPLIT = "&";
private static final char KV_SEPARATOR = '=';
private UrlEncodedBodyUtil() { }
public static MultiValueMap<String, String> parse(String urlEncodedBody) {
String[] parameters = urlEncodedBody.split(PARAM_SPLIT);
MultiValueMap<String, String> map = new MultiValueMapAdapter<>(new HashMap<>(parameters.length));
for (String keyAndValue : parameters) {
int kvSepIdx = keyAndValue.indexOf(KV_SEPARATOR);
if (kvSepIdx == -1) {
map.add(keyAndValue, null);
} else {
String key = keyAndValue.substring(0, kvSepIdx);
String value = urlDecode(keyAndValue.substring(kvSepIdx + 1));
map.add(key, value);
}
}
return map;
}
private static String urlDecode(String text) {
try {
return URLDecoder.decode(text, UTF_8);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
}
3.2. Obj2StrUtil
package com.test.util;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
import java.util.Collection;
import java.util.Map;
@Slf4j
public class Obj2StrUtil {
private static final String MULTI_VALUE_SEPARATOR = " && ";
private static final String MULTI_PARAM_SEPARATOR = " -> ";
public static boolean appendObject(StringBuilder sb, @Nullable Object obj) {
if (obj == null) {
return false;
}
if (isLiteral(obj)) {
String str = obj.toString();
if (str.isEmpty()) {
return false;
}
sb.append(str);
return true;
}
Collection<?> collection;
if (obj instanceof Collection) {
collection = (Collection<?>) obj;
} else if (obj instanceof Map) {
collection = ((Map<?, ?>) obj).values();
} else {
log.error("无法将对象转成字符串!对象:[{}],类型:[{}]", obj, obj.getClass());
throw new RuntimeException("不支持的类型");
}
int oldLength = sb.length();
for (Object item : collection) {
if (appendObject(sb, item)) {
sb.append(MULTI_VALUE_SEPARATOR);
}
}
int newLength = sb.length();
if (newLength == oldLength) {
return false;
}
sb.delete(newLength - MULTI_VALUE_SEPARATOR.length(), newLength);
return true;
}
public static String toString(Map<String, ?> map, @Nullable Collection<String> keys) {
if (keys == null) {
keys = map.keySet();
}
StringBuilder sb = new StringBuilder();
for (String key : keys) {
if (appendObject(sb, map.get(key))) {
sb.append(MULTI_PARAM_SEPARATOR);
}
}
return sb.length() == 0 ? "" : sb.substring(0, sb.length() - MULTI_PARAM_SEPARATOR.length());
}
private static boolean isLiteral(Object value) {
return value instanceof String || value instanceof Number || value instanceof Boolean;
}
}
3.3. ConfigBasedGreenTextExtractorImpl
package com.test.green.impl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.test.green.GreenTextExtractor;
import com.test.util.Obj2StrUtil;
import com.test.util.UrlEncodedBodyUtil;
import lombok.Data;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.MultiValueMap;
import javax.annotation.Nullable;
import java.util.*;
@Slf4j
@Component
@ConfigurationProperties(prefix = "green.text")
public class ConfigBasedGreenTextExtractorImpl implements GreenTextExtractor {
@Data
private static class PathRule {
private List<String> paths;
private Set<HttpMethod> methods;
private List<String> paramsToCheck;
}
@Setter
private boolean enable = true;
@Setter
private Map<String, List<PathRule>> mapping = Collections.emptyMap();
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
@Nullable
public String getTextToCheck(Route route, ServerHttpRequest request, @Nullable Object requestBody) {
if (!enable) {
log.debug("未开启文本检测");
return null;
}
HttpMethod method = request.getMethod();
String path = request.getURI().getPath();
PathRule pathRule = deducePathRule(route, path);
if (pathRule == null || !isMatch(pathRule.getMethods(), method)) {
log.debug("当前接口没有路径规则,或者当前请求方式与规则中的不匹配");
return null;
}
if (method == HttpMethod.GET) {
MultiValueMap<String, String> queryParams = request.getQueryParams();
return Obj2StrUtil.toString(queryParams, pathRule.getParamsToCheck());
}
if (requestBody == null || request.getHeaders().getContentType() == null) {
log.debug("请求体为空,或者未指定请求体类型");
return null;
}
if (!(requestBody instanceof String)) {
log.debug("不支持的请求体数据类型:{}", requestBody.getClass());
return null;
}
String bodyStr = (String) requestBody;
String contentTypeStr = request.getHeaders().getContentType().toString();
if (contentTypeStr.equals(MediaType.APPLICATION_JSON_VALUE)) {
Map<String, Object> bodyMap = JsonUtil.parseJson(bodyStr, new TypeReference<Map<String, Object>>() {});
return Obj2StrUtil.toString(bodyMap, pathRule.getParamsToCheck());
}
if (contentTypeStr.equals(MediaType.APPLICATION_FORM_URLENCODED_VALUE)) {
MultiValueMap<String, String> paramMap = UrlEncodedBodyUtil.parse(bodyStr);
return Obj2StrUtil.toString(paramMap, pathRule.getParamsToCheck());
}
log.debug("不支持的请求体类型:{}", contentTypeStr);
return null;
}
private PathRule deducePathRule(Route route, String path) {
for (PathRule rule : mapping.getOrDefault(route.getId(), Collections.emptyList())) {
for (String pattern : rule.getPaths()) {
if (antPathMatcher.match(pattern, path)) {
return rule;
}
}
}
return null;
}
private static <T> boolean isMatch(@Nullable Set<T> set, T item) {
return set == null || set.contains(item);
}
}
4. 自定义全局过滤器
4.1. RequestBodyCacheFilter
package com.test.filter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
@Component
public class RequestBodyCacheFilter implements GlobalFilter, Ordered {
private static final List<HttpMessageReader<?>> MESSAGE_READERS = HandlerStrategies.withDefaults().messageReaders();
public static final String REQUEST_BODY_CACHE_ATTR = "requestBodyCache";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (MediaType.MULTIPART_FORM_DATA.isCompatibleWith(exchange.getRequest().getHeaders().getContentType())) {
return chain.filter(exchange);
}
return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange, (serverHttpRequest) ->
ServerRequest.create(exchange.mutate().request(serverHttpRequest).build(), MESSAGE_READERS)
.bodyToMono(String.class)
.doOnNext(body -> exchange.getAttributes().put(REQUEST_BODY_CACHE_ATTR, body))
).then(chain.filter(exchange));
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}
4.2. GreenTextFilter
package com.test.filter;
import com.test.green.GreenTextExtractor;
import com.test.green.GreenTextResult;
import com.test.green.GreenTextService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@ConditionalOnProperty(prefix = "green.text", name = "enable", havingValue = "true")
public class GreenTextFilter implements GlobalFilter, Ordered {
private static final String PATH_COUNT_REDIS_KEY_PATTERN = "green:text:counter:%s:%s";
@Autowired
private GreenTextService greenTextService;
@Autowired
private GreenTextExtractor greenTextExtractor;
@Autowired
private RedisTemplate<String, ?> redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
Route route = (Route) exchange.getAttributes().get(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
Object requestBody = exchange.getAttributes().get(RequestBodyCacheFilter.REQUEST_BODY_CACHE_ATTR);
return this.greenTextExtractor.getTextToCheckReactively(route, request, requestBody)
.flatMap(this.greenTextService::checkReactively)
.flatMap(resultPair -> {
String text = resultPair.getFirst();
GreenTextResult result = resultPair.getSecond();
log.debug("检测结果:{}", result);
return increasePathCount(route.getId(), path)
.zipWith(sendRabbitLogMessage(request, text, result))
.then(Mono.just(result));
})
.switchIfEmpty(Mono.fromSupplier(() -> GreenTextResult.pass("无需检测")))
.flatMap(result -> doPassOrReject(exchange, chain, result));
}
private Mono<Long> increasePathCount(String routeId, String path) {
return Mono.fromSupplier(() -> {
String redisKey = String.format(PATH_COUNT_REDIS_KEY_PATTERN, routeId, path);
return redisTemplate.opsForValue().increment(redisKey);
});
}
private Mono<Void> sendRabbitLogMessage(ServerHttpRequest request, String text, GreenTextResult result) {
return Mono.fromRunnable(() -> {
});
}
private Mono<Void> doPassOrReject(ServerWebExchange exchange, GatewayFilterChain chain, GreenTextResult result) {
if (result.isPass()) {
return chain.filter(exchange);
}
Map<String, Object> jsonData = new HashMap<>();
ServerHttpResponse response = exchange.getResponse();
DataBuffer body = response.bufferFactory().wrap(JsonUtil.toJson(jsonData).getBytes(StandardCharsets.UTF_8));
return Mono.defer(() -> {
response.setStatusCode(HttpStatus.OK);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return response.writeWith(Mono.just(body));
});
}
@Override
public int getOrder() {
return 10;
}
}
5. yml配置示例
green:
text:
enable: true
mapping:
my-user-module:
- paths: /user/add
methods: POST
paramsToCheck: username,introduction
- paths: /commons1,/commons2
ali:
accessKeyId: xxx
secret: xxx
logging:
level:
com.test: debug