我们在初学Java Web时都做过最简单的认证:定义一个登录Servlet,负责将前端传入的用户名/密码与数据库中的数据比对,如果一致则登录成功,然后将用户信息放入Cookie。随后定义一个Filter,负责取出Cookie中的用户信息进行验证。步入职场后,我们发现实际使用的认证方案其实相当复杂,多个系统可能会依赖同一个认证服务——SSO,SSO又有多种协议:OAuth、OIDC、CAS、LDAP等等,又或者会集成微信、支付宝等第三方认证服务。但一般来说,每个系统的认证框架都会做成拔插式的,允许扩展多种认证方式。本文结合自身工作经历,介绍在实际生产中一个简单的拔插式认证框架是如何设计与实现的。
框架设计
首先我们要明确两个概念:登录、认证。登录是用户主动发起的请求,而认证是系统被动进行的验证;登录是为了获得系统的访问权限,而认证是为了确认用户的身份。前后端分离架构系统的主流认证方案是基于JWT
的,我们的框架也以JWT
为基础。
我们以框架中涉及到的主要类为切入点,阐述每个类之间的关系、职责,以点带面,一览框架的全貌。
LoginController:登录接口,职责是校验前端传入的用户名/密码,校验成功后记录
Session
并调用LoginAuthHandlerBase
的buildJwt
方法构建JWT
。AuthenticationFilter:认证过滤器,它是认证的入口,除了登录接口外,所有请求都会经过它,职责是拦截请求,验证请求中携带的访问令牌。
ILoginAuthHandler:认证处理器接口,它是认证处理器的顶层接口,也是
LoginAuthHandlerBase
的父接口,是对外的认证门面。LoginAuthHandlerBase:实现了
ILoginAuthHandler
的认证处理器基类,是所有认证处理器的基类。主要职责是完成登录、认证、登出等功能。LoginAuthFactory:认证处理器工厂,职责是在容器刷新时将所有认证处理器
bean
加载到内存中,以便随时取用。DefaultLoginAuthHandler:认证处理器,真正执行认证等逻辑的类,职责是完成具体的登录、认证、登出等功能,并将结果返回给
LoginAuthHandlerBase
。
从功能的最小实现的角度来说,有LoginController
、AuthenticationFilter
、DefaultLoginAuthHandler
足以,但我们考虑到需要支持多种认证方式,并且要遵循开闭原则,于是就有了ILoginAuthHandler
、LoginAuthHandlerBase
、LoginAuthFactory
。
代码实现
ILoginAuthHandler
package neatlogic.framework.filter.core;
import com.alibaba.fastjson.JSONObject;
import neatlogic.framework.dto.UserVo;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface ILoginAuthHandler {
String getType();
UserVo auth(HttpServletRequest request, HttpServletResponse response) throws Exception;
UserVo login(UserVo userVo, JSONObject resultJson);
String directUrl();
String logout();
}
ILoginAuthHandler
接口定义了5个方法:
getType():获取处理器的标识
auth():执行认证逻辑的入口
login():执行登录逻辑的入口
logout():执行登出逻辑的入口
directUrl():获取登录页面地址
LoginAuthHandlerBase
package neatlogic.framework.filter.core;
import com.alibaba.fastjson.JSONObject;
import neatlogic.framework.asynchronization.threadlocal.UserContext;
import neatlogic.framework.common.config.Config;
import neatlogic.framework.dao.mapper.LoginMapper;
import neatlogic.framework.dao.mapper.RoleMapper;
import neatlogic.framework.dao.mapper.UserMapper;
import neatlogic.framework.dao.mapper.UserSessionMapper;
import neatlogic.framework.dto.JwtVo;
import neatlogic.framework.dto.UserVo;
import neatlogic.framework.dto.captcha.LoginFailedCountVo;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.zip.GZIPOutputStream;
public abstract class LoginAuthHandlerBase implements ILoginAuthHandler {
protected static Logger logger = LoggerFactory.getLogger(LoginAuthHandlerBase.class);
public abstract String getType();
protected static UserMapper userMapper;
protected static RoleMapper roleMapper;
protected static LoginMapper loginMapper;
protected static UserSessionMapper userSessionMapper;
@Autowired
public void setUserMapper(UserMapper _userMapper) {
userMapper = _userMapper;
}
@Autowired
public void setRoleMapper(RoleMapper _roleMapper) {
roleMapper = _roleMapper;
}
@Autowired
public void setLoginMapper(LoginMapper _loginMapper) {
loginMapper = _loginMapper;
}
@Autowired
public void setUserSessionMapper(UserSessionMapper _userSessionMapper) {
userSessionMapper = _userSessionMapper;
}
@Override
public UserVo auth(HttpServletRequest request, HttpServletResponse response) throws Exception {
UserVo userVo = myAuth(request);
if (userVo != null && StringUtils.isBlank(userVo.getUuid())) {
userVo = null;
}
//如果认证cookie为null则构建JWT作为cookie
if (userVo != null && StringUtils.isBlank(userVo.getCookieAuthorization())) {
JwtVo jwtVo = buildJwt(userVo);
setResponseAuthCookie(response, request, jwtVo);
userSessionMapper.insertUserSession(userVo.getUuid());
}
return userVo;
}
public abstract UserVo myAuth(HttpServletRequest request) throws Exception;
/**
* 生成JWT对象
*
* @param checkUserVo 用户
* @return JWT对象
* @throws Exception 异常
*/
public static JwtVo buildJwt(UserVo checkUserVo) throws Exception {
// 构建head
JSONObject jwtHeadObj = new JSONObject();
jwtHeadObj.put("alg", "HS256");
jwtHeadObj.put("typ", "JWT");
// 构建payload
JSONObject jwtBodyObj = new JSONObject();
jwtBodyObj.put("useruuid", checkUserVo.getUuid());
jwtBodyObj.put("userid", checkUserVo.getUserId());
jwtBodyObj.put("username", checkUserVo.getUserName());
jwtBodyObj.put("isSuperAdmin", checkUserVo.getIsSuperAdmin());
String jwthead = Base64.getUrlEncoder().encodeToString(jwtHeadObj.toJSONString().getBytes());
String jwtbody = Base64.getUrlEncoder().encodeToString(jwtBodyObj.toJSONString().getBytes());
SecretKeySpec signingKey = new SecretKeySpec(Config.JWT_SECRET().getBytes(), "HmacSHA1");
Mac mac;
mac = Mac.getInstance("HmacSHA1");
mac.init(signingKey);
// 对head与payload签名并URL编码
byte[] rawHmac = mac.doFinal((jwthead + "." + jwtbody).getBytes());
String jwtsign = Base64.getUrlEncoder().encodeToString(rawHmac);
// 压缩JWT内容并URL编码
String c = "Bearer_" + jwthead + "." + jwtbody + "." + jwtsign;
checkUserVo.setAuthorization(c);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
GZIPOutputStream gzipOutputStream = new GZIPOutputStream(bos);
gzipOutputStream.write(c.getBytes());
gzipOutputStream.close();
String cc = Base64.getEncoder().encodeToString(bos.toByteArray());
bos.close();
JwtVo jwtVo = new JwtVo();
jwtVo.setCc(cc);
jwtVo.setJwthead(jwthead);
jwtVo.setJwtbody(jwtbody);
jwtVo.setJwtsign(jwtsign);
return jwtVo;
}
/**
* 设置登录cookie
*
* @param response 响应
* @param request 请求
* @param jwtVo JWT对象
*/
public static void setResponseAuthCookie(HttpServletResponse response, HttpServletRequest request, JwtVo jwtVo) {
Cookie authCookie = new Cookie("authorization", "GZIP_" + jwtVo.getCc());
authCookie.setPath("/");
String domainName = request.getServerName();
if (StringUtils.isNotBlank(domainName)) {
String[] ds = domainName.split("\\.");
int len = ds.length;
if (len > 2 && !StringUtils.isNumeric(ds[len - 1])) {
authCookie.setDomain(ds[len - 2] + "." + ds[len - 1]);
}
}
response.addCookie(authCookie);
// 允许跨域携带cookie
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setContentType(Config.RESPONSE_TYPE_JSON);
}
@Override
public String logout() {
userSessionMapper.deleteUserSessionByUserUuid(UserContext.get().getUserUuid(true));
String url;
try {
url = myLogout();
} catch (IOException e) {
logger.error(e.getMessage());
throw new RuntimeException();
}
return url;
}
protected String myLogout() throws IOException {
return null;
}
@Override
public String directUrl() {
String directUrl = myDirectUrl();
if (StringUtils.isBlank(directUrl)) {
directUrl = Config.DIRECT_URL();
}
return directUrl;
}
protected String myDirectUrl() {
return null;
}
@Override
public UserVo login(UserVo userVo, JSONObject resultJson) {
UserVo checkUserVo = myLogin(userVo, resultJson);
LoginFailedCountVo loginFailedCountVo;
if (checkUserVo == null) {//如果正常用户登录失败则失败次数+1
int failedCount = 1;
loginFailedCountVo = loginMapper.getLoginFailedCountVoByUserId(userVo.getUserId());
if (loginFailedCountVo != null) {
failedCount = loginFailedCountVo.getFailedCount();
}
loginFailedCountVo = new LoginFailedCountVo(userVo.getUserId(), failedCount);
loginMapper.updateLoginFailedCount(loginFailedCountVo);
} else {//如果正常用户登录成功,则清空该用户的失败次数
resultJson.remove("isNeedCaptcha");
loginMapper.deleteLoginFailedCountByUserId(userVo.getUserId());
}
return checkUserVo;
}
public UserVo myLogin(UserVo userVo, JSONObject resultJson) {
return null;
}
}
LoginAuthHandlerBase
实现了ILoginAuthHandler
的auth
方法,这是认证的入口,方法内部使用模板方法模式,先调用子类的myAuth
方法完成真正的认证逻辑,然后调用自身的buildJwt
方法构建JWT
。也就是说,无论以何种方式通过认证,只要发现Cookie
中没有JWT
,就会构建一个JWT
给客户端,这就是上文所说的“以JWT
为基础”的含义。
buildJwt
方法展示了如何构建一个JWT
:
创建
JWT
的head
与payload
使用
HmacSHA1
算法对head
与payload
进行签名将经过
URL
编码过的head
、payload
与签名拼接在一起,拼接时还加上了“Bearer_”作为前缀,这是构建JWT
的通用做法将拼接好的字符串进行压缩并
URL
编码,由此得到的字符串就是最终的JWT
值得一提的是,对head
与payload
进行签名而构建出来的JWT
就是JSON Web Signature(JWS)
,JWS
的标准格式为:Bearer_${head}.${payload}.${signature}
。
login
方法使用的也是模板方法模式,调用子类的myLogin
方法完成真正的登录逻辑,完成后回到login
方法做一些附加的逻辑,例如统计登录失败次数。
LoginAuthFactory
package neatlogic.framework.filter.core;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class LoginAuthFactory implements ApplicationListener<ContextRefreshedEvent> {
private static final Map<String, ILoginAuthHandler> loginAuthMap = new HashMap<>();
public static ILoginAuthHandler getLoginAuth(String type) {
return loginAuthMap.get(type.toUpperCase());
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
Map<String, ILoginAuthHandler> myMap = event.getApplicationContext().getBeansOfType(ILoginAuthHandler.class);
for (Map.Entry<String, ILoginAuthHandler> entry : myMap.entrySet()) {
ILoginAuthHandler authAuth = entry.getValue();
loginAuthMap.put(authAuth.getType().toUpperCase(), authAuth);
}
}
}
LoginAuthFactory
实现了ApplicationListener<ContextRefreshedEvent>
接口,重写了onApplicationEvent
方法,从ApplicationContext
中获取所有实现了ILoginAuthHandler
接口的bean
,这些bean
其实就是各认证处理器的实例,要用到它们时,直接从loginAuthMap
取即可,这是对象池思想的具体实践。
DefaultLoginAuthHandler
package neatlogic.module.framework.filter.handler;
import com.alibaba.fastjson.JSONObject;
import neatlogic.framework.common.config.Config;
import neatlogic.framework.dto.UserVo;
import neatlogic.framework.filter.core.LoginAuthHandlerBase;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.zip.GZIPInputStream;
@Service
public class DefaultLoginAuthHandler extends LoginAuthHandlerBase {
@Override
public String getType() {
return "default";
}
@Override
public UserVo myAuth(HttpServletRequest request) {
//获取 authorization,优先获取header的authorization,不存在则从cookie获取authorization
Cookie[] cookies = request.getCookies();
UserVo userVo = new UserVo();
String authorizationFromCookie = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("authorization".equals(cookie.getName())) {
authorizationFromCookie = cookie.getValue();
}
}
}
String authorization = request.getHeader("Authorization");
if (StringUtils.isBlank(authorization)) {
if (StringUtils.isNotBlank(authorizationFromCookie)) {
userVo.setCookieAuthorization(authorizationFromCookie);
authorization = authorizationFromCookie;
// 解压cookie内容
if (authorization.startsWith("GZIP_")) {
authorization = authorization.substring(5);
try {
byte[] compressData = Base64.getDecoder().decode(authorization);
ByteArrayInputStream bis = new ByteArrayInputStream(compressData);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
GZIPInputStream gzipInputStream = new GZIPInputStream(bis);
byte[] buffer = new byte[2048];
int n;
while ((n = gzipInputStream.read(buffer)) >= 0) {
bos.write(buffer, 0, n);
}
bis.close();
gzipInputStream.close();
authorization = bos.toString();
bos.close();
} catch (Exception ex) {
logger.error(ex.getMessage(), ex);
}
}
}
} else {
userVo.setAuthorization(authorization);
}
//如果 authorization 存在,则拆包获取用户信息
if (StringUtils.isNotBlank(authorization)) {
if (authorization.startsWith("Bearer") && authorization.length() > 7) {
String jwt = authorization.substring(7);
String[] jwtParts = jwt.split("\\.");
if (jwtParts.length == 3) {
SecretKeySpec signingKey = new SecretKeySpec(Config.JWT_SECRET().getBytes(), "HmacSHA1");
Mac mac;
try {
mac = Mac.getInstance("HmacSHA1");
mac.init(signingKey);
byte[] rawHmac = mac.doFinal((jwtParts[0] + "." + jwtParts[1]).getBytes());
String result = Base64.getUrlEncoder().encodeToString(rawHmac);
if (result.equals(jwtParts[2])) {
String jwtBody = new String(Base64.getUrlDecoder().decode(jwtParts[1]), StandardCharsets.UTF_8);
JSONObject jwtBodyObj = JSONObject.parseObject(jwtBody);
userVo.setUuid(jwtBodyObj.getString("useruuid"));
userVo.setUserId(jwtBodyObj.getString("userid"));
userVo.setUserName(jwtBodyObj.getString("username"));
return userVo;
}
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
e.printStackTrace();
}
}
}
}
return userVo;
}
@Override
public String myDirectUrl() {
return Config.DIRECT_URL();
}
@Override
public UserVo myLogin(UserVo userVo, JSONObject resultJson) {
return userMapper.getUserByUserIdAndPassword(userVo);
}
}
DefaultLoginAuthHandler
重写了LoginAuthHandlerBase
的myAuth
方法,myAuth
方法的主要逻辑就是对请求头或Cookie
中的JWT
进行拆包获取用户信息,最终返回一个UserVo
对象。
LoginController
package neatlogic.module.framework.login.handler;
import com.alibaba.fastjson.JSONObject;
import neatlogic.framework.common.ReturnJson;
import neatlogic.framework.common.config.Config;
import neatlogic.framework.dao.mapper.UserSessionMapper;
import neatlogic.framework.dto.JwtVo;
import neatlogic.framework.dto.UserVo;
import neatlogic.framework.exception.core.ApiRuntimeException;
import neatlogic.framework.exception.login.LoginAuthPluginNoFoundException;
import neatlogic.framework.exception.user.UserAuthFailedException;
import neatlogic.framework.filter.core.ILoginAuthHandler;
import neatlogic.framework.filter.core.LoginAuthFactory;
import neatlogic.framework.filter.core.LoginAuthHandlerBase;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Controller
@RequestMapping("/login/")
public class LoginController {
Logger logger = LoggerFactory.getLogger(LoginController.class);
@Resource
private UserSessionMapper userSessionMapper;
@RequestMapping(value = "/check")
public void dispatcherForPost(@RequestBody String json, HttpServletRequest request, HttpServletResponse response) throws Exception {
JSONObject returnObj = new JSONObject();
JSONObject jsonObj = JSONObject.parseObject(json);
JSONObject resultJson = new JSONObject();
try {
String userId = jsonObj.getString("userid");
String password = jsonObj.getString("password");
String authType = "default";
if (StringUtils.isNotBlank(jsonObj.getString("authType"))) {
authType = jsonObj.getString("authType");
} else if (StringUtils.isNotBlank(Config.LOGIN_AUTH_TYPE())) {
authType = Config.LOGIN_AUTH_TYPE();
}
// 验证并获取用户
UserVo userVo = new UserVo();
userVo.setUserId(userId);
userVo.setPassword(password);
//切换到具体的认证插件
ILoginAuthHandler loginAuth = LoginAuthFactory.getLoginAuth(authType);
if (loginAuth == null) {//配置了插件,但不在已有的插件范围内
throw new LoginAuthPluginNoFoundException();
}
UserVo checkUserVo = loginAuth.login(userVo, returnObj);
if (checkUserVo != null) {
// 保存 user 登录访问时间
userSessionMapper.insertUserSession(checkUserVo.getUuid());
JwtVo jwtVo = LoginAuthHandlerBase.buildJwt(checkUserVo);
LoginAuthHandlerBase.setResponseAuthCookie(response, request, jwtVo);
returnObj.put("Status", "OK");
returnObj.put("JwtToken", jwtVo.getJwthead() + "." + jwtVo.getJwtbody() + "." + jwtVo.getJwtsign());
response.getWriter().print(returnObj);
} else {
throw new UserAuthFailedException();
}
} catch (ApiRuntimeException ex) {
ReturnJson.error(ex.getMessage(), resultJson, response);
} catch (Exception ex) {
logger.error(ex.getMessage(), ex);
ReturnJson.error(ex.getMessage(), response);
}
}
}
LoginController
的逻辑比较简单,这里不做阐述。
AuthenticationFilter
package neatlogic.framework.filter;
import com.alibaba.fastjson.JSONObject;
import neatlogic.framework.asynchronization.threadlocal.UserContext;
import neatlogic.framework.common.config.Config;
import neatlogic.framework.dao.mapper.UserSessionMapper;
import neatlogic.framework.dto.UserSessionVo;
import neatlogic.framework.dto.UserVo;
import neatlogic.framework.filter.core.ILoginAuthHandler;
import neatlogic.framework.filter.core.LoginAuthFactory;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.annotation.Resource;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
public class AuthenticationFilter extends OncePerRequestFilter {
@Resource
private UserSessionMapper userSessionMapper;
public AuthenticationFilter() {
}
@Override
public void destroy() {
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws IOException {
boolean isUnExpired = false;
UserVo userVo;
JSONObject redirectObj = new JSONObject();
String authTypeHeader = request.getHeader("AuthType");
String authType = StringUtils.isNotBlank(authTypeHeader) ? authTypeHeader : "default";
ILoginAuthHandler authHandler = LoginAuthFactory.getLoginAuth(authType);
try {
if (authHandler == null) {
throw new RuntimeException("找不到认证方式 '" + authType + "'");
}
userVo = authHandler.auth(request, response);
if (userVo != null) {
isUnExpired = userExpirationValid();
}
if (userVo != null && isUnExpired) {
filterChain.doFilter(request, response);
} else {
if (userVo == null) {
response.setStatus(522);
redirectObj.put("Status", "FAILED");
redirectObj.put("Message", "认证失败(" + authHandler.getType() + "),请重新登录");
redirectObj.put("DirectUrl", authHandler.directUrl());
} else {
response.setStatus(527);
redirectObj.put("Status", "FAILED");
redirectObj.put("Message", "会话已超时或已被终止,请重新登录");
redirectObj.put("DirectUrl", authHandler.directUrl());
}
removeAuthCookie(response);
response.setContentType(Config.RESPONSE_TYPE_JSON);
response.getWriter().print(redirectObj.toJSONString());
}
} catch (Exception ex) {
logger.error("认证失败", ex);
response.setStatus(522);
redirectObj.put("Status", "FAILED");
redirectObj.put("Message", ex.getMessage());
redirectObj.put("DirectUrl", authHandler != null ? authHandler.directUrl() : Config.DIRECT_URL());
removeAuthCookie(response);
} finally {
response.setContentType(Config.RESPONSE_TYPE_JSON);
response.getWriter().print(redirectObj.toJSONString());
}
}
/**
* 登录异常后端清除authorization cookie,防止sso循环跳转
*/
private void removeAuthCookie(HttpServletResponse response) {
Cookie authCookie = new Cookie("authorization", null);
authCookie.setPath("/");
authCookie.setMaxAge(0);//表示删除
response.addCookie(authCookie);
}
/**
* 校验用户登录超时
*/
private boolean userExpirationValid() {
String userUuid = UserContext.get().getUserUuid();
UserSessionVo userSessionVo = userSessionMapper.getUserSessionByUserUuid(userUuid);
if (null != userSessionVo) {
Date visitTime = userSessionVo.getSessionTime();
Date now = new Date();
int expire = Config.USER_EXPIRETIME();
long expireTime = expire * 60L * 1000L + visitTime.getTime();
if (now.getTime() < expireTime) {
userSessionMapper.updateUserSession(userUuid);
return true;
}
userSessionMapper.deleteUserSessionByUserUuid(userUuid);
} else {
return true;
}
return false;
}
}
AuthenticationFilter
的逻辑与LoginController
类似,值得注意的是,AuthenticationFilter
继承的是OncePerRequestFilter
。OncePerRequestFilter
的主要目的是兼容不同的WEB
容器,因为Servlet
版本不同,执行的过程也不同,其实不是所有的容器一次请求只过滤一次。OncePerRequestFilter
可以保证一次外部请求,只执行一次过滤方法,对于服务器内部之间的forward
等请求,不会再次执行过滤方法。
结语
代码中使用到的一些类我们并没有一一给出定义,这是出于防止篇幅过长的考虑,同时也为了提炼精髓。一些无关紧要的细节在实际开发中自由发挥即可,我们只需要理清主体思路、把握整体脉络,不囿于细枝末节。