@CookieValue是 Spring MVC 中用于访问 HTTP Cookie 的核心注解,它提供了一种简洁高效的方式将 Cookie 值绑定到控制器方法参数上。
核心属性详解
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value/name | String | “” | Cookie 名称 |
required | boolean | true | 是否必须存在 |
defaultValue | String | ValueConstants.DEFAULT_NONE | 默认值(当 Cookie 不存在时使用) |
- value / name :指定要读取的Cookie名称,两者是互为别名的关系,用哪个都行。
- required :默认是 true ,表示如果取不到这个Cookie,框架直接抛异常,最终表现是接口返回400错误。
- defaultValue :默认值是 ValueConstants.DEFAULT_NONE ,一个内部约定字符串,用来区分"用户没有设置默认值"和"用户设置的默认值是空字符串"这两种情况。
使用demo:
@GetMapping("/dashboard") public String dashboard(@CookieValue("SESSION_ID") String sessionId) { // 使用sessionId获取用户信息 User user = sessionService.getUser(sessionId); return "dashboard"; }@GetMapping("/preferences") public String preferences(@CookieValue(value = "THEME", defaultValue = "light") String theme) { // 如果Cookie不存在,使用默认值"light" return "preferences-" + theme; }使用案例
一、用Cookie给用户打组
AB实验是 @CookieValue 非常典型的应用场景。运营同学做新功能灰度时,往往希望一批用户看到新版界面,另一批用户看到旧版界面,并保证同一个用户多次访问始终落在同一分组里。通常的做法是用一个独立的Cookie记录用户分组,比如 AB_TEST_GROUP=experiment 。
@GetMapping("/recommend") public RecommendResult recommend( @CookieValue(name = "AB_TEST_GROUP", defaultValue = "control") String group) { boolean isExperiment = "experiment".equals(group); return recommendService.getRecommend(isExperiment); }这个接口在 group 为 experiment 时走新版推荐策略,其它情况走老版策略。由于设置了 defaultValue = "control" ,新用户第一次没被种Cookie时也不会报错,框架自动兜底到对照组。
这一类需求最容易被忽略的细节是: AB_TEST_GROUP 这个Cookie的 Path 和 Domain 一定要和页面请求路径保持一致,否则会出现用户明明被种了实验分组,接口却拿不到值的现象。
二、用户维度限流:自定义注解+AOP+@CookieValue
先定义一个限流注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface UserRateLimit { int qps() default 10; int windowSeconds() default 60; }再写一个AOP切面,从Cookie中取用户身份:
@Aspect @Component public class UserRateLimitAspect { @Around("@annotation(userRateLimit)") public Object limit(ProceedingJoinPoint joinPoint, UserRateLimit userRateLimit) throws Throwable { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attrs.getRequest(); String userId = resolveUserIdFromCookie(request); if (userId == null) { throw new UnauthorizedException("未登录"); } String key = "rate:user:" + userId; long count = redisTemplate.opsForValue().increment(key); if (count == 1) { redisTemplate.expire(key, Duration.ofSeconds(userRateLimit.windowSeconds())); } if (count > userRateLimit.qps()) { throw new RateLimitException("请求过于频繁"); } return joinPoint.proceed(); } private String resolveUserIdFromCookie(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null) { return null; } for (Cookie cookie : cookies) { if ("USER_ID".equals(cookie.getName())) { return cookie.getValue(); } } return null; } }然后在接口上直接用注解:
@GetMapping("/submit") @UserRateLimit(qps = 5, windowSeconds = 60) public Result submit() { // 业务逻辑 }这个组合拳的价值在于:Controller方法里不需要出现任何Cookie相关代码,限流逻辑完全沉淀在切面层,业务代码保持干净。如果你对"根据Cookie识别用户"和"基于注解做接口限流"这两个点都感兴趣,非常推荐把上面这套组合方式应用到项目里,实测下来比在拦截器里写一段模板代码可维护得多。
三、多语言与主题偏好:一个注解完成偏好读取
用户偏好类Cookie也非常适合@CookieValue。比如站点支持多语言,用户切换语言时前端写一个LANG=zh-CN的Cookie,后端接口读取这个值动态组装文案或数据。
@GetMapping("/article") public ArticleVO getArticle( @CookieValue(name = "LANG", defaultValue = "zh-CN") String lang, @CookieValue(name = "THEME", defaultValue = "light") String theme) { ArticleVO article = articleService.findById(articleId); article.setTheme(theme); article.setContent(i18nService.localize(article.getContent(), lang)); return article; }这里两个@CookieValue共用同一个请求,框架会分别独立解析两个不同名称的Cookie,互不干扰。即使浏览器只带了其中一个,另一个也会被defaultValue兜住,不会因为缺少一个偏好项导致整个接口报400。
高频问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 接口返回400 Bad Request | required=true但请求未携带Cookie | 设置required=false或补充defaultValue |
| 拿到null但浏览器明明有Cookie | Cookie名不匹配;依赖了参数名推断;Path范围不对 | 显式指定name/value,检查Cookie作用域 |
| Cookie值中文或特殊字符乱码 | Cookie值不满足RFC 6265,未做编码 | 写入时URLEncoder.encode,读取时URLDecoder.decode |
| 前端JS拿不到Cookie | Cookie设置了HttpOnly | 服务端读取不受影响,前端通过接口获取 |
| 跨域请求带不上Cookie | SameSite限制或跨域未配置withCredentials | 合理设置SameSite,前端请求开启凭证携带 |
四个让我记忆深刻的教训
第一个教训:在旧接口上新增 @CookieValue 参数,没有设置 required=false ,导致线上大量老客户端请求直接400。老客户端不会主动携带新Cookie,而新参数又强制必填,这一改直接把兼容性改没了。从那以后,凡是给已有接口加Cookie参数,我一律先把 required 设为 false 或者给默认值,经过版本迭代确认客户端都改造完成后再收紧。
第二个教训: @CookieValue String user 依赖参数名推断,结果项目编译参数没保留方法名,Spring里拿到的参数名变成 arg0 ,线上一直查不到这个Cookie,排查了一下午才发现是对不上参数名。这个坑在上面2.3节已经详细说过,这里再强调一次:显示写 name ,杜绝一切隐性依赖。
第三个教训:Cookie值里放了签名信息,但签名结果中包含 + 、 = 、 / 等字符,写入和读取时没有做URL编码,导致服务端拿到的值和生成签名时的原文不一致,验签永远失败。这个问题的本质是Cookie值和URL传输之间的编码约定不一致,解决方式是在服务端写入前统一编码、读取后统一解码。
第四个教训:有人图省事在Controller里直接用 @CookieValue 取用户角色做权限判断,绕过了拦截器的统一鉴权,结果Cookie被篡改后越权访问了管理接口。Cookie本来就是客户端可控数据,不能作为安全边界,权限判断必须依赖服务端签发的Token和统一鉴权组件, @CookieValue 只负责把值取出来,不负责验证值的真伪。