springboot中使用过滤器、拦截器、监听器、Servlet
监听器:listener是servlet规范中定义的一种特殊类。用于监听servletContext、HttpSession和servletRequest等域对象的创建和销毁事件。监听域对象的属性发生修改的事件。用于在事件发生前、发生后做一些必要的处理。其主要可用于以下方面:1、统计在线人数和在线用户2、系统启动时加载初始化信息3、统计网站访问量4、记录用户访问路径。
过滤器:Filter是Servlet技术中最实用的技术,Web开发人员通过Filter技术,对web服务器管理的所有web资源:例如Jsp, Servlet, 静态图片文件或静态 html 文件等进行拦截,从而实现一些特殊的功能。例如实现URL级别的权限访问控制、过滤敏感词汇、压缩响应信息等一些高级功能。它主要用于对用户请求进行预处理,也可以对HttpServletResponse进行后处理。使用Filter的完整流程:Filter对用户请求进行预处理,接着将请求交给Servlet进行处理并生成响应,最后Filter再对服务器响应进行后处理。
拦截器:Interceptor 在AOP(Aspect-Oriented Programming)中用于在某个方法或字段被访问之前,进行拦截然后在之前或之后加入某些操作。比如日志,安全等。一般拦截器方法都是通过动态代理的方式实现。可以通过它来进行权限验证,或者判断用户是否登陆,或者是像12306 判断当前时间是否是购票时间。
过滤器Filter只在Servlet前后起作用,而拦截器Interceptor可以深入到方法前后,异常抛出前后等,具有更大的弹性。所以在Spring的程序里应该尽量用拦截器,在简单的java—web项目里可以使用较为简单的过滤器。
对于一个SpringMVC项目,其执行过程如下:
Filter chain 放行前
DispatcherServlet doDispatch 方法
inteceptors - preHandle
invoke 方法
inteceptors - postHandle
inteceptors - afterCompletion
Filter chain 放行后代码
javax.servlet.ServletContext提供了动态注册的接口:
public ServletRegistration.Dynamic addServlet(String servletName, String className); public ServletRegistration.Dynamic addServlet(String servletName, Servlet servlet); public ServletRegistration.Dynamic addServlet(String servletName, Class<? extends Servlet> servletClass);
对于filter和listener 也有对应的add方法。比如addFilter、addListener。 比如动态注册一个Servlet, 如下:
// Register Servlet ServletRegistration sr = servletContext.addServlet("DynamicServlet", new MyServlet()); sr.setInitParameter("servletInitName", "servletInitValue"); sr.addMapping("/*");
其实Spring 提供的一套注册的方式原理也是基于ServletContext 的动态注册。只不过Spring 进行了封装,暴露出一些简单操作的接口 。
==========1.过滤器的使用===========
过滤器的使用有两种方式。
1.基于注册的方式
这种使用方式简单:编写过滤器、注入到spring中。
过滤器1:
package cn.qlq.filter; import java.io.IOException; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.annotation.WebFilter; public class MyFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("=========enter filter==========="); chain.doFilter(request, response); } @Override public void destroy() { } @Override public void init(FilterConfig arg0) throws ServletException { } }
过滤器2:
package cn.qlq.filter; import java.io.IOException; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.annotation.WebFilter; public class MyFilter2 implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("=========enter filter (MyFilter2)==========="); chain.doFilter(request, response); } @Override public void destroy() { } @Override public void init(FilterConfig arg0) throws ServletException { } }
注册到spring中:(这种注册方式的顺序可以过滤器的指定)
package cn.qlq.config; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import cn.qlq.filter.MyFilter; import cn.qlq.filter.MyFilter2; /** * 注册filter,setOrder可以控制顺序 * * @author Administrator * */ @Configuration public class FilterConfig { @Bean public FilterRegistrationBean registMyFilter() { FilterRegistrationBean registration = new FilterRegistrationBean(); registration.setFilter(new MyFilter()); registration.addUrlPatterns("/*"); registration.setName("myFilter"); registration.setOrder(2); return registration; } @Bean public FilterRegistrationBean registMyFilter2() { FilterRegistrationBean registration = new FilterRegistrationBean(); registration.setFilter(new MyFilter2()); registration.addUrlPatterns("/*"); registration.setName("myFilter2"); registration.setOrder(1); return registration; } }
测试:
2.基于注解的方式
这种方式只需要在过滤器加上注解@WebFilter即可。过滤器的执行顺序是按照类名的字母顺序进行过滤。这种方式需要在springboot的运行类增加@ServletComponentScan注解。ServletComponentScan注解的为了Servlet、Filter、Listener可以直接通过@WebServlet、@WebFilter、@WebListener注解自动注册,无需其他代码。
package cn.qs; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.servlet.ServletComponentScan; @SpringBootApplication // Servlet、Filter、Listener可以直接通过@WebServlet、@WebFilter、@WebListener注解自动注册,无需其他代码。 @ServletComponentScan("cn") public class MySpringBootApplication { public static void main(String[] args) { // 入口运行类 SpringApplication.run(MySpringBootApplication.class, args); } }
package cn.qlq.filter; import java.io.IOException; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.annotation.WebFilter; /** * 注解配置的filter执行顺序按类名的顺序执行 */ @WebFilter(filterName = "myFilter", urlPatterns = "/*") public class MyFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("=========enter filter==========="); chain.doFilter(request, response); } @Override public void destroy() { } @Override public void init(FilterConfig arg0) throws ServletException { } }
package cn.qlq.filter; import java.io.IOException; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.annotation.WebFilter; @WebFilter(filterName = "myFilter2", urlPatterns = "/*") public class MyFilter2 implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("=========enter filter (MyFilter2)==========="); chain.doFilter(request, response); } @Override public void destroy() { } @Override public void init(FilterConfig arg0) throws ServletException { } }
测试:
补充:还有另一种注册的方式,直接将filter 注入到Spring
这时候Spring 会采用一些默认的配置,比如filterName 采用的是bean的名称;默认拦截的url 是/*。 比如:
package com.zd.bx.filter; import org.springframework.stereotype.Component; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class MyFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; System.out.println("com.zd.bx.filter.MyFilter.doFilter"); chain.doFilter(request, response); } @Override public void destroy() { } @Override public void init(FilterConfig arg0) throws ServletException { } }
容器启动过程中:
1. 调用org.springframework.boot.web.servlet.ServletContextInitializerBeans#addAdaptableBeans
protected void addAdaptableBeans(ListableBeanFactory beanFactory) { MultipartConfigElement multipartConfig = this.getMultipartConfig(beanFactory); this.addAsRegistrationBean(beanFactory, Servlet.class, new ServletContextInitializerBeans.ServletRegistrationBeanAdapter(multipartConfig)); this.addAsRegistrationBean(beanFactory, Filter.class, new ServletContextInitializerBeans.FilterRegistrationBeanAdapter()); Iterator var3 = ServletListenerRegistrationBean.getSupportedTypes().iterator(); while(var3.hasNext()) { Class<?> listenerType = (Class)var3.next(); this.addAsRegistrationBean(beanFactory, EventListener.class, listenerType, new ServletContextInitializerBeans.ServletListenerRegistrationBeanAdapter()); } }
2. org.springframework.boot.web.servlet.ServletContextInitializerBeans#addAsRegistrationBean(org.springframework.beans.factory.ListableBeanFactory, java.lang.Class<T>, java.lang.Class<B>, org.springframework.boot.web.servlet.ServletContextInitializerBeans.RegistrationBeanAdapter<T>)
private <T, B extends T> void addAsRegistrationBean(ListableBeanFactory beanFactory, Class<T> type, Class<B> beanType, ServletContextInitializerBeans.RegistrationBeanAdapter<T> adapter) { List<Entry<String, B>> entries = this.getOrderedBeansOfType(beanFactory, beanType, this.seen); Iterator var6 = entries.iterator(); while(var6.hasNext()) { Entry<String, B> entry = (Entry)var6.next(); String beanName = (String)entry.getKey(); B bean = entry.getValue(); if (this.seen.add(bean)) { RegistrationBean registration = adapter.createRegistrationBean(beanName, bean, entries.size()); int order = this.getOrder(bean); registration.setOrder(order); this.initializers.add(type, registration); if (logger.isTraceEnabled()) { logger.trace("Created " + type.getSimpleName() + " initializer for bean '" + beanName + "'; order=" + order + ", resource=" + this.getResourceDescription(beanName, beanFactory)); } } } } private <T> List<Entry<String, T>> getOrderedBeansOfType(ListableBeanFactory beanFactory, Class<T> type, Set<?> excludes) { String[] names = beanFactory.getBeanNamesForType(type, true, false); Map<String, T> map = new LinkedHashMap(); String[] var6 = names; int var7 = names.length; for(int var8 = 0; var8 < var7; ++var8) { String name = var6[var8]; if (!excludes.contains(name) && !ScopedProxyUtils.isScopedTarget(name)) { T bean = beanFactory.getBean(name, type); if (!excludes.contains(bean)) { map.put(name, bean); } } } List<Entry<String, T>> beans = new ArrayList(map.entrySet()); beans.sort((o1, o2) -> { return AnnotationAwareOrderComparator.INSTANCE.compare(o1.getValue(), o2.getValue()); }); return beans; }
1》这里获取到所有的filter, 然后创建RegistrationBean。
2》然后获取order,并且设置order。
protected int getOrder(@Nullable Object obj) { if (obj != null) { Integer order = this.findOrder(obj); if (order != null) { return order; } } return 2147483647; }
3》添加到initializers 属性中
关于其默认的拦截路径是:org.springframework.boot.web.servlet.AbstractFilterRegistrationBean#DEFAULT_URL_MAPPINGS
private static final String[] DEFAULT_URL_MAPPINGS = new String[]{"/*"};
==============2.拦截器的使用==================
拦截器的使用方法是先编写拦截器,然后注册到spring。拦截器的执行顺序是按照其注册顺序拦截。
package cn.qlq.interceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; public class MyInterceptor1 implements HandlerInterceptor { /** * 在请求处理之前进行调用(Controller方法调用之前) */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object object) throws Exception { System.out.println("被 MyInterceptor1 postHandle拦截,放行..."); return true; } /** * 请求处理之后进行调用,但是在视图被渲染之前(Controller方法调用之后) */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object object, ModelAndView mv) throws Exception { System.out.println("被 MyInterceptor1 postHandle 拦截,放行..."); } /** * 在整个请求结束之后被调用,也就是在DispatcherServlet 渲染了对应的视图之后执行 (主要是用于进行资源清理工作) */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object object, Exception ex) throws Exception { System.out.println("被 MyInterceptor1 afterCompletion 拦截,放行..."); } }
package cn.qlq.interceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; public class MyInterceptor2 implements HandlerInterceptor { /** * 在请求处理之前进行调用(Controller方法调用之前) */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object object) throws Exception { System.out.println("被 MyInterceptor2 postHandle拦截,放行..."); return true; } /** * 请求处理之后进行调用,但是在视图被渲染之前(Controller方法调用之后) */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object object, ModelAndView mv) throws Exception { System.out.println("被 MyInterceptor2 postHandle 拦截,放行..."); } /** * 在整个请求结束之后被调用,也就是在DispatcherServlet 渲染了对应的视图之后执行 (主要是用于进行资源清理工作) */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object object, Exception ex) throws Exception { System.out.println("被 MyInterceptor2 afterCompletion 拦截,放行..."); } }
注册到spring中:
package cn.qlq.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; import cn.qlq.interceptor.MyInterceptor1; import cn.qlq.interceptor.MyInterceptor2; /** * 注册拦截器 * * @author Administrator * */ @Configuration public class InterceptorConfig extends WebMvcConfigurerAdapter { @Override public void addInterceptors(InterceptorRegistry registry) { /** * 拦截器按照顺序执行 */ registry.addInterceptor(new MyInterceptor1()).addPathPatterns("/th/**").addPathPatterns("/freemarker/**"); registry.addInterceptor(new MyInterceptor2()).addPathPatterns("/freemarker/**"); super.addInterceptors(registry); } }
=============3.监听器的使用=================
监听器的使用与过滤器使用差不多,也是先编写监听器,然后注入到spring中。
package cn.qlq.listener; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; public class MyHttpSessionListener implements HttpSessionListener { public static int online = 0; @Override public void sessionCreated(HttpSessionEvent se) { System.out.println("创建session,在线用户数:" + (++online)); } @Override public void sessionDestroyed(HttpSessionEvent se) { System.out.println("销毁session,在线用户数:" + (--online)); online--; } }
package cn.qlq.listener; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; public class MyServletContextListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { System.out.println("容器创建"); } @Override public void contextDestroyed(ServletContextEvent sce) { System.out.println("容器销毁"); } }
注入到spring中:
package cn.qlq.config; import org.springframework.boot.web.servlet.ServletListenerRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import cn.qlq.listener.MyHttpSessionListener; import cn.qlq.listener.MyServletContextListener; /** * * @author Administrator * */ @Configuration public class ListenerConfig { @Bean public ServletListenerRegistrationBean<MyHttpSessionListener> listenerRegist() { ServletListenerRegistrationBean<MyHttpSessionListener> srb = new ServletListenerRegistrationBean<MyHttpSessionListener>(); srb.setListener(new MyHttpSessionListener()); return srb; } @Bean public ServletListenerRegistrationBean<MyServletContextListener> listenerRegist2() { ServletListenerRegistrationBean<MyServletContextListener> srb = new ServletListenerRegistrationBean<MyServletContextListener>(); srb.setListener(new MyServletContextListener()); return srb; } }
测试:
补充:当然监听器可以使用注解方式,如下:
package cn.qs.listener; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; import javax.servlet.annotation.WebListener; @WebListener public class StartListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { System.out.println("容器启动"); } @Override public void contextDestroyed(ServletContextEvent sce) { System.out.println("容器销毁"); } }
=============4.Servlet的使用=================
这里研究基于SpringMVC 提供的注册器。
1. Servlet
package cn.xm.servlet; import javax.servlet.*; import java.io.IOException; // 也可以用注解注入 //@WebServlet public class MyServlet implements Servlet { public MyServlet() { System.out.println("MyServlet~~~~~~~~~~~ create"); } @Override public void init(ServletConfig config) throws ServletException { } @Override public ServletConfig getServletConfig() { return null; } @Override public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException { System.out.println("cn/xm/servlet/MyServlet.java:23 service~~~~~~"); } @Override public String getServletInfo() { return null; } @Override public void destroy() { } }
2. 注册:
package cn.xm.servlet; import org.springframework.boot.web.servlet.ServletRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MyServletConfig { @Bean public ServletRegistrationBean servletRegistrationBean() { ServletRegistrationBean servletRegistrationBean = new ServletRegistrationBean(new MyServlet(), "/myservlet"); servletRegistrationBean.addInitParameter("param1", "value1"); return servletRegistrationBean; } }
3. 查看源码
Spring 提供了一个注册接口org.springframework.boot.web.servlet.ServletContextInitializer
public interface ServletContextInitializer { void onStartup(ServletContext servletContext) throws ServletException; }
org.springframework.boot.web.servlet.RegistrationBean 是一个抽象类,实现了上面接口,并实现了一些基本的方法,其子类有:分别对应不同的注册器
org.springframework.boot.web.servlet.ServletRegistrationBean 继承自上面类RegistrationBean,源码如下:
public class ServletRegistrationBean extends RegistrationBean { private static final Log logger = LogFactory.getLog(ServletRegistrationBean.class); private static final String[] DEFAULT_MAPPINGS = { "/*" }; private Servlet servlet; private Set<String> urlMappings = new LinkedHashSet<String>(); private boolean alwaysMapUrl = true; private int loadOnStartup = -1; private MultipartConfigElement multipartConfig; /** * Create a new {@link ServletRegistrationBean} instance. */ public ServletRegistrationBean() { } /** * Create a new {@link ServletRegistrationBean} instance with the specified * {@link Servlet} and URL mappings. * @param servlet the servlet being mapped * @param urlMappings the URLs being mapped */ public ServletRegistrationBean(Servlet servlet, String... urlMappings) { this(servlet, true, urlMappings); } /** * Create a new {@link ServletRegistrationBean} instance with the specified * {@link Servlet} and URL mappings. * @param servlet the servlet being mapped * @param alwaysMapUrl if omitted URL mappings should be replaced with '/*' * @param urlMappings the URLs being mapped */ public ServletRegistrationBean(Servlet servlet, boolean alwaysMapUrl, String... urlMappings) { Assert.notNull(servlet, "Servlet must not be null"); Assert.notNull(urlMappings, "UrlMappings must not be null"); this.servlet = servlet; this.alwaysMapUrl = alwaysMapUrl; this.urlMappings.addAll(Arrays.asList(urlMappings)); } /** * Returns the servlet being registered. * @return the servlet */ protected Servlet getServlet() { return this.servlet; } /** * Sets the servlet to be registered. * @param servlet the servlet */ public void setServlet(Servlet servlet) { Assert.notNull(servlet, "Servlet must not be null"); this.servlet = servlet; } /** * Set the URL mappings for the servlet. If not specified the mapping will default to * '/'. This will replace any previously specified mappings. * @param urlMappings the mappings to set * @see #addUrlMappings(String...) */ public void setUrlMappings(Collection<String> urlMappings) { Assert.notNull(urlMappings, "UrlMappings must not be null"); this.urlMappings = new LinkedHashSet<String>(urlMappings); } /** * Return a mutable collection of the URL mappings for the servlet. * @return the urlMappings */ public Collection<String> getUrlMappings() { return this.urlMappings; } /** * Add URL mappings for the servlet. * @param urlMappings the mappings to add * @see #setUrlMappings(Collection) */ public void addUrlMappings(String... urlMappings) { Assert.notNull(urlMappings, "UrlMappings must not be null"); this.urlMappings.addAll(Arrays.asList(urlMappings)); } /** * Sets the {@code loadOnStartup} priority. See * {@link ServletRegistration.Dynamic#setLoadOnStartup} for details. * @param loadOnStartup if load on startup is enabled */ public void setLoadOnStartup(int loadOnStartup) { this.loadOnStartup = loadOnStartup; } /** * Set the {@link MultipartConfigElement multi-part configuration}. * @param multipartConfig the multi-part configuration to set or {@code null} */ public void setMultipartConfig(MultipartConfigElement multipartConfig) { this.multipartConfig = multipartConfig; } /** * Returns the {@link MultipartConfigElement multi-part configuration} to be applied * or {@code null}. * @return the multipart config */ public MultipartConfigElement getMultipartConfig() { return this.multipartConfig; } /** * Returns the servlet name that will be registered. * @return the servlet name */ public String getServletName() { return getOrDeduceName(this.servlet); } @Override public void onStartup(ServletContext servletContext) throws ServletException { Assert.notNull(this.servlet, "Servlet must not be null"); String name = getServletName(); if (!isEnabled()) { logger.info("Servlet " + name + " was not registered (disabled)"); return; } logger.info("Mapping servlet: '" + name + "' to " + this.urlMappings); Dynamic added = servletContext.addServlet(name, this.servlet); if (added == null) { logger.info("Servlet " + name + " was not registered " + "(possibly already registered?)"); return; } configure(added); } /** * Configure registration settings. Subclasses can override this method to perform * additional configuration if required. * @param registration the registration */ protected void configure(ServletRegistration.Dynamic registration) { super.configure(registration); String[] urlMapping = this.urlMappings .toArray(new String[this.urlMappings.size()]); if (urlMapping.length == 0 && this.alwaysMapUrl) { urlMapping = DEFAULT_MAPPINGS; } if (!ObjectUtils.isEmpty(urlMapping)) { registration.addMapping(urlMapping); } registration.setLoadOnStartup(this.loadOnStartup); if (this.multipartConfig != null) { registration.setMultipartConfig(this.multipartConfig); } } }
核心是在onStartup 方法,前面都是在准备以及验证数据,真正的注册是在这里。这里也是用ServletContext.addServlet 动态注册Servlet,然后configure 方法设置匹配的URI。
这个方法的调用过程是在启动时IoC容器创建完成方法org.springframework.boot.context.embedded.EmbeddedWebApplicationContext#onRefresh调用方法
org.springframework.boot.context.embedded.EmbeddedWebApplicationContext#createEmbeddedServletContainer 内。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix