阿里云短信 - __破 - 博客园

mikel阅读(183)

来源: 阿里云短信 – __破 – 博客园

阿里云短信

需要创建Accesskey

 

需要创建签名 发送模板

调用api发送短信相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.po.reggie.utils;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.profile.DefaultProfile;
/**
 * 短信发送工具类
 */
public class SMSUtils {
    /**
     * 发送短信
     * @param signName 签名
     * @param templateCode 模板
     * @param phoneNumbers 手机号
     * @param param 参数
     */
    public static void sendMessage(String signName, String templateCode,String phoneNumbers,String param){
        DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou""LTAI5tM6p--------MScq1gq""4fhp0t8j0y2-----UIlbgfH1GU");
        IAcsClient client = new DefaultAcsClient(profile);
        SendSmsRequest request = new SendSmsRequest();
        request.setSysRegionId("cn-hangzhou");
        request.setPhoneNumbers(phoneNumbers);
        request.setSignName(signName);
        request.setTemplateCode(templateCode);
        request.setTemplateParam("{\"code\":\""+param+"\"}");
        try {
            SendSmsResponse response = client.getAcsResponse(request);
            System.out.println("短信发送成功");
        }catch (ClientException e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) {
        sendMessage("破土重生科技股份有限公司","SMS_154950909","13522715896","12345678");
    }
}

代码开发-导入maven坐标

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.5.16</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>2.1.0</version>
</dependency>

验证码的校验和登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
package com.po.reggie.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.po.reggie.common.R;
import com.po.reggie.domain.User;
import com.po.reggie.service.UserService;
import com.po.reggie.utils.ValidateCodeUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpSession;
import java.util.Map;
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
    @Autowired
    private UserService userService;
    /**
     * 发送手机短信验证码
     * @param user
     * @return
     */
    @PostMapping("/sendMsg")
    public R<String> sendMsg(@RequestBody User user, HttpSession session){
        //获取手机号
        String phone = user.getPhone();
        if(StringUtils.isNotEmpty(phone)){
            //生成随机的4位验证码
            String code = ValidateCodeUtils.generateValidateCode(4).toString();
            log.info("code={}",code);
            //调用阿里云提供的短信服务API完成发送短信
            //SMSUtils.sendMessage("瑞吉外卖","",phone,code);
            //需要将生成的验证码保存到Session
            session.setAttribute(phone,code);
            return R.success("手机验证码短信发送成功");
        }
        return R.error("短信发送失败");
    }
    /**
     * 移动端用户登录
     * @param map
     * @param session
     * @return
     */
    @PostMapping("/login")
    public R<User> login(@RequestBody Map map, HttpSession session){
        log.info(map.toString());
        //获取手机号
        String phone = map.get("phone").toString();
        //获取验证码
        String code = map.get("code").toString();
        //从Session中获取保存的验证码
        Object codeInSession = session.getAttribute(phone);
        //进行验证码的比对(页面提交的验证码和Session中保存的验证码比对)
        if(codeInSession != null && codeInSession.equals(code)){
            //如果能够比对成功,说明登录成功
            LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(User::getPhone,phone);
            User user = userService.getOne(queryWrapper);
            if(user == null){
                //判断当前手机号对应的用户是否为新用户,如果是新用户就自动完成注册
                user = new User();
                user.setPhone(phone);
                user.setStatus(1);
                userService.save(user);
            }
            session.setAttribute("user",user.getId());
            return R.success(user);
        }
        return R.error("登录失败");
    }
}

 

.Net对接阿里云短信平台 - 王精灵 - 博客园

mikel阅读(180)

来源: .Net对接阿里云短信平台 – 王精灵 – 博客园

一、在对接阿里云短信平台之前需完成阿里云短信平台短信签名和短信模板的配置,在此不做过多说明,建议采用企业账号的身份申请短信签名和短信模板更容易审核通过一些
阿里云短信平台地址 https://www.aliyun.com/product/sms?spm=5176.19720258.J_2686872250.7.7b812c4aBro5hF

二、完成以上配置之后在项目Nuget包中安装:AlibabaCloud.SDK.Dysmsapi20170525

三、主体代码

复制代码
        public void SendSms(string PhoneNumbers)
        {
            var verifyCode = GetVerifyCode();
            JObject jObject = new JObject();
            jObject.Add("code", verifyCode);
            AlibabaCloud.SDK.Dysmsapi20170525.Client client = CreateClient("accessKeyId", "accessKeySecret");
            AlibabaCloud.SDK.Dysmsapi20170525.Models.SendSmsRequest sendSmsRequest = new AlibabaCloud.SDK.Dysmsapi20170525.Models.SendSmsRequest
            {
                PhoneNumbers = PhoneNumbers,
                SignName = "SignName",
                TemplateCode = "TemplateCode",
                TemplateParam = jObject.ToString()
            };
            SendSmsResponse sendSmsResponse = client.SendSms(sendSmsRequest);
            if (sendSmsResponse.Body.Code == "OK" && sendSmsResponse.Body.Message == "OK")
            {
                textEdit1.Text = sendSmsResponse.Body.BizId;
                SetCacheTimeSpan(sendSmsResponse.Body.BizId, verifyCode, 20);
            }
            else if("isv.BUSINESS_LIMIT_CONTROL".Equals(sendSmsResponse.Body.Code))
            {
                throw new Exception("获取验证码过于频繁");
            }
        }


        public static AlibabaCloud.SDK.Dysmsapi20170525.Client CreateClient(string accessKeyId, string accessKeySecret)
        {
            AlibabaCloud.OpenApiClient.Models.Config config = new AlibabaCloud.OpenApiClient.Models.Config
            {
                AccessKeyId = accessKeyId,
                AccessKeySecret = accessKeySecret,
            };
            config.Endpoint = "dysmsapi.aliyuncs.com";
            return new AlibabaCloud.SDK.Dysmsapi20170525.Client(config);
        }


        /// <summary>
        /// 生成6位数随机验证码
        /// </summary>
        /// <returns></returns>
        private static int GetVerifyCode()
        {
            Random random = new Random();
            return random.Next(100000, 999999);
        }

        /// <summary>
        /// 设置缓存相对过期时间
        /// </summary>
        /// <param name="cacheKey">key</param>
        /// <param name="objValue">缓存对象</param>
        /// <param name="timeSpan">过期时间(秒)</param>
        public static void SetCacheTimeSpan(string cacheKey, object objValue, long timeSpan)
        {
            System.Web.Caching.Cache objCache = HttpRuntime.Cache;
            objCache.Insert(cacheKey, objValue, null, DateTime.MaxValue, TimeSpan.FromSeconds(timeSpan));
        }


        /// <summary>
        /// 获取Cache的value
        /// </summary>
        /// <param name="cacheKey"></param>
        /// <returns></returns>
        public object GetCacheValue(string cacheKey)
        {
            System.Web.Caching.Cache objCache = HttpRuntime.Cache;
            return objCache.Get(cacheKey);
        }
复制代码

四、调用

            SendSms(PhoneNumbers);

Token,Session,Cookie,JWT,Oauth2傻傻分不清楚 - 苏三说技术 - 博客园

mikel阅读(165)

来源: Token,Session,Cookie,JWT,Oauth2傻傻分不清楚 – 苏三说技术 – 博客园

前言

最近发现有些小伙伴,对Token、Session、Cookie、JWT、OAuth2这些概念非常容易搞混。

有些小伙伴在工作中可能会遇到过这样的困惑:

  • 做登录功能时,到底该用Session还是JWT?
  • OAuth2和Token是什么关系?
  • 为什么有的方案要把Token存在Cookie里?

今天这篇文章专门跟大家一起聊聊这个话题,希望对你会有所帮助。

一、从餐厅就餐模型开始讲

为了让大家更好理解,我先用一个餐厅就餐的比喻来解释这些概念:

image

现在,让我们深入每个概念的技术细节。

二、Cookie:HTTP的世界身份证

2.1 什么是Cookie?

Cookie是存储在浏览器端的一小段文本数据,由服务器通过HTTP响应头的Set-Cookie字段发送给浏览器,浏览器随后会自动在每次请求中通过Cookie头将其带回给服务器。

工作原理

image

2.2 Cookie实战代码

// 服务器设置Cookie
@PostMapping("/login")
public ResponseEntity login(@RequestBody User user, HttpServletResponse response) {
    if (authService.authenticate(user)) {
        Cookie cookie = new Cookie("session_id", generateSessionId());
        cookie.setMaxAge(3600); // 1小时有效期
        cookie.setHttpOnly(true); // 防止XSS攻击
        cookie.setSecure(true); // 仅HTTPS传输
        cookie.setPath("/"); // 对整个站点有效
        response.addCookie(cookie);
        return ResponseEntity.ok().build();
    }
    return ResponseEntity.status(401).build();
}

// 读取Cookie
@GetMapping("/profile")
public ResponseEntity getProfile(@CookieValue("session_id") String sessionId) {
    User user = sessionService.getUserBySession(sessionId);
    return ResponseEntity.ok(user);
}

2.3 Cookie的重要属性

属性 作用 安全建议
HttpOnly 防止JavaScript访问 必须设置为true,防XSS
Secure 仅通过HTTPS传输 生产环境必须设置为true
SameSite 控制跨站请求时是否发送Cookie 建议设置为Strict或Lax
Max-Age 设置Cookie有效期 根据业务安全性要求设置

三、Session:服务端的用户档案

3.1 什么是Session?

Session是存储在服务器端的用户状态信息。服务器为每个用户创建一个唯一的Session ID,并通过Cookie将这个ID传递给浏览器,浏览器后续请求时带上这个ID,服务器就能识别用户身份。

Session存储结构

// 典型的Session数据结构
public class UserSession {
    private String sessionId;
    private String userId;
    private String username;
    private Date loginTime;
    private Date lastAccessTime;
    private Map<String, Object> attributes; // 自定义属性
    
    // 省略getter/setter
}

3.2 Session实战代码

// 基于Spring Session的实现
@PostMapping("/login")
public String login(@RequestParam String username, 
                   @RequestParam String password,
                   HttpSession session) {
    User user = userService.authenticate(username, password);
    if (user != null) {
        // 将用户信息存入Session
        session.setAttribute("currentUser", user);
        session.setAttribute("loginTime", new Date());
        return "redirect:/dashboard";
    }
    return "login?error=true";
}

@GetMapping("/dashboard")
public String dashboard(HttpSession session) {
    // 从Session获取用户信息
    User user = (User) session.getAttribute("currentUser");
    if (user == null) {
        return "redirect:/login";
    }
    return "dashboard";
}

3.3 Session的存储方案

1. 内存存储(默认)

# application.yml
server:
  servlet:
    session:
      timeout: 1800 # 30分钟过期时间

2. Redis分布式存储

@Configuration
@EnableRedisHttpSession // 启用Redis Session存储
public class SessionConfig {
    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }
}

3. Session集群同步问题

image

四、Token:去中心化的身份令牌

4.1 什么是Token?

Token是一种自包含的身份凭证,服务器不需要在服务端存储会话状态,所有必要信息都包含在Token本身中。

Token vs Session 核心区别
image

4.2 Token实战代码

// 生成Token
public String generateToken(User user) {
    long currentTime = System.currentTimeMillis();
    return JWT.create()
            .withIssuer("myapp") // 签发者
            .withSubject(user.getId()) // 用户ID
            .withClaim("username", user.getUsername())
            .withClaim("role", user.getRole())
            .withIssuedAt(new Date(currentTime)) // 签发时间
            .withExpiresAt(new Date(currentTime + 3600000)) // 过期时间
            .sign(Algorithm.HMAC256(secret)); // 签名密钥
}

// 验证Token
public boolean validateToken(String token) {
    try {
        JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret))
                .withIssuer("myapp")
                .build();
        DecodedJWT jwt = verifier.verify(token);
        return true;
    } catch (JWTVerificationException exception) {
        return false;
    }
}

五、JWT:现代化的Token标准

5.1 什么是JWT?

JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在各方之间安全地传输信息作为JSON对象。

这种信息可以被验证和信任,因为它是数字签名的。

JWT结构

header.payload.signature

解码示例

// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622
}

// Signature
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

5.2 JWT实战代码

// 创建JWT
public String createJWT(User user) {
    return Jwts.builder()
            .setHeaderParam("typ", "JWT")
            .setSubject(user.getId())
            .setIssuer("myapp")
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + 3600000))
            .claim("username", user.getUsername())
            .claim("role", user.getRole())
            .signWith(SignatureAlgorithm.HS256, secret.getBytes())
            .compact();
}

// 解析JWT
public Claims parseJWT(String jwt) {
    return Jwts.parser()
            .setSigningKey(secret.getBytes())
            .parseClaimsJws(jwt)
            .getBody();
}

// 在Spring Security中使用JWT
@Component
public class JwtFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain chain) {
        String token = resolveToken(request);
        if (token != null && validateToken(token)) {
            Authentication auth = getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        chain.doFilter(request, response);
    }
}

5.3 JWT的最佳实践

1. 安全存储

// 前端安全存储方案
// 不推荐:localStorage(易受XSS攻击)
// 推荐:HttpOnly Cookie(防XSS)或内存存储

2. 令牌刷新机制

// 双Token机制:Access Token + Refresh Token
public class TokenPair {
    private String accessToken;  // 短期有效:1小时
    private String refreshToken; // 长期有效:7天
}

// 刷新令牌接口
@PostMapping("/refresh")
public ResponseEntity refresh(@RequestBody RefreshRequest request) {
    String refreshToken = request.getRefreshToken();
    if (validateRefreshToken(refreshToken)) {
        String userId = extractUserId(refreshToken);
        String newAccessToken = generateAccessToken(userId);
        return ResponseEntity.ok(new TokenPair(newAccessToken, refreshToken));
    }
    return ResponseEntity.status(401).build();
}

六、OAuth 2.0:授权框架之王

6.1 什么是OAuth 2.0?

OAuth 2.0是一个授权框架,允许第三方应用在获得用户授权后,代表用户访问受保护的资源。

OAuth 2.0角色

  • 资源所有者(Resource Owner):用户
  • 客户端(Client):第三方应用
  • 授权服务器(Authorization Server):颁发访问令牌
  • 资源服务器(Resource Server):托管受保护资源

6.2 OAuth 2.0授权码流程

image

6.3 OAuth 2.0实战代码

// Spring Security OAuth2配置
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    
    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("clientapp")
                .secret(passwordEncoder.encode("123456"))
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .scopes("read", "write")
                .redirectUris("http://localhost:8080/callback");
    }
    
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.authenticationManager(authenticationManager)
                .tokenStore(tokenStore())
                .accessTokenConverter(accessTokenConverter());
    }
}

// 资源服务器配置
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/api/public/**").permitAll()
                .antMatchers("/api/private/**").authenticated()
                .antMatchers("/api/admin/**").hasRole("ADMIN");
    }
}

七、五大概念对比

为了让大家更清晰地理解这五个概念的关系和区别,我准备了以下对比表格:

7.1 功能定位对比

概念 本质 存储位置 主要用途 特点
Cookie HTTP状态管理机制 浏览器 维持会话状态 自动携带,有大小限制
Session 服务端会话信息 服务器 存储用户状态 服务端状态,需要存储管理
Token 访问凭证 客户端/服务端 身份认证 自包含,可验证
JWT Token的一种实现标准 客户端/服务端 安全传输信息 标准化,自包含,可签名
OAuth2 授权框架 不直接存储 第三方授权 标准化授权流程

7.2 应用场景对比

场景 推荐方案 原因说明
传统Web应用 Session + Cookie 简单易用,生态成熟
前后端分离应用 JWT 无状态,适合API认证
第三方登录 OAuth 2.0 标准化授权,安全可靠
微服务架构 JWT 分布式认证,无需会话同步
移动端应用 Token 轻量级,适合移动网络

7.3 安全考虑对比

安全威胁 Cookie方案防护 Token/JWT方案防护
XSS攻击 HttpOnly Cookie 避免localStorage存储
CSRF攻击 SameSite Cookie 自定义Header+CSRF Token
令牌泄露 短期有效+HTTPS 短期有效+HTTPS+刷新机制
数据篡改 服务端验证 签名验证

总结

通过今天的深入探讨,我们可以得出以下结论:

  1. Cookie是载体:HTTP协议的状态管理机制,是Session和Token的传输媒介之一。
  2. Session是状态:服务端维护的会话状态,需要借助Cookie或URL重写来实现。
  3. Token是凭证:认证授权的凭证,可以放在Cookie、Header或URL中。
  4. JWT是标准:Token的一种标准化实现,自包含、可验证、可信任。
  5. OAuth2是框架:授权框架,定义了完整的第三方授权流程。

最终建议

  • 简单Web应用:Session + Cookie
  • 前后端分离:JWT + HTTP Header
  • 第三方授权:OAuth 2.0 + JWT

没有最好的方案,只有最合适的方案。

理解每个技术的本质和适用场景,才能做出正确的架构决策。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。

本文收录于我的技术网站http://www.susan.net.cn

实体框架 - 北在北方 - 博客园

mikel阅读(274)

来源: 实体框架 – 北在北方 – 博客园

实体框架的使用分为Model First,Code First(代码生成模型),Database First。Model First和Database First会使用实体设计器(edmx文件)来创建实体数据模型。

DbContext所使用的连接字符串如果是常规连接字符串则使用Code First,如果使用的是特殊的实体框架连接字符串,则使用Database First或Model First。

 

Database First:

从现有数据库构建edmx文件,然后edmx会生成DbContext和poco模型(model类),并将实体框架专用的连接字符串保存到所在项目的app.config中,但配置文件中的连接字符串并不能直接使用,连接字符串中包含有指向csdl,ssdl,msl文件的路径,自动生成的连接字符串中这3条路径包含一个“*”号,需将edmx所在类库的程序集名称替换“*”号。

Model First:

建立空的edmx文件,手动构建数据库模型,再由根据模型创建数据库和生成DbContext以及poco模型(model类)

Code First:

手写POCO,再由DbContext生成对应的数据库。

POCO中外键和导航属性应用virtual修饰(详情见导航属性的加载),以代理类便于重写。集合类型的导航属性应在构造方法中初始化为HashSet集合。

IDatabaseInitializer<TContext>(TContext是DbContext类型的实例),用于指定DbContext初次使用时用于初始化数据库的策略,使用策略须调用DbContext的DataBase属性(System.Data.Entity.Database实例)的SetInitializer方法,此初始化数据库策略在Global.asax的Application_Start方法中直接调用Database.SetInitializer静态方法,或者在DbContext的构造方法中执行Database.SetInitializer静态方法,或者在配置文件的entityFramwork/contexts的子节点配置,示例如下:

<entityFramework>

<contexts>

<context type=”[DbContext类],[程序集名称]”>

<databaseInitializer type=”[IDatabaseInitializer实现类],[程序集名称]” />

</context>

</contexts>

</entityFramework>

可使用的初始化策略有DropCreateDatabaseIfModelChanged<TContext>,表示如果模型改变,则删除并重建数据库,Database的CompatibleWithModel方法可用于检查模型是否发生改变;DropCreateDatabaseAlways<TContext>,表示每次使用DbContext都重建数据库;CreateDatabaseIfNotExists<TContext>,表示如果数据库不存在则创建数据库。用户选择使用策略时最好继承者三个类中的一个并重写Seed方法,该方法会在数据库创建后执行,此方法称为种子方法。

System.Data.Entity.Database类,可通过DbContext的Database属性获取Database类的实例,该实例可用于检查数据库的状态或执行一些数据库操作,如数据库是否存在,模型是否改变,创建或删除数据库操作,执行SQL脚本等操作。

对模型的定义可通过System.Component.DataAnnotations命名空间的特性,也可以通过Fluent API,Fluent API 的使用工具是DbModelBuilder类,此类可在DbContext的OnModelCreating方法的参数获得,通过Fluent API对模型映射所做的修改如同使用Data Annotation对模型所作的修改一样都会在数据迁移DbMigration中做出相应的反应,即数据迁移支持Fluent API和Data Annotation对模型的映射设置和修改。

 

Code First约定:

注:

主表(主体):具有主键的表。

从表(依赖实体):使用主表的主键作为本表的外键。

主键约定:类的“ID”名称的属性或“<类名>ID”的属性默认为模型的主键。

关系约定:模型的外键关系通过导航属性来推断。建议显示指定外键属性,外键的属性名称为“<导航属性名称>_<主体主键属性>”(如果外键属性是自动生成的,则导航属性名称与主体主键属性之间有下划线,如果是手写的可有可无)、“<主体类名><主体主键名称>”、“<主体模型名称>”。外键属性如果可为null,则外键关系可选的,且外键关系为非级联删除,如果不可为null则为必选的,并设置为级联删除。

对于无法推断出主键的引用类型的属性将作为复杂数据类型,在数据库中的体现是<复杂数据类型的属性名>_<复杂数据类型的类属性>。

当模型的导航属性无法在导航属性对应的另一个模型中找到合适的或应该的导航属性时,可使用InversePropertyAttribute手动指定导航属性对应的另一个模型中的导航属性。

当需要手动指定导航属性在模型中对应的外键属性时,使用ForeignKeyAttribute。

注意:

通过DbContext的访问类集合,返回的结果包括指定的类以及派生类,此结果在TPT策略下,通过执行外联接实现,在TPC下通过union操作实现,而正因此,导致在同时使用数据库的标识列和TPC策略下,派生类(可能多个)对应的表和基类对应的表中的记录不能具有相同的主键值,否则会导致DbContext内部调用的ObjectContext会创建重复的实体键,详情见TPC说明。

 

Fluent API的表映射策略:

每个继承层次结构一张表(TPH)(Fluent API的默认策略),每个类型一张表(TPT),每个具体类一张表(TPC);

单个实体对应多个表(实体拆分),将多个实体映射到一个表(表拆分)

TPH  Table per Hierarachy:每个层次结构一张表,表示继承此结构中的所有类型使用同一张表,这张表使用鉴别器列区分每行对应的模型,这个鉴别器列是对应鉴别器表“Discrimination”的外键,鉴别器表中具有鉴别器值,此值即为模型的类型名称。

modelBuilder.Entity<FatherClass>()

.Map<SonClass1>(m=>m.Requires(“Type”).HasValue(“SonClass1”))

.Map<SonClass2>(m=>m.Requires(“Type”).HasValue(“SonClass2”))

如果要求多态关系或查询,并且子类声明了很少的属性甚至子类的不同在于行为的话,建议使用TPH。目标是尽可能的减少列,并且在长期需求中这种非标准的结构不会引发问题。

TPH策略中,基础类型的字段会被用设为可为空,这会导致不可为空的外键属性因成为可为空而引发问题。

 

TPT  Table per Type:每个类型一张表,表示继承结构中的基类和派生类分别对应一张表,表中只映射对应类中定义的属性,不映射继承的属性,派生类对应的表和基类对应的表通过外键进行关联,以表示模型的继承的关系。

modelBuilder.Entity<SonClass1>().ToTable(“SonClass1”);

modelBuilder.Entity<SonClass2>().ToTable(“SonClass2”);

如果要求多态关系或查询,并且子类定义了较多的属性的话,建议使用TPT,或者如果在继承结构较复杂的情况下,join查询比union查询较节省资源的话,使用TPT,反之使用TPC。

 

TPC  Table per Concrete Type:每个具体类一张表,表示继承层级中的基类和派生类各自对应一张表,基类对应的表与派生类对应的表没有外键关系,派生类的属性包括继承的属性均映射到数据表中。使用MapInheritedProperties方法指示将继承的属性映射到数据表。此策略与数据库的标识列存在矛盾的情况,这个表映射策略会因为多个派生类以及基类具有同名称的标识列类型的主键属性,导致DbContext内部使用的ObjectContext的跟踪机制创建了重复的实体键(EntityKey)从而导致了数据插入失败,可以关闭标识列或设置为其他类型主键来手动设置主键属性来规避这个异常。

modelBuilder.Entity<FatherClass>()

.Property(c => c.ID)

.HasDatabaeGeneratedOption(DatabaseGeneratedOption.None)//在同时保存父类和子类时虽数据表没有外键关联,但因为继承关系会引发主键相关的异常,修改为手动设置主键则不会有此问题。

modelBuilder.Entity<SonClass1>().Map(m => {

m.MapInheritedProperties();

m.ToTable(“SonClass1”);

})

modelBuilder.Entity<SonClass2>().Map(m=>{

m.MapInheritedProperties();

m.ToTable(“SonClass2”);

})

如果不要求多态关系或查询,建议使用TPC,当很少查询基类或者很少有其他类类与基类关联,并且基类很少改动的情况下建议对继承树的高层使用TPC。

注意:使用TPC的模型所继承的属性不能来自这样的基类,这个基类进行了实体拆分,或所处的表进行了表拆分。

 

实体拆分:将单个实体映射到多个表,通过多次调用Map方法,将不同的属性分配到不同的表中。

modelBulder.Entity<Department>()

.Map(m=>{

m.Properties(t=>new{t.DepartmentID,t.Name});

m.ToTable(“Department”);’

})

.Map(m=>{

m.Properties(t=>new{t.DepartmentID,t.Administrator,t.StartDate,t.Budget});

m.ToTable(“DepartmentDetails”);

});

表拆分:将多个实体映射到一个表,通过指定相同的主键将两个实体映射到同一个表

modelBuilder.Entity<OfficeAssignment>().HasKey(t => t.InstructorID);

modelBuilder.Entity<Instructor>().HasRequired(t=>t.OfficeAssignment).WithRequriedPrincipal(t=>t.Instructor);

modelBuilder.Entity<OfficeAssignment>().ToTable(“Instructor”);

modelBuilder.Entity<Instructor>().ToTable(“Instructor”);

 

表关系映射与导航属性:

使用HasMany、HasRequired、HasOptional方法设置设置模型的导航属性的类型为多、单个必须、单个可选,这些方法返回的OptionalNavigationPropertyConfiguration类中With开头的方法用于设置关系类中导航属性和主体与依赖对象的关系。

略:普通的一对一与一对多关系示例代码

多对多关系:

modelBuilder.Entity<Model1>().HasMany(t1=>t1.NaviPropertyCollection).WithMany(t2=>t2.NaviPropertiyCollection)

多对多关系的表之间会自动增加一个关系表。

modelBuilder.Entity<Model1>()

.HasMany(m1=>m1.Model2Collection)

.WithMany(m2=>m2.Model1Collection)

.Map(m=>{

m.ToTable(“Many1ToMany2”)//指定关系表的表名

m.MapLeftKey(“Many1ID”);//指定关系表中对应设置表的外键列名

m.MapRightKey(“Many2ID”);//指定关系表中对应设置表相关联的关系表的外键列名

});

 

级联删除:

如果外键不可为空,则默认级联删除,否则,不是级联删除,主体删除后,外键置为null

modelBuilder.Convertions.Remove<OneToManyCascadeDeleteConvertion>();//移除当一对多必选关系时默认的级联删除选项

modelBuilder.Convertions.Remove<ManyToManyCascadeDeleteConvertion>();//移除当多对多必选关系时默认的级联删除

使用WillCascadeOnDelete方法手动设置是否级联删除

modelBuilder.Entity<TModel>()

.HasRquired(t=>t.NaviProperty)

.WithMany(anotherT=>anotherT.NaviPropertyCollection)

.HasForeighKey(t=>t.ForeighId)

.WillCascadeOnDelete(false)//设置为不级联删除

 

DbContext编写:

添加公共的DbSet类型或IDbSet类型的属性会由DbContext默认调用设置DbSet实例,如:

public IDbSet<TModel> TModels{get;set;}

public DbSet<TModel> TModels{get;set;}

访问实例也可通过Set方法,下面两行代码等效:

DbSet<TModel> result = dbContext.TModels;

DbSet<TModel> result = dbContext.Set<TModel>();

在访问DbSet属性但又仅仅进行只读操作时,为了提高性能可禁用跟踪,通过DbSet实例的AsNoTracking方法禁用追踪。

 

实体关系的修改:

实体关系可通过外键属性或导航属性进行修改,但只有执行DbContext的SaveChange后,外键属性以及导航属性才会反应真是的情况,如,将实体的导航属性进行了设置,这是对应的外键属性(如果有的话)可能为0(或其他表示未指定的值),只有执行SaveChange方法后,导航属性对应的外键值才为关联的模型的主键值。外键属性与导航属性的同步会在DetectChanged方法来完成,以下方法会自动调用DetectChanges方法:

DbSet.Add,DbSet.Find,DbSet.Remove,DbSet.Local,DbContext.SaveChange,DbContext.Attach,DbContext.GetValidationErrors,DbContext.Entry,DbChagne.Tracher.Entries,DbSet执行的linq查询。

删除关系可通过将导航属性设置为null的方式进行,亦或执行如下代码:

dbContext.Entry(tModel).Reference(c=>c.NaviProperty).CurrentValue=null;

通过ObjectContext的ObjectStateManager.ChangeRelationshipState方法也可修改关系:

((IObjectContextAdapter)dbContext).ObjectContext.ObjectStateManager.ChangeRelationshipState(model,anotherModel,m=>m.AnotherModel,EntityState.Added);//EntityState.Deleted表示删除,如果是更新关系的话需要Entity.Added新关系并Deleted旧关系。

DbSet.Local属性说明:该属性包含所有上下文中的对象,包括那些Add的但未保存到数据库的对象;但不包括Remove的但仍在数据库中的对象。

导航属性数据的加载:

导航属性对应的数据默认是惰性加载(在访问时才加载),如果希望查询数据时一同加载关联属性,须调用DbSet的Include方法并指定要求预加载的导航属性数据。

Var result = dbContext.Models.Include(b=>b.NaviProperty);//单级加载

Var result = dbContext.Models.Include(“NaviProperty.SecondNaviProperty”);//多级加载,加载Model的导航属性NaviProperty以及导航属性的导航属性SecondNaviProperty。

导航属性的惰性加载在首次访问时自动进行加载,加载通过继承自模型类的代理类进行,该代理类会重写导航属性,因此,模型类的导航属性必须用virtual修饰,不适用virtual修饰意味着惰性加载的关闭,在DbContext的构造方法中调用Configuration属性(DbContextConfiguration实例)的LazyLoadingEnabled=false也可设置惰性加载为关闭。

显示加载通过DbReferenceEntry的Load方法执行:

dbContext.Entry(model).Reference(m=>m.NavProperty).Load();

在更改外键属性后,可通过Load的方法显示加载同步的导航属性数据。通过dbContext的Entry访问代表集合类型的导航属性的DbCollectionEntry的Query方法获取可操作的IQueryable,来实现对部分实体的导航数据进行加载的要求。

代理类:

在为POCO实体类型实例化时,实体框架会动态生成派生自POCO实体的类,成为代理类,这些代理类通过重写POCO实体的virtual成员来实现一些自动操作,如之前提到的惰性加载。DbContext的Configuration属性(DbContextConfiguration实例)的ProxyCreationEnabled=false可将关闭代理类的生成。

实体的管理:

添加实体可通过DbSet的Add方法,也可通过dbContext的Attach方法并更改返回的Entry的状态为Added的方式。

如果仅仅希望保存实体的某个字段的修改到数据库的话,可通过Entry方法获取的DbEntityEntry实例并通过Property方法获取DbPropertyEntry对象实例并修改IsModified属性为true,这样就可以在保存数据库时,仅仅将IsModified=true的属性保存到数据库。

在调用SaveChange时,可能会引发DbUpdateConcurrentException,这表示更新时的并发异常,可通过dbContext.Entry(model)获取修改实体的DbEntityEntry实例,修改OriginValue和CurrentValue的方式来尝试消除引发并发的数据,DbEntityEntry的GetDatabaseValue方法可用来获取当前数据库中的数据,在重写原始值(OriginValue)或当前值(CurrentValue)时可能会使用到。

 

ASP.NET MVC 中的HttpContextWrapper怎么获取POST请求的所有参数内容

mikel阅读(499)

ASP.NET MVC中,可以通过HttpContextWrapper获取POST请求的所有参数内容。以下是具体方法和步骤:

1. 获取表单数据(Form Data)

适用于application/x-www-form-urlencodedmultipart/form-data类型的POST请求:

csharp

// 创建HttpContextWrapper实例(通常在控制器中可直接使用Request属性)
var httpContextWrapper = new HttpContextWrapper(System.Web.HttpContext.Current);

// 获取POST表单数据
NameValueCollection formData = httpContextWrapper.Request.Form;

// 遍历所有参数
foreach (string key in formData.AllKeys)
{
    string value = formData[key];
    // 处理键值对(例如:key="username", value="John")
}

2. 获取JSON/RAW请求体

适用于application/json或其他原始数据类型的POST请求:

csharp

using (var reader = new StreamReader(httpContextWrapper.Request.InputStream))
{
    // 重置流位置(重要!)
    httpContextWrapper.Request.InputStream.Seek(0, SeekOrigin.Begin);
    
    // 读取原始内容
    string rawBody = reader.ReadToEnd();
    
    // 示例:解析JSON
    // dynamic jsonData = JsonConvert.DeserializeObject(rawBody);
}

3. 获取查询字符串参数(Query String)

如果POST请求同时包含URL查询参数:

csharp

NameValueCollection queryParams = httpContextWrapper.Request.QueryString;
// 遍历方式同Form

4. 统一获取所有参数(Form + QueryString)

csharp

NameValueCollection allParams = httpContextWrapper.Request.Params; // 包含Form和QueryString

关键注意事项:

  1. 流的位置重置
    读取InputStream前必须重置流位置,因为ASP.NET默认不会重置流:csharphttpContextWrapper.Request.InputStream.Seek(0, SeekOrigin.Begin);
  2. 内容类型检查
    根据Content-Type选择处理方式:csharpif (httpContextWrapper.Request.ContentType.Contains(“application/json”)) { // 处理JSON } else if (httpContextWrapper.Request.ContentType.Contains(“application/x-www-form-urlencoded”)) { // 处理表单 }
  3. 在控制器中的简化写法
    在Controller中可直接用基类属性(无需手动创建Wrapper):csharppublic ActionResult YourAction() { var formData = Request.Form; // 表单数据 var jsonData = new StreamReader(Request.InputStream).ReadToEnd(); // 原始Body return View(); }

完整示例(获取所有POST参数并转为字典):

csharp

        /// <summary>
/// 获取所有请求参数
/// </summary>
/// <param name="context">http内容</param>
/// <returns></returns>
public Dictionary<string, string> GetAllPostParameters(HttpContextBase context)
{
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

// 处理表单数据
foreach (string key in context.Request.Form.AllKeys)
{
parameters[key] = context.Request.Form[key];
}

// 处理 JSON 数据
if (context.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true)
{
string json = new StreamReader(context.Request.InputStream, Encoding.UTF8).ReadToEnd();
try
{
// 重置并读取流
context.Request.InputStream.Seek(0, SeekOrigin.Begin);


if (string.IsNullOrWhiteSpace(json))
{
// 记录空 JSON 警告
log.Debug("收到空的 JSON 请求体");
return parameters;
}

// 尝试解析 JSON
var jsonData = JObject.Parse(json);

foreach (var prop in jsonData.Properties())
{
parameters[prop.Name] = prop.Value?.ToString() ?? string.Empty;
}
}
catch (JsonReaderException jex)
{
// 处理 JSON 解析错误
log.Error($"JSON 解析错误: {jex.Message}");

// 尝试解析为查询字符串格式
try
{
var keyValues = HttpUtility.ParseQueryString(json);
foreach (string key in keyValues.AllKeys)
{
parameters[key] = keyValues[key];
}
}
catch
{
// 如果两种方式都失败,保存原始内容
parameters["__raw_body"] = json;
}
}
catch (Exception ex)
{
log.Error($"处理 JSON 请求时出错: {ex.Message}");
}
}

return parameters;
}

// 使用示例
var wrapper = new HttpContextWrapper(HttpContext.Current);
var allParams = GetAllPostParameters(wrapper);

推荐做法:

  • 优先使用模型绑定ASP.NET MVC的模型绑定机制更安全高效。csharp[HttpPost] public ActionResult Submit(YourViewModel model) // 自动绑定POST数据 { // 直接使用model.Property }
  • 直接操作Request对象仅适用于动态数据或特殊情况。

MySQL误删数据了,如何快速恢复? - 苏三说技术 - 博客园

mikel阅读(241)

来源: MySQL误删数据了,如何快速恢复? – 苏三说技术 – 博客园

前言

最近星球中有位小伙伴说:他不小心把测试环境MySQL表中所有数据都误删了,问我要如何快速恢复?

幸好他误删的是测试环境,非生产环境。

我遇到过,之前有同事把生产环境会员表中的数据误删除的情况。

这篇文章跟大家一起聊聊MySQL如果误删数据了,要如何快速恢复。

希望对你会有所帮助。

1.为什么数据恢复如此重要?

2023年某电商平台误删20万用户数据,导致直接损失800万

某金融机构DBA误执行DROP TABLE,系统停摆6小时

这些事故背后,暴露的是误删数据之后恢复方案的缺失。

数据丢失的三大元凶

  1. 人为误操作(占75%):DELETE忘加WHERE、DROP TABLE手滑
  2. 程序BUG(占20%):循环逻辑错误、事务未回滚
  3. 硬件故障(占5%):磁盘损坏、机房断电

下面是数据丢失的主要原因:
image

那么,如果MySQL如果误删数据了,快速恢复数据的方案有哪些呢?

2.常见的数据恢复方案

方案1:Binlog日志恢复

该方案最常用。

适用场景:误执行DELETE、UPDATE

恢复流程

image

操作步骤

  1. 定位误操作位置
mysqlbinlog --start-datetime="2023-08-01 14:00:00" \
           --stop-datetime="2023-08-01 14:05:00" \
           mysql-bin.000001 > /tmp/err.sql
  1. 提取回滚SQL(使用python工具)
# parse_binlog.py
import pymysql
from pymysqlreplication import BinLogStreamReader

stream = BinLogStreamReader(
   connection_settings = {
       "host": "127.0.0.1",
       "port": 3306,
       "user": "root",
       "passwd": "root"},
   server_id=100,
   blocking=True,
   resume_stream=True,
   only_events=[DeleteRowsEvent, UpdateRowsEvent])

for binlogevent in stream:
   for row in binlogevent.rows:
       if isinstance(binlogevent, DeleteRowsEvent):
           # 生成INSERT语句
           print(f"INSERT INTO {binlogevent.table} VALUES {row['values']}")
       elif isinstance(binlogevent, UpdateRowsEvent):
           # 生成反向UPDATE
           print(f"UPDATE {binlogevent.table} SET {row['before_values']} WHERE {row['after_values']}")
  1. 执行恢复
python parse_binlog.py | mysql -u root -p db_name

方案2:延迟复制从库

该方案是金融级的方案。

适用场景:大规模误删数据

架构原理
image

配置步骤

  1. 设置延迟复制
STOP SLAVE;
CHANGE MASTER TO MASTER_DELAY = 1800; -- 延迟30分钟(1800秒)
START SLAVE;
  1. 误删后立即停止同步
STOP SLAVE;
  1. 将延迟从库提升为主库
RESET SLAVE ALL;
SHOW MASTER STATUS; -- 记录binlog位置

方案3:全量备份+增量恢复

适用场景:整表或整库误删

恢复流程

image

操作步骤

  1. 恢复全量备份
mysql -u root -p db_name < full_backup_20230801.sql
  1. 应用增量日志(跳过误操作点)
mysqlbinlog --start-position=100 --stop-position=500 \
          mysql-bin.000001 | mysql -u root -p

方案4:Undo日志恢复

该方案是InnoDB特有的。

适用场景:刚提交的误操作(事务未关闭)

核心原理
image

操作步骤

  1. 查询事务信息
SELECT * FROM information_schema.INNODB_TRX;
  1. 定位Undo页
SHOW ENGINE INNODB STATUS;
  1. 使用undrop-for-innodb工具
./undrop-for-innodb/system_parser -t user_data /var/lib/mysql/ibdata1

方案5:文件恢复

从物理备份中恢复,需要提前做备份。

适用场景:DROP TABLE误操作

恢复流程

image

操作步骤

  1. 安装恢复工具
yum install testdisk -y
  1. 扫描磁盘
photorec /dev/sdb1
  1. 重建表结构
CREATE TABLE user_data (...) ENGINE=InnoDB;
  1. 导入表空间
ALTER TABLE user_data DISCARD TABLESPACE;
cp recovered.ibd /var/lib/mysql/db_name/user_data.ibd
ALTER TABLE user_data IMPORT TABLESPACE;

方案6:云数据库快照恢复

适用场景:阿里云RDS、AWS RDS等云服务

操作流程(以阿里云为例)

image

最佳实践

  1. 设置策略:
    • 保留7天快照
    • 每4小时增量备份
  2. 误删后操作:
# 通过SDK创建临时实例
aliyun rds CloneInstance --DBInstanceId rm-xxxx \
                       --BackupId 111111111 \
                       --PayType Postpaid

3、恢复方案对比选型

方案 恢复粒度 时间窗口 复杂度 适用场景
Binlog日志恢复 行级 分钟级 小范围误删
延迟复制从库 库级 小时级 核心业务数据
全量+增量恢复 库级 小时级 整库丢失
Undo日志恢复 行级 秒级 极高 事务未提交
文件恢复 表级 不确定 极高 DROP TABLE操作
云数据库快照 实例级 分钟级 云环境

4.如何预防误删数据的情况?

4.1 权限控制(事前预防)

核心原则:最小权限分配

-- 禁止开发直接操作生产库
REVOKE ALL PRIVILEGES ON *.* FROM 'dev_user'@'%';

-- 只读账号配置
GRANT SELECT ON app_db.* TO 'read_user'@'%';

-- DML权限分离
CREATE ROLE dml_role;
GRANT INSERT, UPDATE, DELETE ON app_db.* TO dml_role;

4.2 操作规范(事中拦截)

  1. SQL审核:所有DDL必须走工单
  2. 高危操作确认:执行DROP前二次确认
-- 危险操作示例
DROP TABLE IF EXISTS user_data; -- 必须添加IF EXISTS
  1. WHERE条件检查:DELETE前先SELECT验证

4.3 备份策略(事后保障)

黄金备份法则:321原则

  • 3份备份(本地+异地+离线)
  • 2种介质(SSD+磁带)
  • 1份离线存储

总结

下面给大家总了数据恢复的三要三不要。

三要

  1. 立即冻结现场:发现误删马上锁定数据库。
  2. 优先使用Binlog:90%场景可通过日志恢复。
  3. 定期演练恢复:每季度做恢复测试。

三不要

  1. 不要心存侥幸:认为误删不会发生在自己身上。
  2. 不要盲目操作:恢复前先备份当前状态。
  3. 不要忽视监控:设置删除操作实时告警。

设计系统时,永远假设明天就会发生数据误删。

当灾难真正降临时,你会发现所有的预防措施都是值得的。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。

本文收录于我的技术网站http://www.susan.net.cn

最强ORM让你开发效率提升百倍 - 薛家明 - 博客园

mikel阅读(282)

来源: 最强ORM让你开发效率提升百倍 – 薛家明 – 博客园

最强ORM让你开发效率提升百倍

easy-query在经过2年的迭代目前已经在查询领域可以说是无敌的存在,任何orm都不是对手,这几年的功能点简单罗列一下

[x] 动态join:查询涉及到对应的关系表就会自动添加join反之则不会讲join加入到SQL中(2025年了感觉也不是什么新鲜特性了)
[x] 结构化DTO,自动根据DTO和表关系的路径自动筛选出需要的结构化属性(开发效率杀手)
[x] 结构化DTO额外配置,支持结构化DTO的返回下还能额外添加查询条件和筛选条件
[x] 隐式PARTITION BY
[X] 隐式子查询
[x] 子查询转GroupJoin全世界应该是独一份的功能,解决多对多深层关系在ORM中的子查询过多导致的性能问题真正解决了ORM在复杂查询下开发效率和性能的兼顾

框架地址 https://github.com/dromara/easy-query
文档地址 https://www.easy-query.com/easy-query-doc/
该文章demo地址 https://github.com/xuejmnet/eq-doc

刚好前几天我看到公众号有篇关于efcore的性能文章,我看了其实我一眼就知道了他的问题就是eq的子查询转GroupJoin但是正如强大的efcore也是没有实现该功能,话不多说本章节我们将入门通过公众号的demo实现大部分帖子相关的查询和功能

建模

实体关系如下:

  • 用户User:每个用户有多篇帖子和多条评论和多个点赞
  • 分类Category:帖子所属分类类目支持多个分类一个帖子或者多个帖子公用同一个分类
  • 帖子Post:每篇帖子有多个分类并可获得多个赞
  • 评论Comment:每条评论属于一个用户并关联一篇帖子 且评论支持楼中楼
  • 点赞Like:每个赞关联一篇帖子,多个点赞可以关联同一篇帖子
  • 分类帖子关联CategoryPost:帖子和分类的关联关系表

entity-relation

点击查看实体代码

帖子相关查询

帖子分页

对Post表进行分页按publishAt倒序进行排序按title进行搜索

首先我们定一个公用类


@Data
public class PageRequest {
    private Integer pageIndex=1;
    private Integer pageSize=5;
}

定义请求参数

@Data
public class PostPageRequest extends PageRequest {
    private String title;
}

分页动态条件

    @PostMapping("/page")
    public EasyPageResult<Post> page(@RequestBody PostPageRequest request) {
        return easyEntityQuery.queryable(Post.class)
                .where(t_post -> {
//                    if(EasyStringUtil.isNotBlank(request.getTitle())){
//                        t_post.title().contains(request.getTitle());
//                    }
                    t_post.title().contains(EasyStringUtil.isNotBlank(request.getTitle()),request.getTitle());
                })
                .orderBy(t_post -> t_post.publishAt().desc())
                .toPageResult(request.getPageIndex(),request.getPageSize());
    }

这边提供了两种方式实现动态查询,当title不为空的时候加入表达式筛选,执行我们来看看实际情况

  • 使用if函数包裹表达式断言,支持任意java表达式
  • 使用断言函数第一个参数重载,默认第一个参数为true才会执行断言操作
  • 使用where重载第一个参数为true执行当前where

请求参数

{"pageIndex":1,"pageSize":5,"title":"电影"}
==> Preparing: SELECT COUNT(*) FROM `t_post` WHERE `title` LIKE CONCAT('%',?,'%')
==> Parameters: 电影(String)


==> Preparing: SELECT `id`,`title`,`content`,`user_id`,`publish_at` FROM `t_post` WHERE `title` LIKE CONCAT('%',?,'%') ORDER BY `publish_at` DESC LIMIT 3
==> Parameters: 电影(String)

container还是like!!!
> 细心地朋友会发现我们使用了contains函数而不是like函数,因为当传入的查询条件本身带有%时那么like会让%变成通配符,而contains会将%视为被查询的一部分,这是需要用户注意的,具体使用contains还是like应该有用户自行决断

推荐写法🔥: 可能由用户会问如果我添加的条件有很多怎么办难道每一个都要这么写一遍吗?eq贴心的提供了多种方式来实现动态查询比如filterConfigure


    easyEntityQuery.queryable(Post.class)
          .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
          .where(t_post -> {
              t_post.title().contains(request.getTitle());
          })
          .orderBy(t_post -> t_post.publishAt().desc())
          .toPageResult(pageIndex,pageSize);

通过添加filterConfigure支持让参数为null不参与业务,如果是字符串则必须保证isNotBlank,当然用户也可以通过自己的自定义来实现

更多的动态条件设置请参考文档

> 正常我们推荐使用filterConfigure或者使用if函数包裹条件而不是使用方法参数的第一个boolean类型来控制,因为参数boolean类型重载相对会让表达式不够直观且难以阅读所以我们极力推荐另外几种方式

我们学会了如何在单表查询分页下使用动态参数控制SQL,那么接下来我们将学习如何使用参数外部控制动态排序

分页动态排序

首先我们对请求的条件进行修改

@Data
public class PostPage3Request extends PageRequest {
    private String title;

    private List<InternalOrder> orders;

    @Data
     public static class InternalOrder{
         private String property;//这个是查询Post内的属性字段
         private boolean asc;//表示是否需要正序排序
     }
}

@PostMapping("/page3")
public EasyPageResult<Post> page3(@RequestBody PostPage3Request request) {
    return easyEntityQuery.queryable(Post.class)
            .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
            .where(t_post -> {
                t_post.title().contains(request.getTitle());
            })
            //这个request.getOrders()!=null为true才会执行后续的方法也可以使用if包裹
            //当然如果你能确保request.getOrders()肯定不等于null的那么不需要加这个判断
            .orderBy(request.getOrders()!=null,t_post -> {
                for (PostPage3Request.InternalOrder order : request.getOrders()) {
                    //anyColumn表示需要排序的字段,orderBy表示使用正序还是倒序
                    t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                }
            })
            .toPageResult(request.getPageIndex(),request.getPageSize());
}

请求参数

{"pageIndex":1,"pageSize":5,"title":"","orders":[{"property":"publishAt","asc":false},{"property":"title","asc":true}]}

执行请求后生成的sql为

==> Preparing: SELECT COUNT(*) FROM `t_post`
<== Time Elapsed: 13(ms)

==> Preparing: SELECT `id`,`title`,`content`,`user_id`,`publish_at` FROM `t_post` ORDER BY `publish_at` DESC,`title` ASC LIMIT 5
<== Time Elapsed: 17(ms)

支持我们已经支持了分页的动态排序,当然动态排序功能远不止此,更多动态排序请点击链接

分页join筛选

当然对于大部分业务而言实体对象不是一个孤单对象,当前的Post对象也是如此,我们经常会有连表或者子查询等操作,那么eq是如何快速便捷的实现join的呢

下面这种通过关联关系实现join的操作我们称之为隐式join

  • 查询帖子要求查询条件是某个用户的

首先因为涉及到join那么eq提供了关联关系将原先的Post单表和用户表进行多对一的关联

通过插件生成关联关系

  • 第一步呼出ui界面

navigate2

  • 第二步设置关联关系

navigate-ui

选择好对应的关联键后点击确认插件会帮你自动生成强类型属性|lombok属性或字符串

当然你也可以手写关联关系

@Data
@Table("t_post")
@EntityProxy
@EasyAlias("t_post")
@EasyAssertMessage("未找到对应的帖子信息")
public class Post implements ProxyEntityAvailable<Post, PostProxy> {
    @Column(primaryKey = true, comment = "帖子id")
    private String id;
    @Column(comment = "帖子标题")
    private String title;
    @Column(comment = "帖子内容")
    private String content;
    @Column(comment = "用户id")
    private String userId;
    @Column(comment = "发布时间")
    private LocalDateTime publishAt;

    /**
     * 发帖人
     **/
    @Navigate(value = RelationTypeEnum.ManyToOne, selfProperty = {PostProxy.Fields.userId}, targetProperty = {UserProxy.Fields.id})
    private User user;
}

修改完实体对象后我们做了一个post.userId=user.id的关系接下来我们创建查询对象


@Data
public class PostPage4Request extends PageRequest {
    private String title;

    private String userName; ①

    private List<InternalOrder> orders;

    @Data
     public static class InternalOrder{
         private String property;
         private boolean asc;
     }
}

    @PostMapping("/page4")
    public EasyPageResult<Post> page4(@RequestBody PostPage4Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders()!=null,t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .toPageResult(request.getPageIndex(),request.getPageSize());
    }

  • ①是我们新添加的查询属性userName

接下来我们发送请求

{
	"pageIndex": 1,
	"pageSize": 5,
	"title": "",
	"userName": "用户A","orders": [{
		"property": "user.createAt","asc": false
	}, {
		"property": "title",
		"asc": true
	}]
}
==> Preparing: SELECT COUNT(*) FROM `t_post` t LEFT JOIN `t_user` t1 ON t1.`id` = t.`user_id` WHERE t1.`name` LIKE CONCAT('%',?,'%')
==> Parameters: 用户A(String)

==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t LEFT JOIN `t_user` t1 ON t1.`id` = t.`user_id` WHERE t1.`name` LIKE CONCAT('%',?,'%') ORDER BY t1.`create_at` DESC,t.`title` ASC LIMIT 3
==> Parameters: 用户A(String)

  • ①我们使用了用户名称作为筛选条件
  • ②我们使用了用户下的创建时间作为排序时间,user.createAtuser是关联导航属性就是我们之前定义的多对一,createAt是这个导航属性的字段名

当我们传递userName那么看下sql会是怎么样的

==> Preparing: SELECT COUNT(*) FROM `t_post` t

==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t LEFT JOIN `t_user` t1 ON t1.`id` = t.`user_id` ORDER BY t1.`create_at` DESC,t.`title` ASC LIMIT 5

我们惊讶的发现eq非常智能的将分页中的total查询的所有join都去掉了,并且返回集合的那个sql任然保留,如果我们将orderBy也去掉会发现eq居然整个sql都不会添加join选项

==> Preparing: SELECT COUNT(*) FROM `t_post` t
<== Time Elapsed: 21(ms)
<== Total: 1
==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t ORDER BY t.`title` ASC LIMIT 5
<== Time Elapsed: 18(ms)
<== Total: 5

你没有看错动态join就是这么简单,这就是真正的只能orm框架

回顾一下

  • 首先我们添加了动态查询筛选器配置filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)让所有条件参数非null非空的值支持加入条件,这样就做到了动态查询的特性
  • 第二点因为我们传递userName参数,所以表达式的t_post.user().name().contains(request.getUserName());会生效并且会自动根据对应的关系使用leftJoin将post和user关联起来并且查询post下的user下的姓名
  • 第三点因为我们没有传递userName参数,所以表达式的t_post.user().name().contains(request.getUserName());不会生效,但是orderByuser.createAt还是会生效,所以page的时候total的哪一次查询因为没有使用user表所以不会join,但是toList的那一次因为orderBy用到了所以任然会进行leftJoin

扩展篇

为什么使用leftJoin

因为任何两张表的关系在没有明确指定一定存在的情况下那么leftJoin的操作是不会影响主表的结果集,假如每个Post并不是都会有一个user的情况下我如果使用user.createAt进行排序那么inner join会让主表的结果集变少,但这是完全不被允许的这种做法会大大增加用户使用的心智负担

那么如果我希望使用innerJoin而不是leftJoin呢,我们可以再配置@Navigate的时候通过属性required=true来告知框架Post必定有user

//.....省略其它代码
public class Post{

    /**
     * 发帖人
     **/
    @Navigate(value = RelationTypeEnum.ManyToOne,
            selfProperty = {PostProxy.Fields.userId},
            targetProperty = {UserProxy.Fields.id},
            required = true)private User user;
}

添加①属性required = true这样查询我们就能够发现框架会智能的使用innerJoin而不是leftJoin

==> Preparing: SELECT COUNT(*) FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` WHERE t1.`name` LIKE CONCAT('%',?,'%')
==> Parameters: 用户A(String)


==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` WHERE t1.`name` LIKE CONCAT('%',?,'%') ORDER BY t1.`create_at` DESC,t.`title` ASC LIMIT 3
==> Parameters: 用户A(String)

隐式join怎么添加on条件

很多细心的盆友可能希望我们在关联用户的时候添加额外的查询条件那么应该如何实现呢

请求json为如下不查询userName,不进行user的属性排序

{
    "pageIndex":1,
    "pageSize":5,
    "title":"",
    "userName":"",
    "orders":[
        {
            "property":"title",
            "asc":true
        }
    ]
}

    @PostMapping("/page4")
    public EasyPageResult<Post> page4(@RequestBody PostPage4Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.user().filter(u -> { ①
                        u.phone().ne("123");
                    });
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders() != null, t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .toPageResult(request.getPageIndex(), request.getPageSize());
    }
==> Preparing: SELECT COUNT(*) FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` AND t1.`phone` <> ?
==> Parameters: 123(String)

==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` AND t1.`phone` <> ? ORDER BY t.`title` ASC LIMIT 5
==> Parameters: 123(String)

  • ①会将条件添加到join的on上面实现关联关系的定义筛选

奇怪的事情发生了为什么这次我们没有传递user相关的数据依然给我们把inner join加上了,其实本质是inner joinon条件是会影响主表数量,本质和写到where里面是一样的,所以虽然你没有where的条件但是inner joinon条件依然会让整个表达式的join无法动态优化,

filter!!!
> 关联关系的filter会以join on的形式出现在sql中,相当于是额外对关联关系的筛选,缩小关系表,又因为post和user的关系为post必定有user:required=true所以会使用inner join代替left join

帖子内容返回用户名

我们之前使用关联让帖子筛选支持用户姓名,那么如果我们需要返回帖子和对应的发帖人姓名应该怎么处理呢

创建响应dto

/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post} ①
 *
 * @author xuejiaming
 */
@Data
@EntityProxy@SuppressWarnings("EasyQueryFieldMissMatch")public class PostPage4Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;
    
    private String userName; ④
}

  • ①在dto上标记当前表来自于哪张表,插件可以提示相关错误
  • ②自定义dto对象代理实现表达式内赋值
  • ③因为①的存在所以④会有插件提示不存在这个字段的警告,通过添加③来让插件不进行提示
  • ④额外增加一个字段接受用户姓名

    @PostMapping("/page5")
    public EasyPageResult<PostPage4Response> page5(@RequestBody PostPage4Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders() != null, t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .select(t_post -> new PostPage4ResponseProxy() ①
                        .id().set(t_post.id())
                        .title().set(t_post.title())
                        .content().set(t_post.content())
                        .userId().set(t_post.userId())
                        .publishAt().set(t_post.publishAt())
                        .userName().set(t_post.user().name()) ②
                )
                .toPageResult(request.getPageIndex(), request.getPageSize());
    }
  • ①通过@EntityProxy注解eq框架会生成代理对象,改对象支持dsl表达式赋值
  • ②通过使用隐式join的方式赋值到dto中
==> Preparing: SELECT COUNT(*) FROM `t_post` t

==> Preparing: SELECT t.`id` AS `id`,t.`title` AS `title`,t.`content` AS `content`,t.`user_id` AS `user_id`,t.`publish_at` AS `publish_at`,t1.`name` AS `user_name` FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` ORDER BY t.`title` ASC LIMIT 5

我们可以看到生成的sql将joinuser表的name赋值给了dto的userName属性

那么如果属性很多又一样我们是否可以有建议方便的做法呢

.select(t_post -> new PostPage4ResponseProxy()
        .selectAll(t_post) ①
        .userName().set(t_post.user().name())
)
  • ①将原先的属性赋值使用selectAll进行复制如果存在不需要的字段则可通过selectIgnores进行排除如下

        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders() != null, t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .select(t_post -> new PostPage4ResponseProxy()
                        .selectAll(t_post)//查询post的全字段
                        .selectIgnores(t_post.title())//排除title
                        .userName().set(t_post.user().name())
                )
                .toPageResult(request.getPageIndex(), request.getPageSize());
==> Preparing: SELECT COUNT(*) FROM `t_post` t

==> Preparing: SELECT t.`id`,t.`content`,t.`user_id`,t.`publish_at`,t1.`name` AS `user_name` FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` ORDER BY t.`title` ASC LIMIT 5

那么是否有不使用@EntityProxy的方式来返回呢

include查询

有时候我们希望返回的数据内容包含用户相关信息那么我们应该如何操作才能将返回的post信息里面包含user信息呢


    @PostMapping("/page7")
    public EasyPageResult<Post> page7(@RequestBody PostPage7Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .include(t_post -> t_post.user())
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .toPageResult(request.getPageIndex(), request.getPageSize());
    }

这次我们选择返回post本体对象,并且不定义dto结构返回

==> Preparing: SELECT COUNT(*) FROM `t_post` t


==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t LIMIT 5


==> Preparing: SELECT t.`id`,t.`name`,t.`phone`,t.`create_at` FROM `t_user` t WHERE t.`id` IN (?,?,?,?)
==> Parameters: c529b9ba-a90d-490e-9bad-15ef7c4f33cc(String),8510a91a-274e-494f-9325-f55c004706e5(String),1b59fa07-1824-4e01-a491-c780d167cf44(String),23376c96-a315-4a3f-aeb8-2e29c02f330b(String)

框架通过多次分批返回将整个数据返回(注意数据二次查询没有N+1问题完全放心使用),且返回的数据是以结构化对象的形式来返回到前端的

返回的响应数据


        {
            "id": "0c7fd05f-f999-4fcc-8c98-c0509b22b7f1",
            "title": "健身计划分享",
            "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
            "userId": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
            "publishAt": "2025-08-03T21:24:00.577",
            "user": {
                "id": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
                "name": "用户D",
                "phone": "18806982998",
                "createAt": "2025-07-10T13:24:00.576"
            }
        }

那么如果我们希望返回的时候只返回user的id和name应该如何实现

  • 第一种返回数据库对象但是只查询id和name
  • 第二种定义dto使用selectAutoInclude

include部分列

easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .include(t_post -> t_post.user(),uq->{
                    uq.select(u->u.FETCHER.id().name());
                })
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .toList()
==> Preparing: SELECT COUNT(*) FROM `t_post` t


==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t LIMIT 5


==> Preparing: SELECT t.`id`,t.`name` FROM `t_user` t WHERE t.`id` IN (?,?,?,?)
==> Parameters: c529b9ba-a90d-490e-9bad-15ef7c4f33cc(String),8510a91a-274e-494f-9325-f55c004706e5(String),1b59fa07-1824-4e01-a491-c780d167cf44(String),23376c96-a315-4a3f-aeb8-2e29c02f330b(String)

返回的响应数据


        {
            "id": "0c7fd05f-f999-4fcc-8c98-c0509b22b7f1",
            "title": "健身计划分享",
            "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
            "userId": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
            "publishAt": "2025-08-03T21:24:00.577",
            "user": {
                "id": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
                "name": "用户D",
                "phone": null,
                "createAt": null
            }
        }

include函数存在多个重载其中第二参数用于描述前一个include和对应的额外操作这边设置为只返回id和name

我们看到查询的时候仅查询id和name

这种查询返回的任然是数据库对象所以无法再返回的形状上移除phonecreateAt,那么是否有一种办法可以做到形状确定呢

答案是有的时候dto来代替数据库对象在使用selectAutoIncludeapi

结构化dto

结构化dto用来返回dto且形状确定适合生成文档和下游数据交互那么可以通过安装插件后进行如下操作

第一步我们使用插件创建结构化dto

在dto的package处右键选择CreateStructDTO

csdto1

第二步选择要返回的对象

csdto2

第三步勾选要返回的字段

csdto3

确定dto名称后框架会帮我们直接生成dto对象


/**
 * this file automatically generated by easy-query struct dto mapping
 * 当前文件是easy-query自动生成的 结构化dto 映射
 * {@link com.eq.doc.domain.Post }
 *
 * @author xuejiaming
 * @easy-query-dto schema: normal
 */
@Data
public class PostDTO {


    @Column(comment = "帖子id")
    private String id;
    @Column(comment = "帖子标题")
    private String title;
    @Column(comment = "帖子内容")
    private String content;
    @Column(comment = "发布时间")
    private LocalDateTime publishAt;
    /**
     * 发帖人
     **/
    @Navigate(value = RelationTypeEnum.ManyToOne)
    private InternalUser user;


    /**
     * {@link com.eq.doc.domain.User }
     */
    @Data
    public static class InternalUser {
        @Column(comment = "用户id")
        private String id;
        @Column(comment = "用户姓名")
        private String name;


    }

}


@PostMapping("/selectAutoInclude")
public List<PostDTO> selectAutoInclude(@RequestBody PostPage7Request request) {
    return easyEntityQuery.queryable(Post.class)
            .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
            .include(t_post -> t_post.user(),uq->{
                uq.select(u->u.FETCHER.id().name());
            })
            .where(t_post -> {
                t_post.title().contains(request.getTitle());
                t_post.user().name().contains(request.getUserName());
            })
            .selectAutoInclude(PostDTO.class) ①
            .toList();
}
==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`publish_at`,t.`user_id` AS `__relation__userId` FROM `t_post` t


==> Preparing: SELECT t.`id`,t.`name` FROM `t_user` t WHERE t.`id` IN (?,?,?,?,?)
==> Parameters: c529b9ba-a90d-490e-9bad-15ef7c4f33cc(String),8510a91a-274e-494f-9325-f55c004706e5(String),1b59fa07-1824-4e01-a491-c780d167cf44(String),23376c96-a315-4a3f-aeb8-2e29c02f330b(String),947ee5fd-5fd0-4889-94e3-03c5efff2c3a(String)

    {
        "id": "0c7fd05f-f999-4fcc-8c98-c0509b22b7f1",
        "title": "健身计划分享",
        "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
        "publishAt": "2025-08-03T21:24:00.577",
        "user": {
            "id": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
            "name": "用户D"
        }
    }

框架依然通过in来解决n+1的问题实现结构化的对象返回,框架支持任意结构化对象返回包括结构化对象扁平化

  • selectAutoIncludeselectapi和include的结合,会自动安装dto的要求将数据结构进行组装返回

说明!!!
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来

selectAutoInclude!!!
selectAutoInclude这个api是eq的核心数据查询api之一用户必须完全掌握可以提高1000%的效率,并且没有n+1问题支持后续一对一 一对多的任意数据穿透查询

返回数据的时候我们如果不希望以结构化对象的形式返回,希望将user对象平铺到整个post中,又不希望使用set手动复制那么可以通过@NavigateFlat来实现额外属性的获取


/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post} ①
 *
 * @author xuejiaming
 */
@Data
public class PostPage6Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    @NavigateFlat(pathAlias = "user.id")private String userName;
}

注意我们必须要将①的link表示添加上,这样我们在写②的pathAlias时插件会自动给出相应的提示,查询是我们将使用selectAutoInclude来实现万能查询


    @PostMapping("/page6")
    public EasyPageResult<PostPage6Response> page6(@RequestBody PostPage4Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders() != null, t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .selectAutoInclude(PostPage6Response.class)
                .toPageResult(request.getPageIndex(), request.getPageSize());
    }
==> Preparing: SELECT COUNT(*) FROM `t_post` t


==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t ORDER BY t.`title` ASC LIMIT 5


==> Preparing: SELECT `id` FROM `t_user` WHERE `id` IN (?,?,?)
==> Parameters: 8510a91a-274e-494f-9325-f55c004706e5(String),23376c96-a315-4a3f-aeb8-2e29c02f330b(String),c529b9ba-a90d-490e-9bad-15ef7c4f33cc(String)

> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来

@NavigateFlat支持任意级别对象关系获取,如果对象关系获取中间存在toMany无论是OneToMany还是ManyToMany那么最终都会变成List<?>集合

帖子内容带评论

简单的额外对象获取后我们希望实现返回给前端帖子内容并且携带上前三条相关评论,那么eq有几种方式呢

  • NaviagteFlat+limit+union
  • NaviagteFlat+limit+partition by
  • subquery+limit+joining

评论关系添加


@Data
@Table("t_post")
@EntityProxy
@EasyAlias("t_post")
@EasyAssertMessage("未找到对应的帖子信息")
public class Post implements ProxyEntityAvailable<Post, PostProxy> {
    //....业务字段

    /**
     * 发帖人
     **/
    @Navigate(value = RelationTypeEnum.ManyToOne,
            selfProperty = {PostProxy.Fields.userId},
            targetProperty = {UserProxy.Fields.id},
            required = true)
    private User user;


    /**
     * 评论信息
     **/
    @Navigate(value = RelationTypeEnum.OneToMany,
            selfProperty = {PostProxy.Fields.id},
            targetProperty = {CommentProxy.Fields.postId})
    private List<Comment> commentList;
}

因为帖子和评论的关系是一对多所以我们在帖子里面通过插件或者手动添加关联关系

limit+union

首先我们定义好需要返回的对象


/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post}
 *
 * @author xuejiaming
 */
@Data
public class PostPage8Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    @NavigateFlat(pathAlias = "user.id")
    private String userName;

    /**
     * 评论信息
     **/
    @Navigate(value = RelationTypeEnum.OneToMany,orderByProps = {
            @OrderByProperty(property = "createAt",asc = true)
    },limit = 3)
    private List<InternalComment> commentList;

    /**
     * {@link Comment}
     **/
    @Data
    public static class InternalComment {
        private String id;
        private String parentId;
        private String content;
        private LocalDateTime createAt;
    }

}

这样我们就设置好了要返回的数据并且支持额外返回3条评论


    @PostMapping("/postWithCommentPage")
    public List<PostPage8Response> postWithCommentPage(@RequestBody PostPage7Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .selectAutoInclude(PostPage8Response.class).toList();
    }

    {
        "id": "0c7fd05f-f999-4fcc-8c98-c0509b22b7f1",
        "title": "健身计划分享",
        "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
        "userId": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
        "publishAt": "2025-08-03T21:24:00.577",
        "userName": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
        "commentList": [
            {
                "id": "67c9ceb0-3eef-44ba-8bbc-c0d1f15f00ad",
                "parentId": "0",
                "content": "期待更多这样的内容",
                "createAt": "2025-08-05T17:24:00.579"
            },
            {
                "id": "d7753586-4bb9-448b-bedb-b178df897bca",
                "parentId": "fa80aaa0-9742-4a02-9209-a08d1bd979df",
                "content": "@用户B 我也这么认为",
                "createAt": "2025-08-06T00:24:00.579"
            },
            {
                "id": "2b40e873-5c0d-41c4-bf10-a38461017300",
                "parentId": "67c9ceb0-3eef-44ba-8bbc-c0d1f15f00ad",
                "content": "@用户C 具体是指哪方面?",
                "createAt": "2025-08-06T03:24:00.579"
            }
        ]
    }

我们看到真的和编写的dto如出一辙的返回了查询结果

-- 第1条sql数据
 SELECT
        t.`id`,
        t.`title`,
        t.`content`,
        t.`user_id`,
        t.`publish_at` 
    FROM
        `t_post` t

-- 第2条sql数据
SELECT t1.`id`, t1.`parent_id`, t1.`content`, t1.`create_at`, t1.`post_id` AS `__relation__postId`
FROM (
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = 'c529b9ba-a90d-490e-9bad-15ef7c4f33cc'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = '8510a91a-274e-494f-9325-f55c004706e5'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = '1b59fa07-1824-4e01-a491-c780d167cf44'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = '23376c96-a315-4a3f-aeb8-2e29c02f330b'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = '947ee5fd-5fd0-4889-94e3-03c5efff2c3a'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
) t1
-- 第3条sql数据
   SELECT
        `id` 
    FROM
        `t_user` 
    WHERE
        `id` IN (?, ?, ?, ?, ?)

一个生成了三条sql,中limit+union是第二条sql,但是union相对sql会变得复杂并且冗余所以我们尝试eq提供的第二种方式

limit+partition by

springboot的application.yml增加配置项

easy-query:
  #支持的数据库
  database: mysql
  #对象属性和数据库列名的转换器
  name-conversion: underlined
  default-track: true
  include-limit-mode: partition
  • include-limit-mode: partition这句话让原先的union all变成partition(后续partition可能会变成默认)

接下来我们继续请求


-- 第1条sql数据

    SELECT
        t.`id`,
        t.`title`,
        t.`content`,
        t.`user_id`,
        t.`publish_at` 
    FROM
        `t_post` t


-- 第2条sql数据

    SELECT
        t2.`id` AS `id`,
        t2.`parent_id` AS `parent_id`,
        t2.`content` AS `content`,
        t2.`create_at` AS `create_at`,
        t2.`post_id` AS `__relation__postId` 
    FROM
        (SELECT
            t1.`id` AS `id`,
            t1.`parent_id` AS `parent_id`,
            t1.`content` AS `content`,
            t1.`user_id` AS `user_id`,
            t1.`post_id` AS `post_id`,
            t1.`create_at` AS `create_at` 
        FROM
            (SELECT
                t.`id`,
                t.`parent_id`,
                t.`content`,
                t.`user_id`,
                t.`post_id`,
                t.`create_at`,
                (ROW_NUMBER() OVER (PARTITION 
            BY
                t.`post_id` 
            ORDER BY
                t.`create_at` ASC)) AS `__row__` 
            FROM
                `t_comment` t 
            WHERE
                t.`post_id` IN ('09e8395e-b7f7-48b4-8227-fcbf96c35d1e', '5d40f560-af15-4566-93cd-9359e0a27501', '76cdba56-b1f8-4432-bc0e-764d491c6cd5', '81eb5fb7-ec57-45d3-b9b9-5e6217ec4d31', '8a6f16a6-b51e-4a39-9ea9-fda57502bb29', 'a6982186-afc5-4f49-977d-97ff8c25cd9f', 'b1eb997d-9cb0-40ca-9495-a9d41da21125', 'b4f74aeb-3868-4810-9845-cab9e882229b', 'bf7e62ee-d833-4f5a-9a0a-07b9634ba26a', 'c6d0631f-160a-4a8c-8401-62db614f87c8', 'd9629994-d9fa-46a3-bd7c-5982f0900a3d', 'ed01ea8a-4162-42ba-a632-dd6d67bf9d45', 'f27edcf7-0fd8-44e3-b3cc-cbba41427dfe')) t1 
        WHERE
            t1.`__row__` >= 1 
            AND t1.`__row__` <= 3) t2



-- 第3条sql数据

    SELECT
        `id` 
    FROM
        `t_user` 
    WHERE
        `id` IN ('15b6a7c1-3f27-4d21-b67c-9c05cd9bf4b6', '2ae21dfa-9330-4d8c-bbfa-6b4618c56c45', '6e50464d-17a7-4f12-8458-c896d55dd276', '1d2a9d56-63df-4413-bd83-ff9a0c0a2166', '2ede4599-8f1a-4d0c-a0af-0dd50d903b87')
{
        "id": "5d40f560-af15-4566-93cd-9359e0a27501",
        "title": "健身计划分享",
        "content": "# 这是用户用户E的帖子内容\n包含丰富的文本内容...",
        "userId": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "publishAt": "2025-08-04T08:09:30.301",
        "userName": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "commentList": [
            {
                "id": "de5337b2-e13c-49f3-9b15-ac393784fc6f",
                "parentId": "46da0914-b046-45ad-8847-1c65c82ac71c",
                "content": "@用户C 有不同看法:",
                "createAt": "2025-08-07T06:09:30.304"
            },
            {
                "id": "daf5102c-b4dd-4f65-bee6-9b3df4f1b5d9",
                "parentId": "0",
                "content": "写得真详细",
                "createAt": "2025-08-07T09:09:30.304"
            },
            {
                "id": "46da0914-b046-45ad-8847-1c65c82ac71c",
                "parentId": "0",
                "content": "期待更多这样的内容",
                "createAt": "2025-08-07T10:09:30.304"
            }
        ]
    }

我们看到通过简单的配置我们将一对多返回前n条变动轻松简单并且可以快速实现支持分页,但是细心的朋友肯定发现了一个问题,我们需要的评论并不是平铺到整个post贴子的,帖子和评论虽然是一对多但是评论自己也是自关联,评论设计也是楼中楼为支持的那么我们应该如何设置让我们返回的评论支持返回第一层级呢

EXTRA_AUTO_INCLUDE_CONFIGURE

使用eq的EXTRA_AUTO_INCLUDE_CONFIGURE可以对selectAutoInclude的查询添加额外字段或额外搜索排序等处理

关于EXTRA_AUTO_INCLUDE_CONFIGURE的更多信息请查看文档

第一步对原始的dto对象进行插件快速提示插入EXTRA_AUTO_INCLUDE_CONFIGURE

extra-include-tip

我们移除select操作因为我们不需要

最终我们的返回dto如下


/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post}
 *
 * @author xuejiaming
 */
@Data
public class PostPage9Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    @NavigateFlat(pathAlias = "user.id")
    private String userName;

    /**
     * 评论信息
     **/
    @Navigate(value = RelationTypeEnum.OneToMany,orderByProps = {
            @OrderByProperty(property = "createAt",asc = true)
    },limit = 3)
    private List<InternalComment> commentList;

    /**
     * {@link Comment}
     **/
    @Data
    public static class InternalComment {


        private static final ExtraAutoIncludeConfigure EXTRA_AUTO_INCLUDE_CONFIGURE = CommentProxy.TABLE.EXTRA_AUTO_INCLUDE_CONFIGURE()
                .where(t_comment -> {
                    t_comment.parentId().eq("0");
                });

        private String id;
        private String parentId;
        private String content;
        private LocalDateTime createAt;
    }

}

我们看中间sql如下


    SELECT
        t2.`id` AS `id`,
        t2.`parent_id` AS `parent_id`,
        t2.`content` AS `content`,
        t2.`create_at` AS `create_at`,
        t2.`post_id` AS `__relation__postId` 
    FROM
        (SELECT
            t1.`id` AS `id`,
            t1.`parent_id` AS `parent_id`,
            t1.`content` AS `content`,
            t1.`user_id` AS `user_id`,
            t1.`post_id` AS `post_id`,
            t1.`create_at` AS `create_at` 
        FROM
            (SELECT
                t.`id`,
                t.`parent_id`,
                t.`content`,
                t.`user_id`,
                t.`post_id`,
                t.`create_at`,
                (ROW_NUMBER() OVER (PARTITION 
            BY
                t.`post_id` 
            ORDER BY
                t.`create_at` ASC)) AS `__row__` 
            FROM
                `t_comment` t 
            WHERE
                t.`parent_id` = '0'AND t.`post_id` IN ('09e8395e-b7f7-48b4-8227-fcbf96c35d1e', '5d40f560-af15-4566-93cd-9359e0a27501', '76cdba56-b1f8-4432-bc0e-764d491c6cd5', '81eb5fb7-ec57-45d3-b9b9-5e6217ec4d31', '8a6f16a6-b51e-4a39-9ea9-fda57502bb29', 'a6982186-afc5-4f49-977d-97ff8c25cd9f', 'b1eb997d-9cb0-40ca-9495-a9d41da21125', 'b4f74aeb-3868-4810-9845-cab9e882229b', 'bf7e62ee-d833-4f5a-9a0a-07b9634ba26a', 'c6d0631f-160a-4a8c-8401-62db614f87c8', 'd9629994-d9fa-46a3-bd7c-5982f0900a3d', 'ed01ea8a-4162-42ba-a632-dd6d67bf9d45', 'f27edcf7-0fd8-44e3-b3cc-cbba41427dfe')) t1 
        WHERE
            t1.`__row__` >= 1 
            AND t1.`__row__` <= 3) t2
  • ①是我们通过额外配置添加上去的

返回的json如下

{
        "id": "b4f74aeb-3868-4810-9845-cab9e882229b",
        "title": "初探人工智能",
        "content": "# 这是用户用户E的帖子内容\n包含丰富的文本内容...",
        "userId": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "publishAt": "2025-08-07T02:09:30.301",
        "userName": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "commentList": [
            {
                "id": "238fea11-c5d1-4485-977d-a0126cb74402",
                "parentId": "0",
                "content": "期待更多这样的内容",
                "createAt": "2025-08-07T09:09:30.304"
            },
            {
                "id": "e216eaf8-bf15-4eeb-aa4c-6489be83c355",
                "parentId": "0",
                "content": "内容很实用",
                "createAt": "2025-08-07T16:09:30.304"
            },
            {
                "id": "830bd1d9-1600-43a2-94b7-f6426a8a78c9",
                "parentId": "0",
                "content": "写得真详细",
                "createAt": "2025-08-07T17:09:30.304"
            }
        ]
    }

我们返回的post节点完美的符合我们内容

但是有时候我们可能需要返回的是post信息和前三条内容并且将前三条内容合并到一个字段上去那么应该怎么做

joining逗号分割

一如既往我们还是定义对应的dto


@Data
@EntityProxy
public class PostPage10Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    private String userName;
    
    private String commentContent;

}



    @PostMapping("/postWithCommentPage3")
    public List<PostPage10Response> postWithCommentPage3(@RequestBody PostPage7Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .select(t_post -> new PostPage10ResponseProxy()
                        .selectAll(t_post)
                        .userName().set(t_post.user().name())
                        .commentContent().set(t_post.commentList().where(c->c.parentId().eq("0")).elements(0,2).joining(c->c.content()))
                ).toList();
    }

我们来看下表达式t_post.commentList().where(c->c.parentId().eq("0")).joining(c->c.content())这个表达式是将post下方的评论集合commentList通过where筛选取前三个的content内容合并

{
        "id": "09e8395e-b7f7-48b4-8227-fcbf96c35d1e",
        "title": "夏日旅行攻略",
        "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
        "userId": "15b6a7c1-3f27-4d21-b67c-9c05cd9bf4b6",
        "publishAt": "2025-08-05T03:09:30.301",
        "userName": "用户D",
        "commentContent": "非常好的分享!,期待更多这样的内容,非常好的分享!"
    },
    {
        "id": "5d40f560-af15-4566-93cd-9359e0a27501",
        "title": "健身计划分享",
        "content": "# 这是用户用户E的帖子内容\n包含丰富的文本内容...",
        "userId": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "publishAt": "2025-08-04T08:09:30.301",
        "userName": "用户E",
        "commentContent": "完全同意你的观点,期待更多这样的内容,写得真详细"
    }

通过结果我们可以清晰地看到commentContentjoining函数通过逗号分割组合在一起了
我们再来看对应的sql


    SELECT
        t.`id`,
        t.`title`,
        t.`content`,
        t.`user_id`,
        t.`publish_at`,
        t1.`name` AS `user_name`,
        (SELECT
            GROUP_CONCAT(t2.`content` SEPARATOR ',') 
        FROM
            `t_comment` t2 
        WHERE
            t2.`post_id` = t.`id` 
            AND t2.`parent_id` = '0' 
        LIMIT
            3) AS `comment_content` 
    FROM
        `t_post` t 
    INNER JOIN
        `t_user` t1 
            ON t1.`id` = t.`user_id`

框架通过select子查询将结果清晰的将结果集通过group_concat函数组装到了comment_content列上

::: warning 性能!!!

如果由用户嫌弃select子查询性能低下eq贴心的提供了子查询转groupJoin助力用户实现更高效的sql
:::

当然这边为了演示使用了内容逗号分割,其实本质而言应该是将类目逗号分割更加合适

接下来我们创建帖子的类目关系表

帖子和类目关系是多对多通过CategoryPost表进行关联


@Data
@Table("t_post")
@EntityProxy
@EasyAlias("t_post")
@EasyAssertMessage("未找到对应的帖子信息")
public class Post implements ProxyEntityAvailable<Post, PostProxy> {
    //....其他业务字段和导航属性


    /**
     * 帖子类目信息
     **/
    @Navigate(value = RelationTypeEnum.ManyToMany,
            selfProperty = {PostProxy.Fields.id},
            selfMappingProperty = {CategoryPostProxy.Fields.postId},
            mappingClass = CategoryPost.class, targetProperty = {CategoryProxy.Fields.id},
            targetMappingProperty = {CategoryPostProxy.Fields.categoryId}, subQueryToGroupJoin = true)private List<Category> categoryList;
}
  • 其中我们看到①subQueryToGroupJoin = true该配置项让原本的多对多子查询可以直接在使用的时候使用groupJoin来代替可以让生成的sql性能更加高效

返回帖子内容+用户+评论前三个+所属类目逗号分割

设置返回dto


/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post}
 *
 * @author xuejiaming
 */
@Data
public class PostPage11Response {

    private static final ExtraAutoIncludeConfigure EXTRA_AUTO_INCLUDE_CONFIGURE = PostProxy.TABLE.EXTRA_AUTO_INCLUDE_CONFIGURE()
            .select(t_post -> Select.of(
                    t_post.categoryList().joining(cate->cate.name()).as("categoryNames")
            ));
        
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    @NavigateFlat(pathAlias = "user.id")
    private String userName;

    @SuppressWarnings("EasyQueryFieldMissMatch")
    private String categoryNames;

    /**
     * 评论信息
     **/
    @Navigate(value = RelationTypeEnum.OneToMany, orderByProps = {
            @OrderByProperty(property = "createAt", asc = true)
    }, limit = 3)
    private List<InternalComment> commentList;

    /**
     * {@link Comment}
     **/
    @Data
    public static class InternalComment {


        private static final ExtraAutoIncludeConfigure EXTRA_AUTO_INCLUDE_CONFIGURE = CommentProxy.TABLE.EXTRA_AUTO_INCLUDE_CONFIGURE()
                .where(t_comment -> {
                    t_comment.parentId().eq("0");
                });

        private String id;
        private String parentId;
        private String content;
        private LocalDateTime createAt;
    }

}




@PostMapping("/postList4")
public List<PostPage11Response> postList4(@RequestBody PostPage7Request request) {
    return easyEntityQuery.queryable(Post.class)
            .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
            .where(t_post -> {
                t_post.title().contains(request.getTitle());
                t_post.user().name().contains(request.getUserName());
            })
            .selectAutoInclude(PostPage11Response.class).toList();
}

我们通过对主表进行额外字段的添加让其直接支持额外字段返回

  • @SuppressWarnings("EasyQueryFieldMissMatch")这个注解主要是用来抑制插件警告,您如果觉得警告无所谓那么可以不加该注解对结果没有影响

    {
        "id": "0c6ab3ab-29a4-4320-a08e-195bdac27095",
        "title": "JVM调优实战",
        "content": "# 这是用户用户C的帖子内容\n包含丰富的文本内容...",
        "userId": "2e509ef4-0282-448f-ace0-43501d46ccf4",
        "publishAt": "2025-08-04T23:42:43.525",
        "userName": "2e509ef4-0282-448f-ace0-43501d46ccf4",
        "categoryNames": "娱乐,教育",
        "commentList": [
            {
                "id": "2d3643e6-8fb5-4a2b-a0bc-1c92030bfa34",
                "parentId": "0",
                "content": "完全同意你的观点",
                "createAt": "2025-08-07T00:42:43.526"
            },
            {
                "id": "5f7b2333-5578-40cd-940e-28e97d1b0aa1",
                "parentId": "0",
                "content": "完全同意你的观点",
                "createAt": "2025-08-07T11:42:43.526"
            },
            {
                "id": "0b1d0cbd-62a7-4922-b5fe-0ef4780e4c24",
                "parentId": "0",
                "content": "内容很实用",
                "createAt": "2025-08-07T15:42:43.526"
            }
        ]
    },
    {
        "id": "1a0e5854-c748-4c6b-a11d-d5bbb58326a1",
        "title": "电影推荐合集",
        "content": "# 这是用户用户B的帖子内容\n包含丰富的文本内容...",
        "userId": "70ec5f9f-7e9b-4f57-b2a4-9a35a163bd3e",
        "publishAt": "2025-08-03T02:42:43.525",
        "userName": "70ec5f9f-7e9b-4f57-b2a4-9a35a163bd3e",
        "categoryNames": "教育,科技",
        "commentList": [
            {
                "id": "723a588c-0d95-4db7-be6b-1745bfcfc540",
                "parentId": "0",
                "content": "内容很实用",
                "createAt": "2025-08-07T00:42:43.526"
            },
            {
                "id": "116ab46b-9b61-4644-ac10-73e65f5a01b9",
                "parentId": "0",
                "content": "内容很实用",
                "createAt": "2025-08-07T18:42:43.526"
            },
            {
                "id": "65cb0f86-7076-46a6-b333-c9c50e9336ae",
                "parentId": "0",
                "content": "写得真详细",
                "createAt": "2025-08-07T18:42:43.526"
            }
        ]
    }

完全完美符合我们需要的结果


-- 第1条sql数据

    SELECT
        t5.`__joining2__` AS `category_names`,
        t.`id`,
        t.`title`,
        t.`content`,
        t.`user_id`,
        t.`publish_at` 
    FROM
        `t_post` t 
    LEFT JOIN
        (SELECT
            t3.`post_id` AS `post_id`, GROUP_CONCAT(t2.`name` SEPARATOR ',') AS `__joining2__` FROM `t_category` t2 
        INNER JOIN
            `t_category_post` t3 
                ON t2.`id` = t3.`category_id` 
        GROUP BY
            t3.`post_id`) t5 
            ON t5.`post_id` = t.`id`
-- 第2条sql数据

    SELECT
        t2.`id` AS `id`,
        t2.`parent_id` AS `parent_id`,
        t2.`content` AS `content`,
        t2.`create_at` AS `create_at`,
        t2.`post_id` AS `__relation__postId` 
    FROM
        (SELECT
            t1.`id` AS `id`,
            t1.`parent_id` AS `parent_id`,
            t1.`content` AS `content`,
            t1.`user_id` AS `user_id`,
            t1.`post_id` AS `post_id`,
            t1.`create_at` AS `create_at` 
        FROM
            (SELECT
                t.`id`,
                t.`parent_id`,
                t.`content`,
                t.`user_id`,
                t.`post_id`,
                t.`create_at`,
                (ROW_NUMBER() OVER (PARTITION 
            BY
                t.`post_id` 
            ORDER BY
                t.`create_at` ASC)) AS `__row__` 
            FROM
                `t_comment` t 
            WHERE
                t.`parent_id` = '0' 
                AND t.`post_id` IN ('015c8538-0eaa-4afb-a1c7-4cca00dd6638', '0c6ab3ab-29a4-4320-a08e-195bdac27095', '1a0e5854-c748-4c6b-a11d-d5bbb58326a1', '31a955ba-04ec-4d07-a6d4-fac6c408ab7d', '36eba6b0-5dd4-41b3-a4af-d9c522a86b3a', '573ca56a-4575-458e-8258-7b76c2cfe959', '5f72b5bf-3ae6-4bd6-9df9-cf0c43abc37c', '63d5b82f-64e6-4985-ad4b-acf71d8368fc', '669ce2a5-abaf-49e8-bb7e-e498f7377b15', '73f5d341-c6df-43a1-afcd-e246c4d1fcc9', '89bf6652-0ae0-451a-8a16-d9b543898f81', '8dbcfcfe-44a7-45c2-9db9-d0302c5a9a94')) t1 
        WHERE
            t1.`__row__` >= 1 
            AND t1.`__row__` <= 3) t2
-- 第3条sql数据

    SELECT
        `id` 
    FROM
        `t_user` 
    WHERE
        `id` IN ('3b63ddd9-b038-4c24-969e-8b478fe862a5', '2e509ef4-0282-448f-ace0-43501d46ccf4', '70ec5f9f-7e9b-4f57-b2a4-9a35a163bd3e', 'f2bf383e-ee8d-44c5-968d-263191ab058e', 'eda79345-6fbf-4ca6-b9bf-4743a3f991e4')
  • 第一条sql我们看到用来查询返回post信息和对应的categoryNames字段使用groupJoin来代替多对多自查
  • 第二条sql我们看到框架使用patrtition by让用户可以轻松的返回评论信息前n条
  • 第三条sql我们使用NaviagteFlat二次查询杜绝n+1来返回用户信息

到此为止我们的帖子相关的查询已经结束 主要我们实现了框架对一对多 多对一和多对多下如何快速查询并且支持众多开窗函数的隐式使用

最后的最后我非常感谢您能看到这边我相信eq绝对是你不二的orm选择

框架地址 https://github.com/dromara/easy-query
文档地址 https://www.easy-query.com/easy-query-doc/
该文章demo地址 https://github.com/xuejmnet/eq-doc

一款基于 .NET + Vue 编写的仿钉钉的开源低代码工作流引擎,支持多种数据库,开箱即用! - 追逐时光者 - 博客园

mikel阅读(228)

来源: 一款基于 .NET + Vue 编写的仿钉钉的开源低代码工作流引擎,支持多种数据库,开箱即用! – 追逐时光者 – 博客园

一款基于 .NET + Vue 编写的仿钉钉的开源低代码工作流引擎,支持多种数据库,开箱即用! 

前言

今天大姚给大家分享一款基于 .NET + Vue 编写的仿钉钉的开源低代码工作流引擎,支持多种数据库,开箱即用:AntFlow.NET。

项目介绍

AntFlow.NET 是一款基于 .NET + Vue + FreeSQL + Natasha 编写的仿钉钉的开源(Apache-2.0)低代码工作流引擎,支持多种数据库,让工作流开发像普通 CURD 一样简单,即使没有流程开发经验的程序员也能快速开发流程,效率提升利器。

image

项目特征

  • 极简配置:告别繁琐设置,轻松上手,让开发变得更加简单直观。
  • 学习成本低:消除复杂概念,即便是工作流新手也能迅速掌握并应用于项目。
  • 功能对标钉钉/企业微信/飞书等审批流程 ,可用于多种业务场景,简单易用、可扩展性强、可定制、可SAAS化。
  • 支持流程设计、条件分支、顺序会签、会签、或签、加批、委托、转办、打回修改、重新提交等高级特性,为中国式办公量身定制。

项目技术栈

  • ASP.NET Core: 现代化的 Web 开发框架。
  • FreeSQL 高性能、强类型的 ORM 框架。
  • Natasha : 动态编译与运行支持,基于 Roslyn 的 C# 动态程序集构建库。

项目源代码

前端

image

后端

image

演示效果

image

image

image

image

image

image

image

项目源码地址

更多项目实用功能和特性欢迎前往项目开源地址查看👀,别忘了给项目一个Star支持💖。

优秀项目和框架精选

该项目已收录到C#/.NET/.NET Core优秀项目和框架精选中,关注优秀项目和框架精选能让你及时了解C#、.NET和.NET Core领域的最新动态和最佳实践,提高开发工作效率和质量。坑已挖,欢迎大家踊跃提交PR推荐或自荐(让优秀的项目和框架不被埋没🤞)。

分析C#项目的单元测试覆盖率,提高代码质量 - 程序设计实验室 - 博客园

mikel阅读(152)

来源: 分析C#项目的单元测试覆盖率,提高代码质量 – 程序设计实验室 – 博客园

分析C#项目的单元测试覆盖率,提高代码质量

前言#

正如我在前一篇介绍 ImageGlider 的文章里预告的那样,这篇同样属于那套「C# + 自动化发布」开发流程的系列分享,继续把踩过的坑和总结的经验都记录下来,大家一起少走弯路。

单元测试的重要性不用我多说了吧?😄

覆盖率,保证了单元测试的广度和有效性——它能帮助开发者发现遗漏的逻辑分支,避免“测试了但其实没测到”的尴尬场面

特别是在如今的AI编程时代,完善的测试可以让AI自动验证功能的实现结果

刚好 C# 拥有非常完善的基础设施,这种功能丰富的语言,特别适应 AI 时代,我有预感,dotnet 平台在 AI 时代未来可期😁

要进行覆盖率测试,方法有非常多,一开始我使用了一个第三方工具来生成 HTML 报告,后面发现 VSCode、VS、Rider 这些 IDE 里都可以🤣

C#工具库#

今年我陆续用 C# 开发了不少工具

涵盖的范围也不小

感觉都可以组成一个小工具库了

这些工具分别是:

依赖#

使用 dotnet-reportgenerator-globaltool 工具可以生成 HTML 报告

dotnet tool install -g dotnet-reportgenerator-globaltool

测试覆盖率#

以 ImageGlider 项目为例

使用以下命令分析项目的单元测试覆盖率,并生成测试报告网页

# 生成测试覆盖率报告
dotnet test --collect:"XPlat Code Coverage" --results-directory ./temp/TestResults

# 使用 reportgenerator 生成HTML报告
reportgenerator -reports:"./temp/TestResults/*/coverage.cobertura.xml" -targetdir:"./TestResults/CoverageReport" -reporttypes:Html

生成的测试报告路径示例

temp\TestResults\4eaa9684-a3b6-4b2a-81ac-d75e1e375e4b\coverage.cobertura.xml

直接打开这个网页就可以看到覆盖率的报告了

HTML 报告#

非常详细

总览#

查看详细覆盖率#

这里可以调整分组模式

默认是 By assembly

可以改成 By namespace ,命名空间模式又分 level 1 和 level 2

其中 Line coverage 和 Branch coverage 都是可以筛选的

项目大点的话,建议选择 By Namespace level 2 ,比较直观

方法的测试覆盖率#

点击具体的类,跳转到方法覆盖率页面

这里可以看到哪个方法没写测试

或者哪些 case 是没有覆盖到的

总之非常方便

image

VSCode#

在 tests 目录上右键,运行覆盖率测试

很快就会在各个目录上出现类似手机电池的图标(好可爱😄)

可以很方便地看到各个项目、各个代码文件的测试覆盖率

Rider#

我是先在 VSCode 里发现的

我就在想

VSCode 都有的功能,老牌 C# IDE 的这个 Rider,应该更加有吧

结果测了一下,还真有

菜单 Tests -> Cover Unit Tests

这个功能也不错,还能导出 HTML 什么的

小结#

总之,通过dotnet-reportgenerator-globaltool和VSCode、VS、Rider等IDE的内置功能,我们可以轻松分析C#项目的单元测试覆盖率,帮助提升代码质量和测试有效性。

c#中string字符串转为json与json转对象 - 任督二脉 - 博客园

mikel阅读(335)

来源: c#中string字符串转为json与json转对象 – 任督二脉 – 博客园

添加引用,Newtonsoft.Json.dll
using Newtonsoft.Json.Linq;

复制代码
 1 //字符串转json
 2 public static void strJson()
 3 {
 4 string jsonText = "{"shenzheng":"深圳","beijing":"北京","shanghai":[{"zj1":"zj11","zj2":"zj22"},"zjs"]}";
 5 
 6 JObject jo = (JObject)JsonConvert.DeserializeObject(jsonText);//或者JObject jo = JObject.Parse(jsonText);
 7 string zone = jo["shenzheng"].ToString();//输出 "深圳"
 8 string zone_en = jo["shanghai"].ToString();//输出 "[{"zj1":"zj11","zj2":"zj22"},"zjs"]"
 9 string zj1 = jo["shanghai"][1].ToString();//输出 "zjs"
10 Console.WriteLine(jo);
11 }
复制代码
复制代码
 1     //对象与数组转JSON
 2     public static void GetJsonString()
 3     {
 4         //初始化对象
 5         Obj product = new Obj() { Name = "苹果", Price = 5.5 };
 6         //序列化
 7         string o = new JavaScriptSerializer().Serialize(product);//值:"{\"Name\":\"苹果\",\"Price\":5.5}"
 8 
 9         //数组转json
10         List<Obj> products = new List<Obj>(){
11         new Obj(){Name="苹果",Price=5.5},
12         new Obj(){Name="橘子",Price=2.5},
13         new Obj(){Name="干柿子",Price=16.00}
14         };
15 
16         ProductList productlist = new ProductList();
17         productlist.GetProducts = products;
18         //序列化
19         string os = new JavaScriptSerializer().Serialize(productlist);
20         //输出 "{\"GetProducts\":[{\"Name\":\"苹果\",\"Price\":5.5},{\"Name\":\"橘子\",\"Price\":2.5},{\"Name\":\"干柿子\",\"Price\":16}]}"
21     }
22 
23     //json转对象、数组, 反序列化
24     public static void JSONStringToList()
25     {
26 
27         //json格式字符串
28         string JsonStr = "{Name:'苹果',Price:5.5}";
29         JavaScriptSerializer Serializer = new JavaScriptSerializer();
30 
31         //json字符串转为对象, 反序列化
32         Obj obj = Serializer.Deserialize<Obj>(JsonStr);
33             Console.Write(obj.Name + ":" + obj.Price + "\r\n");
34 
35         //json格式字符串
36         string JsonStrs = "[{Name:'苹果',Price:5.5},{Name:'橘子',Price:2.5},{Name:'柿子',Price:16}]";
37 
38         JavaScriptSerializer Serializers = new JavaScriptSerializer();
39 
40         //json字符串转为数组对象, 反序列化
41         List<Obj> objs = Serializers.Deserialize<List<Obj>>(JsonStrs);
42 
43         foreach (var item in objs)
44         {
45            Console.Write(item.Name + ":" + item.Price + "\r\n");
46         }
47     }
复制代码