使用Spring框架各个组件实现一个在线聊天网页,当有用户连接WebSocket,服务器监听到用户连接会使用Stomp推送最新用户列表,有用户断开刷新在线列表,实时推送用户聊天信息。引入Jetty服务器,直接嵌入整个工程可以脱离Java Web容器独立运行,使用插件打包成一个jar文件,就像Spring Boot一样运行,部署。
pom.xml 依赖
UTF-8
UTF-8
UTF-8
9.4.8.v20171121
5.0.4.RELEASE
2.9.4
1.16.18
1.4.196
1.7.25
5.0.3.RELEASE
1.2.3
5.15.0
org.eclipse.jetty
jetty-servlet
${jetty.version}
org.eclipse.jetty.websocket
websocket-server
${jetty.version}
org.eclipse.jetty.websocket
websocket-api
${jetty.version}
org.springframework
spring-webmvc
${spring.version}
org.springframework
spring-jdbc
${spring.version}
org.springframework
spring-webflux
${spring.version}
org.springframework
spring-websocket
${spring.version}
org.springframework
spring-messaging
${spring.version}
org.springframework.security
spring-security-web
${spring.security.version}
org.springframework.security
spring-security-config
${spring.security.version}
org.springframework.security
spring-security-oauth2-client
${spring.security.version}
org.springframework.security
spring-security-oauth2-jose
${spring.security.version}
org.springframework.security
spring-security-messaging
${spring.security.version}
com.fasterxml.jackson.core
jackson-core
${jackson.version}
com.h2database
h2
${dbh2.version}
com.fasterxml.jackson.core
jackson-databind
${jackson.version}
org.projectlombok
lombok
${lombok.version}
org.slf4j
jcl-over-slf4j
${jcl.slf4j.version}
ch.qos.logback
logback-classic
${logback.version}
org.apache.activemq
activemq-broker
${activemq.version}
io.projectreactor.ipc
reactor-netty
0.7.2.RELEASE
1. 配置H2 嵌入式数据库
@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数据库配置基本已经设置完成了
2. Spring MVC配置
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> converters) {
converters.add(new MappingJackson2HttpMessageConverter());
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
//添加静态路径映射
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
}
}
3. Jetty嵌入式服务
因为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;
}
3. 配置Spring Security
默认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 监听器 限制只允许一个用户登录
4. 配置WebSocketStompConfig
@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 {
@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{
@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 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 onlineUsers(){
return maps.values();
}
}
4. Spring Security OAuth2 Client 配置
手动配置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 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 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 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 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 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查看