电商SAAS系统的数据隔离组件设计
问题:
电商SAAS系统的数据隔离组件如何设计,我采用 共享数据库,共享数据表,行级别隔离方案。
系统采用Spring Boot 2.x,网关框架使用Spring Cloud Gateway。 ORM 框架采用MyBatis_plus,
数据库采用MySQL,rpc使用Feign。数据隔离组件的核心功能,实现租户身份识别:
在每个请求中,能够准确识别当前租户的身份。
可以通过 HTTP Header、Session、Token 等方式传递租户 ID。
数据访问控制:
在数据访问层,根据租户 ID 自动添加数据过滤条件。
确保每个租户只能访问自己的数据。
可以使用 AOP、拦截器等技术实现。
。帮忙设计与编码实现租户身份识别、数据访问控制,租户id可以在网关与微服务、微服务之间传递,遵循最佳实践
-----------grok3----------
在电商 SAAS 系统中,采用共享数据库、共享数据表、行级别隔离的方案,需要设计一个数据隔离组件来实现租户身份识别和数据访问控制。以下是一个详细的设计与实现方案,涵盖租户身份识别、租户 ID 传递、数据访问控制等功能,基于 Spring Boot 2.x、Spring Cloud Gateway、MyBatis-Plus 和 MySQL。
---
### 一、总体设计
#### 1. 核心功能
- **租户身份识别**:
- 通过 HTTP Header、Token 或 Session 传递租户 ID。
- 在网关层解析租户 ID 并传递到下游微服务。
- 在微服务内部,通过 ThreadLocal 存储租户 ID,确保线程安全。
- **数据访问控制**:
- 在 MyBatis-Plus 的 SQL 执行过程中,自动为查询和更新操作添加租户 ID 的过滤条件。
- 使用 AOP 或 MyBatis-Plus 插件实现数据隔离。
- **租户 ID 传递**:
- 在网关与微服务之间,通过 HTTP Header 传递租户 ID。
- 在微服务之间,通过 Feign 或 RestTemplate 传递租户 ID。
#### 2. 技术选型
- **框架**:Spring Boot 2.x、Spring Cloud Gateway、MyBatis-Plus。
- **数据库**:MySQL。
- **租户 ID 存储**:ThreadLocal(线程安全)。
- **数据隔离**:MyBatis-Plus 插件 + AOP。
- **租户 ID 传递**:HTTP Header + Feign 拦截器。
---
### 二、详细设计与实现
#### 1. 租户身份识别
##### (1) 网关层解析租户 ID
在 Spring Cloud Gateway 中,通过全局过滤器(GlobalFilter)解析租户 ID,并将其添加到 HTTP Header 中,传递到下游微服务。
**代码实现:**
```java
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class TenantIdFilter implements GlobalFilter, Ordered {
private static final String TENANT_ID_HEADER = "X-Tenant-Id";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 从请求头中获取租户 ID(假设通过 Token 或 Header 传递)
String tenantId = resolveTenantId(request);
// 将租户 ID 添加到请求头中,传递到下游微服务
ServerHttpRequest modifiedRequest = request.mutate()
.header(TENANT_ID_HEADER, tenantId)
.build();
return chain.filter(exchange.mutate().request(modifiedRequest).build());
}
private String resolveTenantId(ServerHttpRequest request) {
// 从 Header 中获取租户 ID
String tenantId = request.getHeaders().getFirst(TENANT_ID_HEADER);
if (tenantId == null) {
// 如果 Header 中没有,可以从 Token 或其他方式解析
// 示例:从 Token 中解析(需要结合认证服务)
tenantId = "default-tenant"; // 假设默认租户
}
return tenantId;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // 优先级最高
}
}
```
##### (2) 微服务层存储租户 ID
在微服务中,通过 Filter 或拦截器从 HTTP Header 中获取租户 ID,并将其存储到 ThreadLocal 中,供后续使用。
**代码实现:**
```java
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class TenantContextFilter extends OncePerRequestFilter {
private static final String TENANT_ID_HEADER = "X-Tenant-Id";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 从请求头中获取租户 ID
String tenantId = request.getHeader(TENANT_ID_HEADER);
// 存储到 ThreadLocal
TenantContextHolder.setTenantId(tenantId);
try {
filterChain.doFilter(request, response);
} finally {
// 请求结束后清理 ThreadLocal,防止内存泄漏
TenantContextHolder.clear();
}
}
}
```
**TenantContextHolder 实现:**
```java
public class TenantContextHolder {
private static final ThreadLocal<String> TENANT_ID_HOLDER = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
TENANT_ID_HOLDER.set(tenantId);
}
public static String getTenantId() {
return TENANT_ID_HOLDER.get();
}
public static void clear() {
TENANT_ID_HOLDER.remove();
}
}
```
#### 2. 数据访问控制
##### (1) MyBatis-Plus 租户插件
MyBatis-Plus 提供了租户插件(TenantLineInnerInterceptor),可以自动为 SQL 添加租户 ID 的过滤条件。
**步骤:**
1. 在数据库表中添加 `tenant_id` 列,用于存储租户 ID。
2. 配置 MyBatis-Plus 的租户插件,自动为 SQL 添加 `tenant_id = 当前租户 ID` 的条件。
**代码实现:**
```java
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.StringValue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 从 ThreadLocal 中获取当前租户 ID
String tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
throw new IllegalStateException("Tenant ID not found in context");
}
return new StringValue(tenantId);
}
@Override
public String getTenantIdColumn() {
// 指定租户 ID 列名
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
// 忽略不需要租户隔离的表(例如系统配置表)
return "sys_config".equalsIgnoreCase(tableName);
}
}));
return interceptor;
}
}
```
##### (2) 数据插入时的租户 ID 填充
在插入数据时,需要自动填充 `tenant_id` 字段。可以通过 MyBatis-Plus 的字段填充功能实现。
**代码实现:**
```java
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
@Component
public class TenantMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 在插入时自动填充 tenant_id
String tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
this.strictInsertFill(metaObject, "tenantId", String.class, tenantId);
}
}
@Override
public void updateFill(MetaObject metaObject) {
// 更新时不填充 tenant_id
}
}
```
#### 3. 租户 ID 在微服务之间的传递
##### (1) Feign 客户端传递租户 ID
在微服务之间通过 Feign 调用时,需要将租户 ID 传递到下游服务。可以通过 Feign 的 RequestInterceptor 实现。
**代码实现:**
```java
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FeignTenantInterceptor implements RequestInterceptor {
private static final String TENANT_ID_HEADER = "X-Tenant-Id";
@Override
public void apply(RequestTemplate template) {
// 从 ThreadLocal 中获取租户 ID
String tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
template.header(TENANT_ID_HEADER, tenantId);
}
}
}
```
##### (2) RestTemplate 传递租户 ID
如果使用 RestTemplate 调用下游服务,可以通过拦截器传递租户 ID。
**代码实现:**
```java
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class RestTemplateTenantInterceptor implements ClientHttpRequestInterceptor {
private static final String TENANT_ID_HEADER = "X-Tenant-Id";
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
// 从 ThreadLocal 中获取租户 ID
String tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
HttpHeaders headers = request.getHeaders();
headers.add(TENANT_ID_HEADER, tenantId);
}
return execution.execute(request, body);
}
}
```
---
---------gemini-2.0-pro-exp-02-05---------
好的,针对您提出的电商 SaaS 系统数据隔离组件设计需求,我将提供详细的设计方案和基于您所选技术栈的代码实现。
**设计方案**
1. **租户身份识别(Tenant Identification)**
* **方案:** 结合使用 HTTP Header 和 JWT(JSON Web Token)。
* **HTTP Header:** 在每个请求的 Header 中添加一个自定义字段,例如 `X-Tenant-ID`,用于传递租户 ID。
* **JWT:** 如果您的系统使用 JWT 进行用户身份验证,可以将租户 ID 作为 claim 之一包含在 JWT 中。这样,在验证 JWT 的同时,也能提取出租户 ID。
* **实现步骤:**
1. **Spring Cloud Gateway 配置:**
* 配置一个全局过滤器(GlobalFilter),从请求头或 JWT 中提取租户 ID。
* 将提取到的租户 ID 放入请求的 attribute 中,以便后续服务使用。
2. **微服务配置:**
* 创建一个 `TenantContext` 类,用于在当前线程中存储租户 ID。
* 创建一个 `HandlerInterceptor`,从请求的 attribute 中获取租户 ID,并将其设置到 `TenantContext` 中。
2. **数据访问控制(Data Access Control)**
* **方案:** 利用 MyBatis-Plus 的插件机制,结合 AOP 实现数据过滤。
* **实现步骤:**
1. **自定义 MyBatis-Plus 插件:**
* 创建一个插件类,实现 `Interceptor` 接口。
* 在 `intercept` 方法中,拦截 SQL 执行。
* 从 `TenantContext` 中获取租户 ID。
* 如果租户 ID 存在,则修改 SQL 语句,添加 `WHERE tenant_id = ?` 条件。
2. **AOP 切面:**
* 创建一个切面类,使用 `@Around` 注解环绕所有需要进行数据隔离的 Service 方法。
* 在切面方法中,可以进行一些额外的逻辑处理,例如:
* 检查租户 ID 是否为空,如果为空则抛出异常。
* 记录租户访问日志等。
**代码实现**
以下是基于您提供的技术栈的代码实现示例:
**1. 租户身份识别**
* **Spring Cloud Gateway 全局过滤器 (TenantExtractionFilter)**
```java
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class TenantExtractionFilter implements GlobalFilter, Ordered {
private static final String TENANT_ID_HEADER = "X-Tenant-ID";
private static final String TENANT_ID_ATTRIBUTE = "tenantId";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String tenantId = request.getHeaders().getFirst(TENANT_ID_HEADER);
// 如果Header中没有,尝试从JWT中获取(如果使用JWT)
if (tenantId == null) {
// 从JWT中解析tenantId的逻辑 (省略)
// tenantId = ...;
}
if (tenantId != null) {
// 将租户ID放入attribute
ServerHttpRequest modifiedRequest = request.mutate()
.headers(httpHeaders -> httpHeaders.remove(TENANT_ID_HEADER)) //可选:移除原始Header
.build();
exchange.getAttributes().put(TENANT_ID_ATTRIBUTE, tenantId);
return chain.filter(exchange.mutate().request(modifiedRequest).build());
} else {
// 没有租户ID,可以返回错误响应或放行(根据业务需求)
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
// 或者 return chain.filter(exchange);
}
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // 设置高优先级,确保在其他过滤器之前执行
}
}
```
* **微服务 TenantContext**
```java
public class TenantContext {
private static final ThreadLocal<String> tenantIdHolder = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
tenantIdHolder.set(tenantId);
}
public static String getTenantId() {
return tenantIdHolder.get();
}
public static void clear() {
tenantIdHolder.remove();
}
}
```
* **微服务 HandlerInterceptor (TenantContextInterceptor)**
```java
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class TenantContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String tenantId = (String) request.getAttribute("tenantId"); // 从attribute中获取
if (tenantId != null) {
TenantContext.setTenantId(tenantId);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
TenantContext.clear(); // 清理ThreadLocal
}
}
```
* **微服务 WebMvcConfigurer 配置**
```java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private TenantContextInterceptor tenantContextInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantContextInterceptor);
}
}
```
**2. 数据访问控制**
* **自定义 MyBatis-Plus 插件 (TenantDataFilterInterceptor)**
```java
import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.StringValue;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.select.PlainSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.SelectBody;
import net.sf.jsqlparser.statement.update.Update;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import java.sql.Connection;
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class TenantDataFilterInterceptor implements InnerInterceptor {
private static final String TENANT_ID_COLUMN = "tenant_id"; // 数据库表中的租户ID字段名
@Override
public void beforePrepare(StatementHandler statementHandler, Connection connection, Integer transactionTimeout) {
BoundSql boundSql = statementHandler.getBoundSql();
String originalSql = boundSql.getSql();
String tenantId = TenantContext.getTenantId();
if (tenantId == null) {
return; // 没有租户ID,不进行过滤
}
// 获取 MappedStatement
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
try {
```java
Statement statement = CCJSqlParserUtil.parse(originalSql);
if (sqlCommandType == SqlCommandType.SELECT) {
if (statement instanceof Select) {
Select select = (Select) statement;
SelectBody selectBody = select.getSelectBody();
if (selectBody instanceof PlainSelect) {
PlainSelect plainSelect = (PlainSelect) selectBody;
Expression where = plainSelect.getWhere();
// 构建租户ID过滤条件
EqualsTo tenantFilter = new EqualsTo();
tenantFilter.setLeftExpression(new Column(TENANT_ID_COLUMN));
tenantFilter.setRightExpression(new StringValue(tenantId));
// 将租户ID过滤条件添加到WHERE子句
if (where == null) {
plainSelect.setWhere(tenantFilter);
} else {
AndExpression andExpression = new AndExpression(where, tenantFilter);
plainSelect.setWhere(andExpression);
}
//设置新的SQL
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
mpBoundSql.sql(select.toString());
}
}
} else if (sqlCommandType == SqlCommandType.UPDATE) {
if (statement instanceof Update) {
Update update = (Update) statement;
Expression where = update.getWhere();
// 构建租户ID过滤条件
EqualsTo tenantFilter = new EqualsTo();
tenantFilter.setLeftExpression(new Column(TENANT_ID_COLUMN));
tenantFilter.setRightExpression(new StringValue(tenantId));
// 将租户ID过滤条件添加到WHERE子句
if (where == null) {
update.setWhere(tenantFilter);
} else {
AndExpression andExpression = new AndExpression(where, tenantFilter);
update.setWhere(andExpression);
}
//设置新的SQL
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
mpBoundSql.sql(update.toString());
}
} else if (sqlCommandType == SqlCommandType.DELETE) {
// 处理DELETE语句, 原理与UPDATE类似 (省略)
// 注意:DELETE 语句通常需要特别小心,确保不会误删其他租户的数据!
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
// mpBoundSql.sql(update.toString()); //你需要在这里实现DELETE的SQL改写逻辑
}
} catch (Exception e) {
// 异常处理 (例如:记录日志、抛出自定义异常等)
throw new RuntimeException("Failed to apply tenant data filter", e);
}
}
}
```
* **MyBatis-Plus 配置 (MybatisPlusConfig)**
```java
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantDataFilterInterceptor()); // 添加自定义的租户数据过滤拦截器
// 可以添加其他MyBatis-Plus内置拦截器,例如分页拦截器
return interceptor;
}
}
```
* **AOP 切面 (TenantDataAspect)** (可选, 用于增强)
```java
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class TenantDataAspect {
@Around("execution(* com.example.service.*.*(..))") // 拦截Service层的所有方法
public Object aroundServiceMethods(ProceedingJoinPoint joinPoint) throws Throwable {
String tenantId = TenantContext.getTenantId();
if (tenantId == null) {
throw new RuntimeException("Tenant ID is missing!"); // 抛出异常,阻止访问
}
// 可以在这里添加其他逻辑,例如:
// 1. 记录租户访问日志
// 2. 检查租户是否有权限访问当前资源
return joinPoint.proceed(); // 执行原方法
}
}
```
**重要说明和最佳实践**
* **安全性:**
* **JWT 验证:** 如果使用 JWT,务必确保 JWT 的签名验证是严格的,防止伪造。
* **SQL 注入防护:** 虽然 MyBatis-Plus 可以有效防止 SQL 注入,但仍建议对用户输入进行严格校验。
* **DELETE 操作:** 对于 DELETE 操作,务必谨慎处理,最好添加额外的确认机制,或者使用逻辑删除(软删除)代替物理删除。
* **异常处理:** 在各个环节添加适当的异常处理,例如:
* 租户 ID 获取失败时,返回明确的错误信息。
* SQL 过滤失败时,记录日志并抛出异常。
* **性能优化:**
* **索引:** 确保 `tenant_id` 字段上有索引,以提高查询效率。
* **缓存:** 对于一些不经常变动的数据,可以考虑使用缓存来减少数据库访问。
* **可扩展性:**
* **租户 ID 来源:** 如果未来租户 ID 的获取方式发生变化(例如,从数据库中获取),可以修改 `TenantExtractionFilter` 和 `TenantContextInterceptor`,而无需改动其他代码。
* **数据隔离策略:** 如果未来需要支持其他数据隔离策略(例如,共享数据库,独立 Schema),可以创建新的 MyBatis-Plus 插件来实现。
* **测试:**
* **单元测试:** 对 `TenantDataFilterInterceptor` 进行单元测试,确保 SQL 过滤逻辑正确。
* **集成测试:** 模拟多租户场景,测试整个数据隔离流程是否正常工作。
* **代码清晰度**: 使用有意义的变量名,添加必要的注释,使代码易于理解和维护。
* **DELETE语句实现**: 务必实现DELETE的SQL改写逻辑,并进行充分测试。
通过以上设计和代码实现,您的电商 SaaS 系统就具备了基于行级别的数据隔离能力。请根据您的实际业务需求和代码风格进行调整和完善。 希望这个详细的方案和代码对您有所帮助!
------------
[mybatis-plus的TenantLineInnerInterceptor插件使用](https://blog.csdn.net/King_of_liuguan/article/details/131414853)
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性