使用Spring框架各个组件实现一个在线聊天网页,当有用户连接WebSocket,服务器监听到用户连接会使用Stomp推送最新用户列表,有用户断开刷新在线列表,实时推送用户聊天信息。引入Jetty服务器,直接嵌入整个工程可以脱离Java Web容器独立运行,使用插件打包成一个jar文件,就像Spring Boot一样运行,部署。
pom.xml 依赖
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <maven.compiler.encoding>UTF-8</maven.compiler.encoding> <jetty.version>9.4.8.v20171121</jetty.version> <spring.version>5.0.4.RELEASE</spring.version> <jackson.version>2.9.4</jackson.version> <lombok.version>1.16.18</lombok.version> <dbh2.version>1.4.196</dbh2.version> <jcl.slf4j.version>1.7.25</jcl.slf4j.version> <spring.security.version>5.0.3.RELEASE</spring.security.version> <logback.version>1.2.3</logback.version> <activemq.version>5.15.0</activemq.version> </properties> <dependencies> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-servlet</artifactId> <version>${jetty.version}</version> </dependency> <!-- 添加websocket 依赖不然会出现 java.lang.IllegalStateException: No suitable default RequestUpgradeStrategy found --> <dependency> <groupId>org.eclipse.jetty.websocket</groupId> <artifactId>websocket-server</artifactId> <version>${jetty.version}</version> </dependency> <dependency> <groupId>org.eclipse.jetty.websocket</groupId> <artifactId>websocket-api</artifactId> <version>${jetty.version}</version> </dependency> <!--spring mvc --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webflux</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-websocket</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-messaging</artifactId> <version>${spring.version}</version> </dependency> <!--spring security --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-web</artifactId> <version>${spring.security.version}</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> <version>${spring.security.version}</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-client</artifactId> <version>${spring.security.version}</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> <version>${spring.security.version}</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-messaging</artifactId> <version>${spring.security.version}</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-core</artifactId> <version>${jackson.version}</version> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>${dbh2.version}</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>${jackson.version}</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>jcl-over-slf4j</artifactId> <version>${jcl.slf4j.version}</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>${logback.version}</version> </dependency> <dependency> <groupId>org.apache.activemq</groupId> <artifactId>activemq-broker</artifactId> <version>${activemq.version}</version> </dependency> <dependency> <groupId>io.projectreactor.ipc</groupId> <artifactId>reactor-netty</artifactId> <version>0.7.2.RELEASE</version> </dependency> </dependencies>
@Bean //内存模式 public DataSource dataSource(){ EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder(); EmbeddedDatabase build = builder.setType(EmbeddedDatabaseType.H2) .addScript("db/sql/create-db.sql") //每次创建数据源都会执行脚本 .addScript("db/sql/insert-data.sql") .build(); return build; }
这种方式是利用Spring 内置的嵌入式数据库的数据源模板,创建的数据源,比较简单,但是这种方式不支持定制,数据只能保存在内存中,项目重启数据就会丢失了。
设置数据保存到硬盘
@Bean public DataSource dataSource() { DriverManagerDataSource dataSource = new DriverManagerDataSource(); dataSource.setDriverClassName("org.h2.Driver"); dataSource.setUsername("embedded"); dataSource.setPassword("embedded"); dataSource.setUrl("jdbc:h2:file:./data;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;"); return dataSource; }
如果你还想每次创建数据源执行初始化sql,使用 org.springframework.jdbc.datasource.init.ResourceDatabasePopulator
装载sql 脚本用于初始化或清理数据库
@Bean public ResourceDatabasePopulator databasePopulator() { ResourceDatabasePopulator populator = new ResourceDatabasePopulator(); populator.addScript(schema); populator.addScripts(data); populator.setContinueOnError(true); return populator; }
设置 DatabasePopulator 对象,用户数据源启动或者消耗的时候执行脚本
@Bean public DataSourceInitializer initializer() { DataSourceInitializer initializer = new DataSourceInitializer(); initializer.setDatabasePopulator(databasePopulator()); initializer.setDataSource(dataSource()); return initializer; }
启用H2 web Console
@Bean(initMethod = "start",destroyMethod = "stop") public Server DatasourcesManager() throws SQLException { return Server.createWebServer("-web","-webAllowOthers","-webPort","8082"); }
浏览器打开 http://localhost:8082
访问H2 控制台
设置事务管理器
@Bean public PlatformTransactionManager transactionManager() { PlatformTransactionManager manager = new DataSourceTransactionManager(dataSource()); return manager; } }
到这里,嵌入H2数据库配置基本已经设置完成了
import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @Configuration @EnableWebMvc @ComponentScan(basePackages = "org.ting.spring.controller", //基包路径设置 includeFilters = @ComponentScan.Filter(value = {ControllerAdvice.class,Controller.class})) //只扫描MVC controll的注解 public class WebMvcConfiguration implements WebMvcConfigurer { public void extendMessageConverters(List<HttpMessageConverter<?>> converters) { converters.add(new MappingJackson2HttpMessageConverter()); } @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { //添加静态路径映射 registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/"); } }
因为Spring 注解扫描只能注册一个类, 使用 @Import
引入其他的配置类
@Configuration @ComponentScan(basePackages = "org.ting.spring", excludeFilters = {@ComponentScan.Filter(value = {Controller.class,ControllerAdvice.class})}) @Import({WebMvcConfiguration.class}) //引入Spring MVC配置类 public class WebRootConfiguration { @Autowired private DataSource dataSource; @Bean public JdbcTemplate jdbcTemplate(){ JdbcTemplate template = new JdbcTemplate(dataSource); return template; } }
使用Spring AnnotationConfigWebApplicationContext
启动注解扫描,注册创建bean将 WebApplicationContext
,在将对象传给 DispatcherServlet
public class JettyEmbedServer { private final static int DEFAULT_PORT = 9999; private final static String DEFAULT_CONTEXT_PATH = "/"; private final static String MAPPING_URL = "/*"; public static void main(String[] args) throws Exception { Server server = new Server(DEFAULT_PORT); JettyEmbedServer helloServer = new JettyEmbedServer(); server.setHandler(helloServer.servletContextHandler()); server.start(); server.join(); } private ServletContextHandler servletContextHandler() { WebApplicationContext context = webApplicationContext(); ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS); servletContextHandler.setContextPath(DEFAULT_CONTEXT_PATH); ServletHolder servletHolder = new ServletHolder(new DispatcherServlet(context)); servletHolder.setAsyncSupported(true); servletContextHandler.addServlet(servletHolder, MAPPING_URL); return servletContextHandler; } private WebApplicationContext webApplicationContext() { AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); context.register(WebRootConfiguration.class); return context; }
默认Spring Security拦截请求,登录失败,登录成功都是页面跳转的方式,我们希望ajax请求的时候,无论是被拦截了,或者登录失败,成功都可以返回json格式数据,由前端人员来处理。
根据 HttpRequestServlet
请求头 X-Requested-With
是否等于 XMLHttpRequest
判断是否是ajax。
public class RespnonseJson { public static void jsonType(HttpServletResponse response) { response.setContentType("application/json;charset=UTF-8"); response.setCharacterEncoding("utf-8"); } public static boolean ajaxRequest(HttpServletRequest request){ String header = request.getHeader("X-Requested-With"); return ! StringUtils.isEmpty(header) && header.equals("XMLHttpRequest"); } public static boolean matchURL(String url) { Pattern compile = Pattern.compile("^/api/.+"); return compile.matcher(url).matches(); } }
登录认证处理器
public class RestAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint { /** * @param loginFormUrl URL where the login page can be found. Should either be * relative to the web-app context path (include a leading {@code /}) or an absolute * URL. */ public RestAuthenticationEntryPoint(String loginFormUrl) { super(loginFormUrl); } @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { String uri = request.getRequestURI(); if (matchURL(uri)) { // /api 都是ajax 请求 jsonType(response); response.getWriter().println(getErr(authException.getMessage())); }else if (ajaxRequest(request)){ jsonType(response); response.getWriter().println(getErr(authException.getMessage())); }else super.commence(request,response,authException); } private String getErr(String description) throws JsonProcessingException { Result result = Result.error(Result.HTTP_FORBIDDEN, description); ObjectMapper mapper = new ObjectMapper(); return mapper.writeValueAsString(result); } }
登录成功处理
public class RestAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { String uri = request.getRequestURI(); if (matchURL(uri)){ jsonType(response); String value = loginSuccess(); response.getWriter().println(value); }else if (ajaxRequest(request)){ jsonType(response); String success = loginSuccess(); response.getWriter().println(success); }else super.onAuthenticationSuccess(request,response,authentication); } private String loginSuccess() throws JsonProcessingException { Result success = Result.success("sign on success go to next!"); ObjectMapper mapper = new ObjectMapper(); return mapper.writeValueAsString(success); } }
登录失败处理
public class RestAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { if (ajaxRequest(request)){ jsonType(response); String err = getErr(exception.getMessage()); response.getWriter().println(err); }else super.onAuthenticationFailure(request,response,exception); } public String getErr(String description) throws JsonProcessingException { Result result = Result.error(Result.HTTP_AUTH_FAILURE, description); ObjectMapper mapper = new ObjectMapper(); return mapper.writeValueAsString(result); } }
我在网上搜索ajax 认证错误,很多博客是这样写的
response.sendError(500, "Authentication failed");
这个错误会被Jetty 错误页面捕获,扰乱返回JSON数据,这个细节要注意下
注册Handler
@Bean public AuthenticationEntryPoint entryPoint() { RestAuthenticationEntryPoint entryPoint = new RestAuthenticationEntryPoint("/static/html/login.html"); return entryPoint; } @Bean public SimpleUrlAuthenticationSuccessHandler successHandler() { RestAuthSuccessHandler successHandler = new RestAuthSuccessHandler(); return successHandler; } @Bean public SimpleUrlAuthenticationFailureHandler failureHandler() { RestAuthFailureHandler failureHandler = new RestAuthFailureHandler(); return failureHandler; }
配置url 认证
@Bean public SessionRegistry sessionManager() { return new SessionRegistryImpl(); } @Override protected void configure(HttpSecurity http) throws Exception { http.exceptionHandling() .authenticationEntryPoint(entryPoint()) .and() .authorizeRequests() .antMatchers("/static/html/jetty-chat.html", "/api/user/online", "/api/user/loginuser") .authenticated() //设置需要认证才可以请求的接口 .and() .formLogin() .successHandler(successHandler()) //登录成功处理 .failureHandler(failureHandler()) //登录失败处理 .loginPage("/static/html/login.html") //登录页面 .loginProcessingUrl("/auth/login") //登录表单url .defaultSuccessUrl("/static/html/jetty-chat.html") //成功跳转url .permitAll() .and().csrf().disable()//禁用csrf 因为没有使用模板引擎 .sessionManagement().maximumSessions(1) //设置同一个账户,同时在线次数 .sessionRegistry(sessionManager()) // 设置Session 管理器, .expiredUrl("/static/html/login.html") //session 失效后,跳转url .maxSessionsPreventsLogin(false) //设置true,达到session 最大登录次数后,后面的账户都会登录失败,false 顶号 前面登录账户会被后面顶下线 ; //注销账户,跳转到登录页面 http.logout().logoutUrl("/logout").logoutSuccessUrl("/static/html/login.html");
在配置类添加 @EnableWebSecurity
,在扫描类上引入Spring Security配置,大功告成了,并没有!Spring Security 是使用Filter来处理一些认证请求,需要我们在Jetty中手动注册拦截器
//手动注册拦截器,让Spring Security 生效 FilterHolder filterHolder = new FilterHolder(new DelegatingFilterProxy("springSecurityFilterChain")); servletContextHandler.addFilter(filterHolder, MAPPING_URL, null); servletContextHandler.addEventListener(new ContextLoaderListener(context)); servletContextHandler.addEventListener(new HttpSessionEventPublisher()); //使用security session 监听器 限制只允许一个用户登录
@Configuration @EnableWebSocketMessageBroker @ComponentScan(basePackages = "org.ting.spring.stomp.message") @Slf4j public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer { //设置连接的端点路径 @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("endpoint").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 定义了两个客户端订阅地址的前缀信息,也就是客户端接收服务端发送消息的前缀信息 registry.enableSimpleBroker("/topic", "/queue"); // 定义了服务端接收地址的前缀,也即客户端给服务端发消息的地址前缀 registry.setApplicationDestinationPrefixes("/app"); //使用客户端一对一通信 registry.setUserDestinationPrefix("/user"); registry.setPathMatcher(new AntPathMatcher(".")); } }
配置stomp 频道认证
@Configuration public class SocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer { @Override protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) { messages.simpDestMatchers("/user/**").authenticated()//认证所有user 链接 .anyMessage().permitAll(); } //允许跨域 不然会出现 Could not verify the provided CSRF token because your session was not found 异常 @Override protected boolean sameOriginDisabled() { return true; } }
信息处理
@Controller @Slf4j public class StompController { @Autowired private SimpMessagingTemplate messagingTemplate; @MessageExceptionHandler @SendToUser("/queue.errors") public String handleException(Throwable exception) { return exception.getMessage(); } @MessageMapping("receive.messgae") public void forwardMsg(ChatMessage message){ log.info("message : {}",message); message.setLocalDateTime(LocalDateTime.now()); messagingTemplate.convertAndSendToUser(message.getTargetUser().getEmail() ,"queue.notification",message); } }
@MessageMapping
作用与 @RequestMapping
功能差不多用于匹配url
更多 Spring WebSocket 官方文档查看
我们使用一个集合来保存连接上的用户,使用连接,断开监听器来修改集合的列表,并将集合的数据发布到频道上。
websocket 断开连接监听器
@Component @Slf4j public class WebSocketDisconnectListener implements ApplicationListener<SessionDisconnectEvent> { @Autowired private UserService userService; @Autowired private SimpMessagingTemplate messageTemplate; @Override public void onApplicationEvent(SessionDisconnectEvent event) { Principal principal = event.getUser(); log.info("client sessionId : {} name : {} disconnect ....",event.getSessionId(),principal.getName()); if (principal != null){ //已经认证过的用户 User user = userService.findByEmail(principal.getName()); Online.remove(user); messageTemplate.convertAndSend("/topic/user.list",Online.onlineUsers()); } } }
注册连接websocket 监听器
@Component @Slf4j public class WebSocketSessionConnectEvent implements ApplicationListener<SessionConnectEvent>{ @Autowired private SimpMessagingTemplate messageTemplate; @Autowired private UserService userService; @Override public void onApplicationEvent(SessionConnectEvent event) { Principal principal = event.getUser(); log.info("client name: {} connect.....",principal.getName()); if (principal != null){ User user = userService.findByEmail(principal.getName()); Online.add(user); messageTemplate.convertAndSend("/topic/user.list",Online.onlineUsers()); } } }
保存在线列表
public class Online { private static Map<String,User> maps = new ConcurrentHashMap<>(); public static void add(User user){ maps.put(user.getEmail(),user); } public static void remove(User user){ maps.remove(user.getEmail()); } public static Collection<User> onlineUsers(){ return maps.values(); } }
手动配置ClientRegistrationRepository 设置 client-id
, client-secret
, redirect-uri-template
@Bean public ClientRegistrationRepository clientRegistrationRepository() { return new InMemoryClientRegistrationRepository(githubClientRegstrationRepository() ,googleClientRegistrionRepository()); } public ClientRegistration githubClientRegstrationRepository(){ return CommonOAuth2Provider.GITHUB.getBuilder("github") .clientId(env.getProperty("registration.github.client-id")) .clientSecret(env.getProperty("registration.github.client-secret")) .redirectUriTemplate(env.getProperty("registration.github.redirect-uri-template")) .build(); } public ClientRegistration googleClientRegistrionRepository(){ return CommonOAuth2Provider.GOOGLE.getBuilder("google") .clientId(env.getProperty("registration.google.client-id")) .clientSecret(env.getProperty("registration.google.client-secret")) .redirectUriTemplate(env.getProperty("registration.google.redirect-uri-template")) .scope( "profile", "email") .build(); } @Bean public OAuth2AuthorizedClientService authorizedClientService() { return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository()); }
我们使用github,google OAuth2 授权登录的账户,登录通过后保存起来,则需求继承 DefaultOAuth2UserService
@Service @Slf4j public class CustomOAuth2UserService extends DefaultOAuth2UserService { @Autowired private UserService userService; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); try { oAuth2User = processOAuth2User(oAuth2User,userRequest); } catch (Exception e) { log.error("processOAuth2User error {}",e); } return oAuth2User; } private OAuth2User processOAuth2User(OAuth2User oAuth2User,OAuth2UserRequest userRequest) { String clientId = userRequest.getClientRegistration().getRegistrationId(); if (clientId.equalsIgnoreCase("github")) { Map<String, Object> map = oAuth2User.getAttributes(); String login = map.get("login")+"_oauth_github"; String name = (String) map.get("name"); String avatarUrl = (String) map.get("avatar_url"); User user = userService.findByEmail(login); if (user == null) { user = new User(); user.setUsername(name); user.setEmail(login); user.setAvatar(avatarUrl); user.setPassword("123456"); userService.insert(user); }else { user.setUsername(name); user.setAvatar(avatarUrl); userService.update(user); } return UserPrincipal.create(user, oAuth2User.getAttributes()); }else if (clientId.equalsIgnoreCase("google")){ Map<String, Object> result = oAuth2User.getAttributes(); String email = result.get("email")+"_oauth_google"; String username = (String) result.get("name"); String imgUrl = (String) result.get("picture"); User user = userService.findByEmail(email); if (user == null){ user = new User(); user.setEmail(email); user.setPassword("123456"); user.setAvatar(imgUrl); user.setUsername(username); userService.insert(user); }else { user.setUsername(username); user.setAvatar(imgUrl); userService.update(user); } return UserPrincipal.create(user,oAuth2User.getAttributes()); } return null; } }
重写UserDetails
public class UserPrincipal implements OAuth2User,UserDetails { private long id; private String name; private String password; private boolean enable; private Collection<? extends GrantedAuthority> authorities; private Map<String,Object> attributes; UserPrincipal(long id,String name,String password,boolean enable,Collection<? extends GrantedAuthority> authorities){ this.id = id; this.name = name; this.password = password; this.authorities = authorities; this.enable = enable; } public static UserPrincipal create(User user){ return new UserPrincipal(user.getId(),user.getEmail() ,user.getPassword(),user.isEnable(),Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))); } public static UserPrincipal create(User user, Map<String, Object> attributes) { UserPrincipal userPrincipal = UserPrincipal.create(user); userPrincipal.attributes = attributes; return userPrincipal; } @Override public String getPassword() { return this.password; } @Override public String getUsername() { return name; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return this.enable; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return this.authorities; } @Override public Map<String, Object> getAttributes() { return this.attributes; } @Override public String getName() { return String.valueOf(this.id); } }
设置Spring Security OAuth2 Client
@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomOAuth2UserService customOAuth2UserService; @Override protected void configure(HttpSecurity http) throws Exception { http.oauth2Login() .clientRegistrationRepository(clientRegistrationRepository()) .authorizedClientService(authorizedClientService()) .userInfoEndpoint() .userService(customOAuth2UserService) .and() .defaultSuccessUrl("/static/html/jetty-chat.html"); } }
默认授权端点,点击后直接重定向到授权服务器的登录页面,Spring 默认是: oauth2/authorization/{clientId}
默认授权成功跳转url: /login/oauth2/code/{clientId}
这个项目参考的教程:
https://www.baeldung.com/spring-security-5-oauth2-login
https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-1/这个教程只展示了一部分的代码,想查看完整的项目代码,可以去 github: spring-stomp-security-webflux-embedded-jetty 查看