使用Spring框架各個組件實現一個在線聊天網頁,當有用戶鏈接WebSocket,服務器監聽到用戶鏈接會使用Stomp推送最新用戶列表,有用戶斷開刷新在線列表,實時推送用戶聊天信息。引入Jetty服務器,直接嵌入整個工程能夠脫離Java Web容器獨立運行,使用插件打包成一個jar文件,就像Spring Boot同樣運行,部署。
pom.xml 依賴
html
<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 內置的嵌入式數據庫的數據源模板,建立的數據源,比較簡單,可是這種方式不支持定製,數據只能保存在內存中,項目重啓數據就會丟失了。前端
設置數據保存到硬盤java
@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 腳本用於初始化或清理數據庫react
@Bean public ResourceDatabasePopulator databasePopulator() { ResourceDatabasePopulator populator = new ResourceDatabasePopulator(); populator.addScript(schema); populator.addScripts(data); populator.setContinueOnError(true); return populator; }
設置DatabasePopulator 對象,用戶數據源啓動或者消耗的時候執行腳本git
@Bean public DataSourceInitializer initializer() { DataSourceInitializer initializer = new DataSourceInitializer(); initializer.setDatabasePopulator(databasePopulator()); initializer.setDataSource(dataSource()); return initializer; }
啓用H2 web Consolegithub
@Bean(initMethod = "start",destroyMethod = "stop") public Server DatasourcesManager() throws SQLException { return Server.createWebServer("-web","-webAllowOthers","-webPort","8082"); }
瀏覽器打開 http://localhost:8082
訪問H2 控制檯web
設置事務管理器ajax
@Bean public PlatformTransactionManager transactionManager() { PlatformTransactionManager manager = new DataSourceTransactionManager(dataSource()); return manager; } }
到這裏,嵌入H2數據庫配置基本已經設置完成了spring
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
引入其餘的配置類sql
@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查看