1.什么是Zuul
简单来说是为了通过它来进行服务调用,一切资源的请求都需要它进行请求转发。需要Zuul主要有两个原因
- Nginx、F5、等因为需要手工维护路由规则,微服务过多会带来麻烦
- 需要权限校验机制
2.快速入门
添加依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
加上注解
@EnableZuulProxy
@SpringBootApplication
public class ZuulApplication {
public static void main(String[] args) {
SpringApplication.run(ZuulApplication.class, args);
}
}
传统路由方式
- 用于通过http://localhost:5555/api-a-url/hello会被转发到http://localhost:8080/hello上面
- 从这里也可以看出来,当我们微服务过多时候维护将会变得异常麻烦
zuul.routes.api-a-url.path=/api-a-url/**
zuul.routes.api-a-url.url=http://localhost:8080/
面向服务路由
@EnableZuulProxy
@EnableDiscoveryClient
@SpringBootApplication
public class ZuulApplication {
public static void main(String[] args) {
SpringApplication.run(ZuulApplication.class, args);
}
}
zuul.routes.api-a.path=/api-a/**
zuul.routes.api-a.service-id=hello-service
zuul.routes.api-b.path=/api-b/**
zuul.routes.api-b.service-id=feign-consumer
spring.application.name=api-gateway
server.port=5555
eureka.client.service-url.defaultZone=http://localhost:1111/eureka/
请求过滤
访问权限肯定需要有一定的拦截,所以zuul提供了过滤器,可以在路由执行的前后进行过滤
(当初还未学习的时候以为可以用spring security过滤,现在才发现zuul没有Controller,只是一个转发器,所以使用Zuul过滤器而不是用spring security)
创建过滤器,继承ZuulFilter
- filterType:表示执行前后,pre代表路由之前执行
- filterOrder:代表执行顺序
- shouldFilter:代表是否过滤,返回true为全局过滤
- run : 过滤实际逻辑处
- ctx.setSendZuulResponse(false) 表示不进行路由
public class AccessFilter extends ZuulFilter {
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 0;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext ctx=RequestContext.getCurrentContext();
HttpServletRequest request=ctx.getRequest();
Object accessToken=request.getParameter("accessToken");
if(accessToken==null){
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(401);
return null;
}
return null;
}
}
注册Bean
@EnableZuulProxy
@EnableDiscoveryClient
@SpringBootApplication
public class ZuulApplication {
@Bean
public AccessFilter accessFilter(){
return new AccessFilter();
}
public static void main(String[] args) {
SpringApplication.run(ZuulApplication.class, args);
}
}
3.路由详解
传统路由配置
不依赖服务发现机制,完全是靠手工维护映射关系
单实例配置:通过zuul.routes.<route>.path与zuul.routes.<route>.url参数对的方式进行配置
zuul.routes.user-service.path=/user-service/**
zuul.routes.user-service.url=http://localhost:8080/
多实例配置:通过zuul.routes.<route>.path与zuul.routes.<route>.serviceId参数对的方式进行配置
由于不已开服务发现机制,所以需要关闭ribbion获取配置服务,配置实例的路由地址
zuul.routes.user-service.path=/hello-service/**
zuul.routes.user-service.service-id=hello-service
ribbion.eureka.enabled=false
hello-service.ribbion.listOfServers=http://lcoalhost:9001,http://localhost:9002
服务路由配置
普通配置
zuul.routes.user-service.path=/hello-service/**
zuul.routes.user-service.service-id=hello-service
简洁配置
zuul.routes.<serviceId>=<path>
zuul.routes.hello-service=/hello-service/**
服务路由的默认规则
从上面的诸多配置我们可以看出来来在配置请求路径的匹配规则的时候,大部分都会将服务名当做请求的前缀名,也就是上文的
zuul.routes.user-service中的user-service
所以由于这个缘故,zuul已经帮我们自动配置了所有服务,我们无需再写,如果我们不想某个服务被路由,则需要另外配置
局部忽略
zuul.ignored-services=hello-service
全部忽略,我们只有重新按照上述方法配置路由规则才会生效
zuul.ignored-services=*
自定义路由映射规则
通过创建bean实现,为了服务升级后的相互使用的版本对应所以通过自定义路由映射自动生成版本号为前缀的路径
- 第一个参数匹配服务名字
- 第二个参数通过服务名字创建请求路径
- 例子: 服务名字 service-v1 被转换成 /v1/service
- 注意:当匹配失败额时候将会使用原来服务名字作为匹配路径
@Bean
public PatternServiceRouteMapper serviceRouteMapper(){
return new PatternServiceRouteMapper("(?<name>^.+)-(?<version>v.+$)","${version}/${name}");
}
路径匹配
从上面明显可以看出路径的匹配是使用了Ant风格定义
Ant大致有三种匹配符
- ? 匹配任意单个字符,例如/xxx/a 、/xxx/b
- * 匹配任意数量字符 ,例如/xxx/aasd 、/xxx/basdfadfa
- ** 匹配任意字符且支持多级目录,例如/xxx/a/asd/asd 、/xxx/b/sd/asd/sd
由于版本迭代,我们可能需要将一个服务拆成多个服务,所以路径映射也要修改
zuul.routes.user-service.path=/hello-service/**
zuul.routes.user-service.service-id=hello-service
zuul.routes.user-service-ext.path=/hello-service/ext/**
zuul.routes.user-service-ext.service-id=hello-service-ext
但是由于properties没有顺序的原因,而路径匹配又是首位匹配之后不匹配的原则,所以这里需要改成yaml文件配置
zuul:
routes:
user-service-ext:
path:/user-service-ext/**
serviceId:user-service-ext
user-service:
path:/user-service/**
serviceId:user-service
忽略表达式
可以屏蔽默写不想被路由的URL,但是要注意的是屏蔽的是所有路由,所以具体的规则需要自己思考
zuul.ignored-patterns=/**/hello/*
路由前缀
由于路由前缀可能会与路径映射冲突,所以如果必须要做路由前缀,需要自行测试
zuul.prefix=/api
本地跳转
当访问 /api-b/hello 的时候会将请求转发到本地 /local/hello ,切记是转发到本地方法,不是路由方法。如果找不到则会返回404错误
注意:本地方法所写的Controller不要跟其他Controller重名,这样子会造成意想不到的错误,例如无法访问或者后台报错。
解决方法最好是自定义一个Controller的名字,这也是一个良好的编程习惯
zuul.routes.api-b.path=/api-b/**
zuul.routes.api-b.url=forward:/local
zuul.routes.api-b-url.strip-prefix=false如果关闭前缀也会无法访问,出现404
Cookie与头信息
默认情况下,zuul会过滤掉Http请求头信息中的一些敏感信息,防止它们被传递到下游的外部服务器,这里面包括了我们常用的Cookie、SetCookie、Authorization三个属性。为了解决这个问题,方法很多
1.通过设置全局参数为空覆盖默认设置
不推荐:因为这样子直接破坏了默认设置,比较多的微服务还是无状态的Restful API请求,这样子会一网打尽
zuul.sensitiveHeaders=
2.通过指定路由来局部设置
推荐:仅对指定的路由进行敏感信息的传输
zuul.routes.<router>.customSensitiveHeaders=true
zuul.routes.<router>.sensitiveHeaders=
重定向问题
由于登录系统后,会进行重定向,请求响应头信息包含了Location,内容是具体的服务实例,请求头中的具体信息也是包括了ip地址和端口,所以根本原因是没有将host设置正确
解决方法
zuul.addHostHeader=true
Hystrix和Ribbion的支持
由于zuul包括了hystrix和ribbion的功能封装,所以在配置path和serviceId的时候自动包装了HystrixCommand,还有负载均衡。而path和url则没有这个功能,切记。
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds 转发请求的执行超时时间
ribbion.ConnecTimeout 设置超时时间
超时时间小于请求执行时间的时候,直接进行重试,重试失败则返回错误的json信息
超时时间大于请求执行时间的时候,直接返回错误的json信息
ribbion.ReadTimeOut:该参数用来设置路由转发请求的超时时间
ReadTimeOut < timeoutInMilliseconds 再次重试路由
ReadTimeOut > timeoutInMilliseconds 直接返回TimeOut
有些情况下我们可能需要关闭重试机制
zuul.retryable=false
zuul.routes.<route>.retryable=false
4.过滤器详解
路由功能在运行的时候,包括路由映射、请求转发等都是由zuul过滤器完成的。
过滤器包括四个基本特征
public class AccessFilter extends ZuulFilter {
@Override
public String filterType() { return "pre"; }
@Override
public int filterOrder() {
return 0;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext ctx=RequestContext.getCurrentContext();
HttpServletRequest request=ctx.getRequest();
Object accessToken=request.getParameter("accessToken");
if(accessToken==null){
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(401);
return null;
}
return null;
}
}
1.过滤类型(filterType):返回一个过滤器类型
- pre:可以在请求被路由之前执行
- routing:在路由请求时被调用
- post:在routing和error过滤器之后被调用
- error:处理请求时发生错误时被调用
2.执行顺序(filterOrder): 通过int值定义请求顺序,数字越小优先级越高
3.执行条件(shouldfilter): 返回一个boolean值来判断过滤器是否要执行,数值越小优先级越高
4.具体操作(run):过滤器的具体逻辑
请求生命周期
request----->pre-filter------>routing-filter----->post-filter----->response(任一一个过滤器出现错误则跳到error-filter过滤器,然后继续流向post-filter过滤器)
Pre过滤器(执行顺序排序)
第一个过滤器,判断请求是通过spring的DispatcherServlet还是ZuulServlet。一般路径为/zuul/*的路径访问额请求都通过ZuulServlet处理,主要应用于大文件上传处理。
对于ZuulServlet的路径前缀,可以通过配置zuul.servletPath参数来进行修改
ublic class ServletDetectionFilter extends ZuulFilter {
public ServletDetectionFilter() {
}
public String filterType() {
return "pre";
}
public int filterOrder() {
return -3;
}
public boolean shouldFilter() {
return true;
}
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
if (!(request instanceof HttpServletRequestWrapper) && this.isDispatcherServletRequest(request)) {
ctx.set("isDispatcherServletRequest", true);
} else {
ctx.set("isDispatcherServletRequest", false);
}
return null;
}
private boolean isDispatcherServletRequest(HttpServletRequest request) {
return request.getAttribute(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null;
}
}
第二个过滤器Servlet30WrapperFilter,将HttpServletRequest转换成Servlet30RequestWrapper
public class Servlet30WrapperFilter extends ZuulFilter {
private Field requestField = null;
public Servlet30WrapperFilter() {
this.requestField = ReflectionUtils.findField(HttpServletRequestWrapper.class, "req", HttpServletRequest.class);
Assert.notNull(this.requestField, "HttpServletRequestWrapper.req field not found");
this.requestField.setAccessible(true);
}
protected Field getRequestField() {
return this.requestField;
}
public String filterType() {
return "pre";
}
public int filterOrder() {
return -2;
}
public boolean shouldFilter() {
return true;
}
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
if (request instanceof HttpServletRequestWrapper) {
request = (HttpServletRequest)ReflectionUtils.getField(this.requestField, request);
ctx.setRequest(new Servlet30RequestWrapper(request));
} else if (RequestUtils.isDispatcherServletRequest()) {
ctx.setRequest(new Servlet30RequestWrapper(request));
}
return null;
}
}
第三个过滤器FormBodyWrapperFilter,只对两种请求生效,分别是application/x-www-form-urlencoded和multipart/form-data
,过滤器的目的是讲符合请求的请求体包装成FormBodyRequestWrapper对象
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
FormBodyWrapperFilter.FormBodyRequestWrapper wrapper = null;
if (request instanceof HttpServletRequestWrapper) {
HttpServletRequest wrapped = (HttpServletRequest)ReflectionUtils.getField(this.requestField, request);
wrapper = new FormBodyWrapperFilter.FormBodyRequestWrapper(wrapped);
ReflectionUtils.setField(this.requestField, request, wrapper);
if (request instanceof ServletRequestWrapper) {
ReflectionUtils.setField(this.servletRequestField, request, wrapper);
}
} else {
wrapper = new FormBodyWrapperFilter.FormBodyRequestWrapper(request);
ctx.setRequest(wrapper);
}
if (wrapper != null) {
ctx.getZuulRequestHeaders().put("content-type", wrapper.getContentType());
}
return null;
}
第四个过滤器DebugFilter,通过zuul.debug.request和请求中的debug参数来决定是否执行过滤器中的操作,对于debug参数可以通过zuul.debug.parameter定义。
第五个过滤器PreDecorationFilter,判断是否有forward.to和serviceId参数,如果都不存在的话,那么就会执行过滤的具体操作,如果存在则说明已经处理过。Zuul在请求跳转的时候会自动添加头部信息,这个由zuul.addProxyHeaders=true实现的,并且这个也是默认值
route过滤器
第一个过滤器为RibbionRoutingFilter:只对有serviceId参数的请求过滤,通过使用Ribbion和Hystrix来向服务实例发起请求
第二个过滤器SimpleHostRoutingFilter:只对有url配置参数的路由生效,通过使用httpclient来向物理地址发起请求
第三个过滤器SendForwardFilter:只对forward.to参数的路由生效,用来处理forward
post过滤器
第一个过滤器为SendErrorFilter:只对有error.statu_code参数并且还没有被该过滤器处理过的时候执行
第二个过滤器为SendResponseFilter:利用请求的上下文的响应信息来组织需要发送回客户端的响应内容
异常处理
实现error阶段的过滤器,例如下方的ThrowExceptionFilter过滤器通过抛出zuul异常的方式让SendErrorFilter过滤器捕获。
@Component
public class ThrowExceptionFilter extends ZuulFilter {
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 1;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run(){
RequestContext ctx=RequestContext.getCurrentContext();
try{
throw new RuntimeException("error");
}catch (Exception e){
throw new ZuulRuntimeException(e);
}
}
}
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
return ctx.getThrowable() != null && !ctx.getBoolean("sendErrorFilter.ran", false);
}
public Object run() {
try {
RequestContext ctx = RequestContext.getCurrentContext();
ZuulException exception = this.findZuulException(ctx.getThrowable());
HttpServletRequest request = ctx.getRequest();
request.setAttribute("javax.servlet.error.status_code", exception.nStatusCode);
log.warn("Error during filtering", exception);
request.setAttribute("javax.servlet.error.exception", exception);
if (StringUtils.hasText(exception.errorCause)) {
request.setAttribute("javax.servlet.error.message", exception.errorCause);
}
RequestDispatcher dispatcher = request.getRequestDispatcher(this.errorPath);
if (dispatcher != null) {
ctx.set("sendErrorFilter.ran", true);
if (!ctx.getResponse().isCommitted()) {
ctx.setResponseStatusCode(exception.nStatusCode);
dispatcher.forward(request, ctx.getResponse());
}
}
} catch (Exception var5) {
ReflectionUtils.rethrowRuntimeException(var5);
}
return null;
}
public class ErrorFilter extends ZuulFilter {
@Override
public String filterType() {
return "error";
}
@Override
public int filterOrder() {
return 10;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
RequestContext requestContext=RequestContext.getCurrentContext();
requestContext.set("error.status_code",400);
return null;
}
}
不足与优化
从源码中可以看出只有post过滤器抛出异常后由error过滤器处理之后不会再调用post阶段的请求
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
try {
this.init((HttpServletRequest)servletRequest, (HttpServletResponse)servletResponse);
RequestContext context = RequestContext.getCurrentContext();
context.setZuulEngineRan();
try {
this.preRoute();
} catch (ZuulException var13) {
this.error(var13);
this.postRoute();
return;
}
try {
this.route();
} catch (ZuulException var12) {
this.error(var12);
this.postRoute();
return;
}
try {
this.postRoute();
} catch (ZuulException var11) {
this.error(var11);
}
} catch (Throwable var14) {
this.error(new ZuulException(var14, 500, "UNHANDLED_EXCEPTION_" + var14.getClass().getName()));
} finally {
RequestContext.getCurrentContext().unset();
}
}
为了解决没有将自定义错误信息返回到客户端的问题,我们直接通过继承SendErrorFilter,仅对post过滤器异常后处理
异常直接抛出吧,不然太过于麻烦,通过构造zuul异常抛出省事。
@Component
public class ErrorFilter extends ZuulFilter {
@Override
public String filterType() {
return "post";
}
@Override
public int filterOrder() {
return 100;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
try {
int a = 1 / 0;
}catch (Exception e){
throw new ZuulException(e,500,"o error!");
}
return null;
}
}
自定义异常信息
可以对信息的增加和修改来满足项目的需要
@Component
public class DidErrorAttribute extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(RequestAttributes requestAttributes, boolean includeStackTrace) {
Map<String, Object> map=super.getErrorAttributes(requestAttributes, includeStackTrace);
map.remove("exception");
return map;
}
}
禁用过滤器
zuul.<ClassName>.<filtertype>.disable=true
className:类名
filtertype: 过滤器类型
zuul.ErrorFilter.error.disable=true
Zuul过滤器的核心处理器FilterProcessor
执行所有过滤器
4.动态加载
因为路由一旦停机,那么一定是对用造成重大的影响。所以应该尽量让路由不停机,所以一些操作例如动态修改路由规则、动态添加、删除过滤器等都应该实现成动态的。
动态路由
与spring cloud config结合在一起即可
只需要配置config路径即可,记住git的属性文件名字跟服务名字一样
spring.application.name=api-gateway
server.port=5556
#spring.cloud.config.profile=dev
spring.cloud.config.uri=http://localhost:7001/
eureka.client.serviceUrl.defaultZone=http://localhost:1111/eureka/
动态过滤器
请求路由通过配置文件即可,而请求过滤则需要编码实现。所以我们要想实现请求过滤器的动态加载,我们需要借助于基于JVM实现的动态语言的帮助,比如Groovy。java是编译性语言,就是得编译后才能运行。groovy语言是动态语言。也就是说我直接改变文件即可马上更新配置,所以叫动态语言。
大致思路是配置一个可以扫描文件夹的bean,这个bean会每隔一定时间去轮询这个文件夹里面的过滤器,我们如果需要增加过滤器的话只需要将代码添加进去即可,如果要删除过滤器的话,直接删除是无效的,只有将shouldfilter的属性改成false才行。并且在这个过滤器里面也不能用spring 容器里面的bean.在当前版本,之后不清楚。。。
注意:此过滤器文件是在某个目录中,所以放到生产环境的时候注意相对路径
首先配置依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zuul</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
</dependency>
接着配置配置文件
主要是配置自定义属性文件
- root表示根路径
- interval表示加载过滤器的间隔时间
spring.application.name=api-gateway
server.port=5555
eureka.client.serviceUrl.defaultZone=http://localhost:1111/eureka/
zuul.filter.root=filter
zuul.filter.interval=5
#zuul.debug.request=true
配置相对应的属性类
@ConfigurationProperties("zuul.filter")
public class FilterConfiguration {
private String root;
private Integer interval;
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public Integer getInterval() {
return interval;
}
public void setInterval(Integer interval) {
this.interval = interval;
}
}
配置启动类,加载动态加载过滤器的Bean
@EnableZuulProxy
@EnableConfigurationProperties({FilterConfiguration.class})
@SpringCloudApplication
public class Application {
public static void main(String[] args) {
new SpringApplicationBuilder(Application.class).web(true).run(args);
}
@Bean
public FilterLoader filterLoader(FilterConfiguration filterConfiguration) {
FilterLoader filterLoader = FilterLoader.getInstance();
filterLoader.setCompiler(new GroovyCompiler());
try {
FilterFileManager.setFilenameFilter(new GroovyFileFilter());
FilterFileManager.init(
filterConfiguration.getInterval(),
filterConfiguration.getRoot() + "/pre",
filterConfiguration.getRoot() + "/post");
} catch (Exception e) {
throw new RuntimeException(e);
}
return filterLoader;
}
}
写测试用的过滤器,注意过滤器都是groovy文件,也就是说后缀都是groovy而不是java
class PostFilter extends ZuulFilter{
Logger log = LoggerFactory.getLogger(PostFilter.class)
@Override
String filterType() {
return "post"
}
@Override
int filterOrder() {
return 2000
}
@Override
boolean shouldFilter() {
return true
}
@Override
Object run() {
log.info("debug request : {}", RequestContext.getCurrentContext().getBoolean("debugRequest"))
log.info("this is a post filter: Receive response")
HttpServletResponse response = RequestContext.getCurrentContext().getResponse()
response.getOutputStream().print(", I am zhaiyongchao")
response.flushBuffer()
}
}
class PreFilter extends ZuulFilter {
Logger log = LoggerFactory.getLogger(PreFilter.class)
@Override
String filterType() {
return "pre"
}
@Override
int filterOrder() {
return 1000
}
@Override
boolean shouldFilter() {
return true
}
@Override
Object run() {
HttpServletRequest request = RequestContext.getCurrentContext().getRequest()
log.info("this is a pre filter: Send {} request to {}", request.getMethod(), request.getRequestURL().toString())
return null
}
}
5.Zuul的高可用
1.eureka方式
此方式首先需保证访问方就是一个eukrea客户端,一般很难做到。例如手机端就不是一个eureka客户端,网页也不是
2.使用其他的负载均衡Hystrix
例如nginx,使用nginx负载均衡多个zuul。在这里虽然nginx与zuul功能相似,但是使用zuul的同时也会将zuul变成一个eureka客户端,所以对于服务的转发都是通过服务发现的方式来获取真实url,不需要因编码。而使用nginx需要硬编码。由于nginx只需要维护zuul服务即可,所以维护数量小,没有关系。
6.使用Sidecar整合非jvm微服务
Sidecar是一种功能上等价于eureka client 的一种实现服务注册与发现的方式
Eureka Server 提供了一些关于eureka的端点,通过这些端点可以操作eureka,达到服务的注册与发现。实际上eureka client底层也是使用这些端点进行注册和发现的。
使用步骤
1.写一个非jvm服务,例如node.js
2.配置sidecar相关依赖包
3.配置启动类打上@EnableSidecar
4.编写配置信息 ,进行服务注册
- sidecar.port=8060 这里的端口是微服务的端口
- sidecar.id-address 这里写的是ip地址
5.非jvm访问jvm,通过zuul
http://localhost:8070/llg-consumer-one/hello
6.jvm访问非jvm,通过服务名称
实际上就是使用ribbon的方式去访问 restTemplate.getForObject("xx")
7.编写文件上传微服务
功能与传统文件上传没啥区别,只是因为有了zuul后稍微有点不同。由于为了避免转发大量数据。对于超过10MB大小的文件可以通过加/zuul前缀进行上传文件。具体url就是 http://localhost:5555/zuul/llg-consumer-one/upload。如果小于1MB则无需加上/zuul前缀
注意:需要考虑超时问题和文件大小问题