ES重构搜房网
一、es相关技术
强力技术组合技 = ElasticSearch + MySQL + Kafka
强强联合 = ElasticSearch + 百度地图
ElasticSearch生产环境优化经验
负载加安全 = ElasticSearch + Nginx
传说中的数据分析神器 ELK = ElasticSearch + Logstash +
Kibana
数据库的常青树 = MySQL + Spring Data JPA
前端核心技术框架 = thymeleaf + Bootstrap + jQuery
项目安全框架 = Spring Security
图片上传 = 七牛云 + webUpload
1、系统架构
2、课程收获
了解一个中度复杂规模的应用开发流程
掌握ElasticSearch的高级业务应用
熟悉ES与其他技术框架的应用结合思路与技巧
掌握ES的相关优化技巧及扩展应用
熟悉完整的搜房网业务,提升技术应用能力
二、技术选型介绍
1、数据库技术选型介绍
MySQL :MySQL是当前最流行的关系型数据库,在互联网公司MySQL也是应用最多的关系型数据库。
ElasticSearch :ElasticSearch是基于Apache
Lucene的开源搜索引擎。
ElasticSearch Vs MySQL :
利用ES方便实现站内搜索引擎
利用MySQL的事务特性做稳定的数据存储
以MySQL做基础数据存储,结合ES实现站内搜索引擎
三、需求分析
项目背景
目标用户
项目可行性
四、数据库设计
ER图
1、基础表介绍
image-20220729182048729
其中关于两个表之前的关联,最好使用逻辑上的外键连接,少用数据库中的外键连接,在分表分库的时候会依赖数据库的一些操作,有一定的依赖性。慎用数据库特性,分表的时候将会是一个灾难。
2、表结构设计原则
减少中间表的设计
保证表表之间没有耦合
五、环境要求
Java环境: JDK1.8
构建工具: Maven
常用IDE:(IDEA、Eclipse等)
六、后端框架搭建
SpringBoot
Spring Data JPA + Hibernate
1、新建config工具包,创建JpaConfig类
image-20220729183149365
加入注解@Configuration。
加入@EnableJpaRepositories让其可以扫描到我们的repository Dao类。
加入@EnableTransactionManagement 允许事务管理。
新建
1 2 3 4 5 6 @Bean @ConfigurationProperties(prefix = "spring.datasource") public DataSource dataSource () { return DataSourceBuilder.create().build(); }
在application.properties文件中增加mysql信息:
1 2 3 4 spring.datasource.driver-class-name=com.mysql.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/xunwu?useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=123456789
另外设置实体类的管理工厂
LocalContainerEntityManagerFactoryBean,实例化Hibernate,因为jpa实现的是hibernate,所以要选择HibernateJpaVendorAdapter,然后设置jpaVendor.setGenerateDdl为false,设置其不会自动生成sql,因为要把sql权掌握在自己的手里。
再=实例化实体映射管理工厂类LocalContainerEntityManagerFactoryBean。对该工厂类设置一些属性,setDataSource,setJpaVendorAdapter(jpaVendorAdapter),setPackagesToScan(实体类的包名),最后返回新建的实体类映射管理工厂bean。
1 2 3 4 5 6 7 8 9 10 @Bean public LocalContainerEntityManagerFactoryBean entityManagerFactory () { HibernateJpaVendorAdapter japVendor = new HibernateJpaVendorAdapter (); japVendor.setGenerateDdl(false ); LocalContainerEntityManagerFactoryBean entityManagerFactory = new LocalContainerEntityManagerFactoryBean (); entityManagerFactory.setDataSource(dataSource()); entityManagerFactory.setJpaVendorAdapter(japVendor); entityManagerFactory.setPackagesToScan("com.zryy.soufangtest.entity" ); return entityManagerFactory;
再去新建一个com.zryy.soufangtest.entity实体类包。
在JPAConfig类下我们添加了一个事务管理的注解,所以我们需要新建一个事务管理的类PlatformTransactionManager。传的参数是实体映射管理工厂EntityManagerFactory,在内部中实例化一下事务管理类JpaTransactionManager,把实体映射管理工厂当参数传进去。最后返回事物管理类TransactionManager。
1 2 3 4 5 6 @Bean public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { JpaTransactionManager transactionManager = new JpaTransactionManager(); transactionManager.setEntityManagerFactory(entityManagerFactory); return transactionManager; }
到这里我们的Jpa配置就设置完成了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 /** * Created by zhiqiang */ /*允许事务管理*/ @Configuration @EnableJpaRepositories(basePackages = "com.zryy.soufangtest.repository") @EnableTransactionManagement public class JPAConfig { @Bean /*建立数据源,并设定数据源配置的前缀,需要用到数据源mysql的用户名密码等,在properties文件中增加*/ @ConfigurationProperties(prefix = "spring.datasource") public DataSource dataSource(){ return DataSourceBuilder.create().build(); } @Bean public LocalContainerEntityManagerFactoryBean entityManagerFactory() { HibernateJpaVendorAdapter japVendor = new HibernateJpaVendorAdapter(); japVendor.setGenerateDdl(false); LocalContainerEntityManagerFactoryBean entityManagerFactory = new LocalContainerEntityManagerFactoryBean(); entityManagerFactory.setDataSource(dataSource()); entityManagerFactory.setJpaVendorAdapter(japVendor); entityManagerFactory.setPackagesToScan("com.zryy.soufangtest.entity"); return entityManagerFactory; } @Bean public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { JpaTransactionManager transactionManager = new JpaTransactionManager(); transactionManager.setEntityManagerFactory(entityManagerFactory); return transactionManager; } }
另外在配置文件中我们还要增加两条:
1 2 spring.jpa.show-sql =true #方便我们在开发过程中看到hibernate给我们建立的sql语句。 spring.jpa.hibernate.ddl-auto = validate
正常情况下我们的日志级别是info,但是我们的日志打印级别是debug级别,把sql级别打印调为debug这样才可以正常输出。
1 logging.level.org.hibernate.SQL = debug
运行测试mvn,如果提示“No Spring Session store is configured: Set the
spring.session.store-type property”,则将设置spring会话存储的类型
1 spring.session.store-type = hash_map
如果打开localhost:8080后有验证,则可以设置
1 security.basic.enabled =false
测试在main函数中增加一个@RestController
七、集成单元测试
1、在spring中使用JUnit来进行测试。
2、如何利用H2内存数据库解耦mysql来进行测试。
依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-jpa</artifactId > </dependency > <dependency > <groupId > com.h2database</groupId > <artifactId > h2</artifactId > <scope > test</scope > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency >
建数据表,存入sql数据。
或者新建entity包,新建实体类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Entity @Table(name = "user") public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String password; private String email; @Column(name = "phone_number") private String phoneNumber;
其中因为要兼顾hibernate和h2所以主键的自增方式不能写为GenerationType.auto,修改为Identity
在实体类中定义的变量参数一般都是驼峰的,而在数据库中一般定义的是下划线的,所以添加column注解。给他的orm-Mapping做一个映射。另外我们定义的实体类首字母是大写的,但是在sql中表是小写的,所以也要添加@Table(name
=“ ”)注解。
再定义userRepository 也就是jpa操作类,是一个接口,并且实现Crud
Repository<xx,xx>。 第一个是我们自己定义的实体类,第二个参数。
我们在单元测试中写一个单独的测试类去继承系统给的测试类
1 2 3 4 5 6 7 8 9 10 public class UserRepositoryTest extends SoufangTestApplicationTests { @Autowired private UserRepository userRepository; @Test public void testFindOne() { User user = userRepository.findOne(1L); Assert.assertEquals("wali", user.getName()); } }
在实际开发过程中,会脱离mysq进行测试,而使用h2内存数据库。
做一下配置分离。新建:application-test.properties /
application-dev-properties
然后在在application.properties文件中添加spring.profiles.active = dev
进行激活。
可以把一些通用的配置放在application.properties中,一些个性化的放在单独的文件中。
在-test配置文件中加入
1 spring.datasource.driver-class-name =org.h2.Driver
如果上面步骤直接执行的话,并不会走测试配置,需要配置一个注解@ActiveProfiles("test"),
/加这个注解就会走application-test.properties /
在resources文件夹下新建db文件夹,里面包含两个文件,一个是结构体,另外一个是表数据文件。
然后在-test.properties中指定目录:
1 2 spring.datasource.schema =classpath:db/schema.sql spring.datasource.data =classpath:db/data.sql
再运行测试文件就ok通过了。
八、前端集成
1、集成thymeleaf及基本用法
添加依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-thymeleaf</artifactId > </dependency > <dependency > <groupId > org.thymeleaf</groupId > <artifactId > thymeleaf</artifactId > <version > ${thymeleaf.version}</version > </dependency > <dependency > <groupId > org.thymeleaf</groupId > <artifactId > thymeleaf-spring4</artifactId > <version > 3.0.2.RELEASE</version > </dependency >
注意thymeleaf的版本做个一个覆盖,在properties标签下进行对springboot所依赖的版本进行了覆盖:
1 2 3 4 <thymeleaf.version > 3.0.3.RELEASE</thymeleaf.version > <thymeleaf-layout-dialect.version > 2.1.1</thymeleaf-layout-dialect.version > <thymeleaf-extras-springsecurity4.version > 3.0.2.RELEASE</thymeleaf-extras-springsecurity4.version >
并进行相关的配置:
新建WebMvcConfig类
,并且对其继承WebMvcConfigurerAdapter类,并实现ApplicationContextAware,实现的该接口可以帮助我们获取spring的一个上下文。
先私有化一个类,把这个对象持久化一下。
1 private ApplicationContext applicationContext
然后在覆盖的set方法里面,把这个变量给set进去,赋值。
然后对模板资源进行解析(模版资源解析器)
1 2 3 4 5 6 7 8 @Bean public SpringResourceTemplateResolver templateResolver () { #新建一个 SpringResourceTemplateResolver templateRosolver = new SpringResourceTemplateResolver (); #然后设置spring的application上下文 templateRolver.setApplicationContext(this .applicationContext); return templateRolver; }
另外一个呢,还有一个thymeleaf标准方言解释器。
1 2 3 4 5 6 7 8 9 10 @Bean public SpringTemplateEngine templateEngine () { SpringTemplateEngine templateEngine = new SpringTemplateEngine (); templateEngine.setTemplateResolver(templateResolver) templateEngine.setEnableSpringELCompiler(true ); return templateEngine; }
在上面还定义springSecurity方言,这里不做设置,在下文中会进行介绍。
除此之外还有一个视图解析器。
1 2 3 4 5 6 @Bean public ThymeleafViewResolver viewResolver (templateEngine) { ThymeleafViewResolver viewResolver = new ThymeleafViewResolver (); viewResolver.setTemplateEngine(templateEngine()); return vierResolver; }
到目前为止thymeleaf的搜索引擎基本搭建完毕。
另外呢还需要在配置文件中进行一些其他的配置,因为模版内容是经常进行变化的,thymeleaf在运行中默认是开启缓存的,会使得修改的内容不能够得到及时的反馈。可以设置其不能进行缓存。
1 spring.thymeleaf.cache = false
另外还有一个通用的配置:
1 spring.thymeleaf.mode =HTML
thymeleaf默认的是HTML5,不过现在已经废弃了,这个是必备的,用HTML。
另外还可以在配置文件中配置thymeleaf模版的前缀、后缀。
1 2 spring.thymeleaf.suffix = .html. spring.thymeleaf.prefix =classpath:/templates/
小技巧:可以通过命令行mvn spring-boot:run方式进行启动
当然也可以通过IDEA的快捷方式来启动:
Command line: clean package spring-boot:run
-Dmaven.test.skip=true
//-Dmaven.test.skip=true 是跳过测试类
有时候启动程序先加载再编译时间速度非常缓慢。可以使用SpringBoot自带热加载开发工具:
1 2 3 4 5 6 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-devtools</artifactId > <scope > runtime</scope > </dependency >
还要在IDEA中修改一些配置才可以生效,注意设置好之后需要重启IDEA才可以生效,这里不做介绍更多自行查阅。
如果提示:
1 Could not open ServletContext resource [/index]
那么就在模版资源解析器中添加配置注解:
1 @ConfigurationProperties(prefix = "spring.thymeleaf")
这样就可以了。
新建一个controller添加测试:
1 2 3 4 5 @GetMapping("/index2") public String index2 (Model model) { model.addAttribute("name" ,"zhiqiang" ); return "index2" ; }
并且新建一个html页面:
1 2 3 4 5 6 7 8 9 10 11 12 <!DOCTYPE html > <html xmlns:th ="http://www.thymeleaf.org" > <head > <meta charset ="UTF-8" > <title > Title</title > </head > <body > <h1 > Hello ,zhiqiang</h1 > <span th:text ="${name}" > </span > </body > </html >
image-20220802111018198
另外,加载一些静态资源:
按照该目录结构进行部署,并且在WebMvcConfig中需要单独进行配置:
1 2 3 4 5 6 7 8 @Override public void addResourceHandlers (ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**" ).addResourceLocations("classpath:/static/" ); }
增加处理路径和静态资源绝对路径。意味着我们只要在静态资源里面加入/static/**前缀就会到classpath:/static/路径下找到相关的静态资源文件。
2、集成Bootstrap
3、集成jQuery
九、架构设计与分层
结构分层
经典的三层架构:
表示层 ——web
业务逻辑层——service
数据层——entity、repository
十、API结构设计
RestfulApi的结构设计
自定义API返回的标准
code 自定义请求状态编码
message自定义请求响应信息描述
data请求目标数据
新建一个base文件包,存储一些基础的结构体,比如:ApiResponse
另外我们在class ApiResponse定义了一个内部允许类 Status
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class ApiResponse { private int code; private String message; private Object data; private boolean more; public enum Status { SUCESS(200 ,"ok" ), BAD_REQUEST(400 , "Bad Request" ), INTERNAL_SERVER_ERROR(500 ,"Unknown Internal Error" ), NOT_VALID_PARAM(40006 ,"Operation not supported" ), NOT_LOGIN(50000 ,"Not Login" ); private int code; private String standardMessage; Status(int code, String standardMessage) { this .code = code; this .standardMessage = standardMessage; } } }
并且可以创建一个构造器,而有的api只是想确认一下状态,这时候可以创建一个空的构造器
1 2 3 4 5 6 7 8 9 10 public ApiResponse (int code, String message, Object data) { this .code = code; this .message = message; this .data = data; } public ApiResponse () { this .code = Status.SUCCESS.getCode(); this .message = Status.SUCCESS.getStandardMessage(); }
以及设计三个静态方法类:
1 2 3 4 5 6 7 8 9 10 11 public static ApiResponse ofMessage (int code ,String message) { return new ApiResponse (code,message,null ); } public static ApiResponse ofSuccess (Object data) { return new ApiResponse (Status.SUCCESS.getCode(), Status.SUCCESS.getStandardMessage(), data); } public static ApiResponse ofStatus (Status status) { return new ApiResponse (status.getCode(),status.getStandardMessage(),null ); }
测试:
1 2 3 4 5 @GetMapping("/get") @ResponseBody public ApiResponse apiget () { return ApiResponse.ofMessage(200 ,"成功了" ); }
返回结果如下:
image-20220802172936208
十一、异常拦截器
1、首先,为什么要添加异常拦截器?
会有很多我们想象不到的情况,比如说用户页面访问接口异常,用户访问不存在的页面
,用户的权限不如等等。
2、我们主要实现两个异常拦截器:
1、页面异常拦截器 404 403
500页面等。
2、API异常拦截器
同样是拦截404 403 500等情况。
比如我们输入localhost:8000/xxx就会弹出 Whitelabel Error
Page页面,这个是springboot自带的一个页面。
在配置文件中修改并进行优化:
1 server.error.whitelabel.enabled =false
并且在base包中新建一个AppErrorController类
继承ErrorController类。
设置一个静态变量ERROR_PATH = "/error"
并且覆盖类 getErrorPath()方法类,return ERROR_PATH。
并且将errorAttributes新建个内部变量存储起来。
1 2 3 4 @Autowired public AppErrorController (ErrorAttributes errorAttributes) { this .errorAttributes = errorAttributes; }
并且新建一个Web页面错误处理类errorhandler
上面添加注解@RequestMapping(value 接收 定义的Error_path,
并且produces= “text/html“)
Web页面错误处理类errorhandler接收的参数是HttpServerRequest
和HttpServerletResponse
{
获取到response的状态
int status = response.getStatus() 然后switch进行判断
并return ”404“到该页面,等等
如果什么都没匹配到,就返回index页面。
}
因为在方法上面使用的是requestMapping,所以也要在类上面增加@controller注解。
以上定义的是web页面的拦截处理,下面定义api的拦截处理:
除web页面外的错误处理,比如Json/xml等,那么就不需要像web页面拦截器一样声明produces
= "xxx“了,因为是除了web页面以外的信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @RequestMapping(value = ERROR_PATH) @ResponseBody public ApiResponse errorApiHandler (HttpServletRequest request) { 然后需要定义一个RequestAttributes实例,把类参数传入进去。 RequestAttributes requestAttributes = new ServletRequestAttributes (request) 需要获取到请求返回的状态 Map<String,Object> attr = this .errorAttributes.getErrorAttributes(requestAttributes, 第二个参数是includeStackTrance,取值为false ) 使用一个map来接收。 想要获取到状态需要单独写一个函数getStatus() private int getStatus (HttpServletRequest request) { Integer status = (Integer) request.getAttribute("javax.servlet.error.status_code" ); if (status != null ){ return status; } return 500 ; } 然后返回函数中,使用 int status = getStatus(request);获取到状态。 获取到状态后,直接使用定义好的ApiResponse.ofMessage(status, 并且在返回信息这里使用getOrDefault方法, String.valueOf(attr.getOrDefault("message" ,"error" ))) }
如果正确请求的话,就会直接进入到controller中对应RequestMapping下的函数中去。
如果错误请求的话,内部会检测到匹配不到的错误请求path,然后在AppErrorController中赋值给ERROR_PATH,然后通过this.errorAttributes.getErrorAttributes(requestAttributes,false);去获取到javax.servlet.error.status_code对应的状态码,以及对应的error_message。最后包装在我们定义好的ApiResponse.ofMessage中去。
十二、功能性页面开发
403:权限限制性页面
404:Not found 提示页面
500:异常服务提示页面
另外,springdevTools是在整个项目进行监听的,如果在开发前端的过程中引发一些热加载其实是没有必要的,因为我们设置了thymeleaf的缓存cache为false,只需要一行配置即可生效,
spirng.devtools.restart.exclude=templates/** ,static/**
这样对静态资源的修改就不会引发热加载了
当thymeleaf出现乱码的情况下,只需要在templateResolver中进行设置就可以。
1 templateResolver.setCharacterEncoding("UTF-8" )
这样就解决了thymeleaf前端乱码的问题了。
十三、后台管理模块
业务与功能分析:
为了方便网站运营人员管理租房网站 的房源信息,就需要有后台管理系统。开发一个后台管理模块来管理租房网站的数据、人员信息等等
首先在webcontroller下面新建一个admin的文件夹,并且新建一个AdminController控制类,新建两个getMapping方法。
1 2 3 4 5 6 7 8 9 @GetMapping("/admin/center") public String adminCenterPage () { return "admin/center" ; } @GetMapping("/admin/welcome") public String welcomePage () { return "admin/welcome" ; }
并且把admin/center页面添加进去。
十四、后台登录功能模块实现
在后台管理页面上有一个展示目前登录账户名称,就需要一个登录管理模块,那么就需要查询数据库,就需要用到hibernate,所以需要在WebMvcConfig中SpringTemplateEngine(tyhmeleaf标准方言解释器)中增加支持springSecurity方言。
1 2 3 SpringSecurityDialect securityDialect = new SpringSecurityDialect (); templateEngine.addDialect(securityDialect);
并且在pom.xml中加入web
SpringSecurity依赖和thymeleafSecurity依赖,并且制定thymeleafsecurity依赖的固定版本3.0.2
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency > <dependency > <groupId > org.thymeleaf.extras</groupId > <artifactId > thymeleaf-extras-springsecurity4</artifactId > <version > ${thymeleaf-extras-springsecurity4.version}</version > </dependency > <properties > <thymeleaf-extras-springsecurity4.version > 3.0.2.RELEASE</thymeleaf-extras-springsecurity4.version > </properties >
并且新建一个类,WebSecurityConfig
继承WebSecurityConfigurerAdapter
并且在类上面添加两个注解:
@EnableWebSecurity
@EnableGlobalMethodSecurity
再然后复写一下继承类的方法:configure(接收的参数是HttpSecurity
http)的。
里面就是http权限控制的内容了,比如设置页面的权限、api的角色权限等等。
即增加:
http.authorizeRequests().antMatchers("/admin/login").permitAll();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @EnableWebSecurity @EnableGlobalMethodSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/admin/login" ).permitAll() .antMatchers("/static/**" ).permitAll() .antMatchers("/user/login" ).permitAll() .antMatchers("/admin/**" ).hasRole("ADMIN" ) .antMatchers("/user/**" ).hasAnyRole("ADMIN" ,"USER" ) .antMatchers("/admin/user/**" ).hasAnyRole("ADMIN" ,"USER" ) .and() .formLogin() .loginProcessingUrl("/login" ) .defaultSuccessUrl("/admin/center" ,true ).permitAll() .failureHandler(authFailHandler()) .and() .logout() .logoutUrl("/logout" ) .logoutSuccessUrl("/logout/page" ) .deleteCookies("JSESSIONID" ) .invalidateHttpSession(true ) .and() .exceptionHandling() .authenticationEntryPoint(urlEntryPoint()) .accessDeniedPage("/403" ); http.csrf().disable(); http.headers().frameOptions().sameOrigin(); }
在我们设置了loginProcessingUrl("/login")后,springSecurity默认的登录界面是这样的:
所以我们需要在controller中去配置/admin/login函数并且return
/admin/login
另外我们还需要去关闭两个设置:
http.csrf().disable();
//csrf是一个防御策略,为了方便开发我们这里关闭
http.headers().frameOptions().sameOrigin()。
h-ui是使用iframe开发的,所以我们要设置同源策略。
并在还需要另外设置自定义认证策略:
1 2 3 4 5 6 @Autowired public void configGlobal (AuthenticationManagerBuilder auth) { auth.inMemoryAuthentication().withUser("admin" ).password("admin" ).roles("ADMIN" ).and(); }
测试admin/login,登录成功之后默认跳转到首页。
但是呢我们的认证数据是从内存中定义的,这里我们需要从数据库中进行替换认证的数据及逻辑。
另外呢需要单独新建一个security的包,负责存放一些关于安全认证的代码。
然后在securiyty包里AuthProvider(自定义认证实现)实现AuthenticationProvider,然后实现两个覆盖类:authenticate和supports。
其中supports我们设置默认返回true,支持所有的权限认证,在authenticate中去实现我们对用户的一些详细的认证。
在athenticate方法中authentication.getName() getCredentials()
去获取用户名和输入的密码。
这时候需要新建一个service包用来存放接口
IUserService,新建一个findUserByName方法,
然后新建一个实现类UserServiceImpl
实现IUserService,然后实现findUserByName方法,并且新建一个UserRepository来查询数据库。
但是UserRepository虽然继承了CrudRepository但是并没有findByName,需要在UserRepository中去定义。
1 2 3 4 public interface UserRepository extends CrudRepository <User,Long>{ User findByName (String username) ; }
然后这时候才可以在UserServiceImpl中使用repository的findByName()
这样的话我们就可以在AuthProvider中使用UserService了,需要@autowired,注入之后就可以使用其从数据库中查出我们的用户名和密码来了。
获取到用户名后,需要添加一个if判断,如果为null的话直接throw一个
AuthenticationCredentialsNotFoundException("authError")错误。
然后就可以验证inputPassword和数据库里的密码做比对了/。
1 user.getPassword().equals(inputPassword)
但是这样的话就容易在逻辑代码中暴露用户的密码了,所以要使用md5加密。
1 private final Md5PasswordEncoder passwordEncoder = new Md5PasswordEncoder ()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 if (this .passwordEncoder.isPasswordValid(user.getPassword(),inputPassword,user.getId())){ return new UsernamePasswordAuthenticationToken (user, null , user.getAuthorities()); } @Transient private List<GrantedAuthority> authorityList; @Override public boolean isAccountNonExpired () { return true ; } @Override public boolean isAccountNonLocked () { return true ; } @Override public boolean isCredentialsNonExpired () { return true ; } @Override public boolean isEnabled () { return true ; } @Override public Collection<? extends GrantedAuthority > getAuthorities() { return this .authorityList; } throw new BadCredentialsException ("authError" );
并且在WebSecurityConfig类的configGlobal()方法中我们之前定义的是在内存中设定一个用户admin/admin
现在因为我们自己设定了一个AuthProvider类,所以呢我们就先建立一个@bean
1 2 3 4 @Bean public AuthProvider authProvider () { return new AuthProvider (); }
然后在configGlobal中auth.authenticationProvider(authProvider()).eraseCredentials(true);
//并且设置擦除密码验证。
十五、验证失败逻辑处理
authProvider实现了自定义登录功能。下面实现基于springSecurity的权限控制。
比如说我们要根据不同的请求去做不同的跳转页面控制,比如普通用户登录就跳转到普通用户的登录界面,管理员就跳转到管理员登录的登录页面。
我们新建LoginAuthFailHandler并且继承simpleUrlAuthenticationFailureHandler
并且定义一个属性 ,LoginUrlEntryPoint
用来跳转到需要跳转的url并附带一些其他信息。
并且实现一个覆盖类onAuthenticationFailure
里面方法写this.urlEntryPoint.determineUrlToUseForThisRequest(request,response,exception),并且返回的是targetUrl。
接下来对我们获取到的targetUrl进行处理:
1 targetUrl += “?” +exception.getMessage()
然后再用父类的一个方法,跳转到targetUrl。
1 super .setDefaultFailureUrl(targetUrl);
执行父级的跳转逻辑:
1 super .onAuthenticationFailure(request,response,exception);
这个时候呢,验证失败处理器LoginAuthFailHandler就配置完成了,但是还要设置另外一个地方,那就是WebSecurityConfig中设置failureHandler(authFailHandler())
authFailHandler是新建的一个Bean类
1 2 3 4 @Bean public LoginAuthFailHandler authFailHandler () { return new LoginAuthFailHandler (urlEntryPoint()) }
十六、房源信息管理模块
业务与功能分析:
已经搭建了后台管理模块的框架,另外还需要完善网站的房源信息管理子模块,那么接下来就要实现房源信息的增删改查。
实现目标:
新增房源
房源信息管理(查、改、删)
房源审核(一些完善性的工作)
十七、基于七牛云的图片上传(上传到本地)
图片上传功能:
七牛依赖:
1 2 3 4 5 <dependency > <groupId > com.qiniu</groupId > <artifacId > qiniu-java-sdk</artifactId > <version > [7.2.0, 7.2.99]</version > </dependency >
前端使用的是百度开源的webuploader。
后段需要在AdminController中定义:
1 2 3 4 5 6 @PostMapping(value = "admin/upload/photo", consumes = MediaType.MULTIPART_FROM_DATA_VALUE) @ResponseBody public ApiResponse uploadPhoto (@RequestParam("file") MultipartFile file) { return ApiResponse.ofSuccess(null ) }
另外新建一个文件上传配置类
1 2 3 4 5 6 @Configuration @ConditionalOnClass({Servlet.class, StandardServletMultipartResolver.class, MultipartConfigElement.class}) @ConditionalOnProperty(prefix = "spring.http.multipart", name = "enabled", matchIfMissing = true) @EnableConfigurationProperties(MultipartProperties.class) public class WebFileUploadConfig {}
另外呢还要去properties类中设置mutipartProperties multipart config
1 2 3 4 spring.http.multipart.enabled=true spring.http.multipart.location=/Users/zhiqiang/Downloads/zq/有趣的项目/soufang-test/tmp spring.http.multipart.file-size-threshold=5MB spring.http.multipart.max-file-size=20MB
然后继续完善类里面的配置,因为我们让springboot自动配置了MultipartProperties.class类,所以我们要定义一个MultipartProperties
变量。
然后新建一个WebFileUploadConfig类去注入这个属性
1 2 3 4 5 private final MultipartProperties multipartProperties; public WebFileUploadConfig (MultipartProperties multipartProperties, MultipartProperties multipartProperties1) { this .multipartProperties = multipartProperties; }
另外还需要创建一个上传配置类:
1 2 3 4 5 6 7 8 9 @Bean @ConditionalOnMissingBean public MultipartConfigElement multipartConfigElement () { return this .multipartProperties.createMultipartConfig(); } }
另外还需要创建一个注册解析器:
1 2 3 4 5 6 7 8 9 10 11 12 @Bean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME) @ConditionalOnMissingBean(MultipartResolver.class) public StandardServletMultipartResolver multipartResolver () { StandardServletMultipartResolver multipartResolver = new StandardServletMultipartResolver (); multipartResolver.setResolveLazily(this .multipartProperties.isResolveLazily()); return multipartResolver; }
创建完毕后需要再完善一下webUpload的接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @PostMapping(value = "admin/upload/photo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseBody public ApiResponse uploadPhoto (@RequestParam("file") MultipartFile file) { if (file.isEmpty()){ return ApiResponse.ofStatus(ApiResponse.Status.NOT_VALID_PARAM); } String fileName = file.getOriginalFilename(); File target = new File ("/Users/zhiqiang/Downloads/zq/tmp/" +fileName); try { file.transferTo(target); } catch (IOException e) { e.printStackTrace(); return ApiResponse.ofStatus(ApiResponse.Status.INTERNAL_SERVER_ERROR); } return ApiResponse.ofSuccess(null ); }
这样图片上传就到了我们的本地/Users/zhiqiang/Downloads/zq/tmp/了。
十八、基于七牛云的图片上传(上传到七牛云)
配置七牛云服务器直传,需要设置一个Bean类去配置qiniuConfig
1 2 3 4 5 6 7 8 @Bean public com.qiniu.storage.Configuration qiniuConfig () { return new com .qiniu.storage.Configuration(Zone.zone1()); }
并且设置一个七牛云上传工具管理类,把上面设置的配置类当作参数传入进去。
另外七牛云还需要配置AccessKey, SecretKey和Bucket
另外,七牛云新建对象存储后会有30天的临时测试域名。
image-20220810152446672
并且在properties文件中定义:
1 2 3 4 5 # qiniu config qiniu.AccessKey=FQ_nf-hE1Bu7ZE-ffCgkSxQnbUGXhBGgT0UVwP-J qiniu.SecretKey=mPHwqW5mYIp3Wgdj2vFvWAATN8NBgwVo0rHduz44 qiniu.Bucket=soufang-zryy qiniu.cdn.prefix=http:
然后在WebFileUploadConfig文件中以注解的方式注入:
1 2 3 4 5 @Value("${qiniu.AccessKey}") private String accessKey;@Value("${qiniu.SecretKey}") private String secretKey;
然后利用这两个变量来生成认证信息:
1 2 3 4 5 6 7 8 @Bean public Auth auth () { return Auth.create(accessKey,secretKey); }
并且构建七牛云空间管理实例,把认证类auth当作参数传入,并且把qiniuConfig传入:
1 2 3 4 5 6 7 @Bean public BucketManager bucketManager () { return new BucketManager (auth(),qiniuConfig()); }
这样配置文件弄好了,就可以去设计业务了。从设计业务角度来讲,从顶层由上自下设计是合适的。从开发流程来说,从数据库建表开始进行开始是最为合适的。
新建IQiNiuService接口:
1 2 3 4 5 6 7 8 9 10 11 public interface IQiNiuService { Response uploadFile (File file) throws QiniuException; Response uploadFile (InputStream inputStream) throws QiniuException; Response delete (String key) throws QiniuException; }
然后新建QiNiuServiceImpl实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 @Service public class QiNiuServiceImpl implements IQiNiuService , InitializingBean { @Autowired private UploadManager uploadManager; @Autowired private BucketManager bucketManager; @Autowired private Auth auth; @Value("${qiniu.Bucket}") private String bucket; private StringMap putPolicy; @Override public Response uploadFile (File file) throws QiniuException { Response response = this .uploadManager.put(file,null ,getUploadToken()); int retry = 0 ; while (response.needRetry() && retry < 3 ){ response = this .uploadManager.put(file,null ,getUploadToken()); retry++; } return response; } @Override public Response uploadFile (InputStream inputStream) throws QiniuException { return null ; } @Override public Response delete (String key) throws QiniuException { return null ; } @Override public void afterPropertiesSet () throws Exception { this .putPolicy = new StringMap (); putPolicy.put("returnBody" , "{\"key\":\"$(key)\",\"hash\":\"$(etag)\",\"bucket\":\"$(bucket)\",\"width\":$(imageInfo.width), \"height\":${imageInfo.height}}" ); } private String getUploadToken () { return this .auth.uploadToken(bucket,null ,3600 , putPolicy); }
其中自动配置@Autowired upLoadManager类,
uploadManager.put传的参数是file、null、以及return
this.auth.uploadToken(bucket,null,3600, putPolicy);
其中InitializingBean、afterPropertiesSet当一个类实现这个接口之后,Spring启动后,初始化Bean时,若该Bean实现InitialzingBean接口,会自动调用afterPropertiesSet()方法,完成一些用户自定义的初始化操作。
putPolicy是七牛云固定的returnBody的格式。
最后我们建立一个测试类并且继承之前定义好的SoufangtestApplicationTests
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class QiNiuServiceTests extends SoufangTestApplicationTests { @Autowired private IQiNiuService qiNiuService; @Test public void testUploadFile () { String fileName = "/Users/zhiqiang/Downloads/zq/tmp/664328b2a21a76bd8de7c629eeff7e.jpg" ; File file = new File (fileName); Assert.assertTrue(file.exists()); try { Response response = qiNiuService.uploadFile(file); Assert.assertTrue(response.isOK()); } catch (QiniuException e) { e.printStackTrace(); } } }
测试通过,成功上传到七牛云上面。
image-20220811141223495
另外除了uploadFile外,我们还定义了uploadInputStream。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @PostMapping(value = "admin/upload/photo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseBody public ApiResponse uploadPhoto (@RequestParam("file") MultipartFile file) throws IOException { if (file.isEmpty()){ return ApiResponse.ofStatus(ApiResponse.Status.NOT_VALID_PARAM); } String fileName = file.getOriginalFilename(); try { InputStream inputStream = file.getInputStream(); Response response = iQiNiuService.uploadFile(inputStream); if (response.isOK()){ return ApiResponse.ofSuccess(); }else { return ApiResponse.ofMessage(response.statusCode,response.getInfo()); } } catch (IOException e){ return ApiResponse.ofStatus(ApiResponse.Status.INTERNAL_SERVER_ERROR); } }
不过在return
ApiResponse.ofSuccess的时候,需要定义一个dto来接收,这时候新建一个QiNiuPutRet类,并且定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public final class QiNiuPutRet { public String key; public String hash; public String bucket; public int width; public int height; @Override public String toString () { return "QiNiuPutRet{" + "key='" + key + '\'' + ", hash='" + hash + '\'' + ", bucket='" + bucket + '\'' + ", width=" + width + ", height=" + height + '}' ; } }
而且在WebFileUploadConfig类中定义一个Gson解析json
1 2 3 4 @Bean public Gson gson () { return new Gson (); }
这样呢,就可以在controller中去自动注入Gson了
AdminController ————》
1 2 3 4 @Autowired private Gson gson;gson.fromJson(response.bodyString(), QiNiuPutRet.class);
gson.fromJson需要输入两个参数一个是string类型的json数据,另外一个是对应变量的类。所以需要定义一个dto类QiNiuPutRet.class
另外呢还需要定义一个catch去抓取QiNiu的异常。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 try { InputStream inputStream = file.getInputStream(); Response response = iQiNiuService.uploadFile(inputStream); if (response.isOK()){ QiNiuPutRet ret = gson.fromJson(response.bodyString(), QiNiuPutRet.class); return ApiResponse.ofSuccess(ret); }else { return ApiResponse.ofMessage(response.statusCode,response.getInfo()); } } catch (QiniuException e){ Response response = e.response; return ApiResponse.ofMessage(response.statusCode,response.bodyString()); } catch (IOException e){ return ApiResponse.ofStatus(ApiResponse.Status.INTERNAL_SERVER_ERROR); }
在我们编写完以文件流方式上传图片后,获取到了文件的key:FnJXb0TSb-ImeX1PzxIRo9wc1AkX
然后我们就去实现delete操作:
但是这里需要注意的是delete是操作的BucketManager,而上传使用的是uploadManager
1 2 3 4 5 6 7 8 9 @Override public Response delete (String key) throws QiniuException { Response response = bucketManager.delete(this .bucket, key); int retry = 0 ; while (response.needRetry() && retry++ < 3 ) { response = bucketManager.delete(bucket, key); } return response; }
然后编辑测试类:
1 2 3 4 5 6 7 8 9 10 11 @Test public void deleteFile () throws QiniuException { String key = "FnJXb0TSb-ImeX1PzxIRo9wc1AkX" ; try { Response delete = qiNiuService.delete(key); Assert.assertTrue(delete.isOK()); }catch (QiniuException e){ e.printStackTrace(); } }
最终的QiNiuServiceImpl类的实现代码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 @Service public class QiNiuServiceImpl implements IQiNiuService , InitializingBean { @Autowired private UploadManager uploadManager; @Autowired private BucketManager bucketManager; @Autowired private Auth auth; @Value("${qiniu.Bucket}") private String bucket; private StringMap putPolicy; @Override public Response uploadFile (File file) throws QiniuException { Response response = this .uploadManager.put(file,null ,getUploadToken()); int retry = 0 ; while (response.needRetry() && retry < 3 ){ response = this .uploadManager.put(file,null ,getUploadToken()); retry++; } return response; } @Override public Response uploadFile (InputStream inputStream) throws QiniuException { Response response = this .uploadManager.put(inputStream,null ,getUploadToken(),null ,null ); int retry = 0 ; while (response.needRetry() && retry < 3 ){ response = this .uploadManager.put(inputStream,null ,getUploadToken(),null ,null ); retry++; } return response; } @Override public Response delete (String key) throws QiniuException { Response deleteResponse = bucketManager.delete(this .bucket, key); int retry = 0 ; while (deleteResponse.needRetry() && retry++ <3 ){ deleteResponse = bucketManager.delete(bucket,key); } return deleteResponse; } @Override public void afterPropertiesSet () throws Exception { this .putPolicy = new StringMap (); putPolicy.put("returnBody" , "{\"key\":\"$(key)\",\"hash\":\"$(etag)\",\"bucket\":\"$(bucket)\",\"width\":$(imageInfo.width), \"height\":${imageInfo.height}}" ); } private String getUploadToken () { return this .auth.uploadToken(bucket,null ,3600 , putPolicy); } }
十九、新增房源信息功能(1)
在新增房源这里我们设定了支持城市下拉的选择:
image-20220812094729522
image-20220812094810146
我们从下到上开始创建:
新建一个controller类。HouseController
并且新建实体类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 package com.zryy.soufangtest.entity;import javax.persistence.*;@Entity @Table(name = "support_address") public class SupportAddress { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "belong_to") private String belongTo; @Column(name = "en_name") private String enName; @Column(name = "cn_name") private String cnName; private String level; public Long getId () { return id; } public void setId (Long id) { this .id = id; } public String getBelongTo () { return belongTo; } public void setBelongTo (String belongTo) { this .belongTo = belongTo; } public String getEnName () { return enName; } public void setEnName (String enName) { this .enName = enName; } public String getCnName () { return cnName; } public void setCnName (String cnName) { this .cnName = cnName; } public String getLevel () { return level; } public void setLevel (String level) { this .level = level; } public enum Level { CITY("city" ), REGION("region" ); private String value; Level(String value){ this .value = value; } public String getvalue () { return value; } public static Level of (String value) { for (Level level : Level.values()) { if (level.getvalue().equals(value)){ return level; } } throw new IllegalArgumentException (); } } }
另外呢,要新建Repository接口SupportAddressRepository去继承CrudRepository<SupportAddress,Long>
第一个参数是实体类,第二个参数是id的类型
1 2 public interface SupportAddressRepository extends CrudRepository <SupportAddress,Long> {}
然后我们去定义controller:
1 2 3 4 5 6 7 8 9 @Controller public class HouseController { @GetMapping("address/support/cities") @ResponseBody public ApiResponse getSupportCities () { return ApiResponse.ofSuccess(null ); } }
既然我们定义了support/cities路径所以我们的dao就需要一个获取所有城市列表的接口:
1 2 3 4 5 6 7 public interface SupportAddressRepository extends CrudRepository <SupportAddress,Long> { List<SupportAddress> findAllByLevel (String level) ; }
然后再去定义service层:
新建IAddressService接口
1 2 3 public interface IAddressService (){ List<SupportAddress> findAllCities () ; }
虽然我们可以这样定义接口,
但是不建议,我们的dao和返回的dto(也就是传输到前端的类型)尽量要有一个数据的隔阂。也就是对数据进行保护,并不是所有的数据都可以被前端获取到。所以我们要定义一个中间的转换对象,所以我们新建一个新的类型SupportAddressDTO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 public class SupportAddressDTO { private Long id; @JsonProperty(value = "belong_to") private String belongTo; @JsonProperty(value = "en_name") private String enName; @JsonProperty(value = "cn_name") private String cnName; private String level; public Long getId () { return id; } public void setId (Long id) { this .id = id; } public String getBelongTo () { return belongTo; } public void setBelongTo (String belongTo) { this .belongTo = belongTo; } public String getEnName () { return enName; } public void setEnName (String enName) { this .enName = enName; } public String getCnName () { return cnName; } public void setCnName (String cnName) { this .cnName = cnName; } public String getLevel () { return level; } public void setLevel (String level) { this .level = level; } }
这样呢我们就需要在IAddressService这里去修改返回list的类型了,由SupportAddress转换成了SupportAddressDTO:
1 2 3 public interface IAddressService (){ List<SupportAddressDTO> findAllCities () ; }
然后新建一个service实现类,AddressServiceImpl
实现IAddressService:
1 2 3 4 5 6 7 8 9 10 public ServiceMultiResult<SupportAddressDTO> findAllCities () { List<SupportAddress> addresses = supportAddressRepository.findAllByLevel(SupportAddress.Level.CITY.getValue()); List<SupportAddressDTO> addressDTOS = new ArrayList <>(); for (SupportAddress supportAddress : addresses) { SupportAddressDTO target = modelMapper.map(supportAddress, SupportAddressDTO.class); addressDTOS.add(target); } return new ServiceMultiResult <>(addressDTOS.size(), addressDTOS); }
在上面的函数中
我们使supportAddressRepository去查询所有的地址后,返回了supportAddress的list,然后新建了一个List的列表,进行for循环,然后通过建立的modelMapper进行映射。
在我们对SupportAddress类型转换为SupportAddressDto类型的时候在webMvcConfig中定义了一个Bean
Util,modelMapper是一个复制bean的。
1 2 3 4 5 6 7 @Bean public ModelMapper modelMapper () { return new ModelMapper (); }
这样呢我们就存在一个问题,虽然这样我们虽然返回了一个List列表(findAllCities函数中),
1 public List<SupportAddressDTO> findAllCities () {}
但是其他接口也有返回一个supportAddress列表呢?比如说分页,所以说我们要定义这个返回的列表,设置所有的返回都有一个list,同时呢还要有一个表示数据总集的字段(result),下面去定义一个通用的结构:
ServiceMultiResult
我们定义了total、List result
、以及getResultSize方法作为该类的返回结构。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public class ServiceMultiResult <T> { private long total; private List<T> result; public ServiceMultiResult (long total, List<T> result) { this .total = total; this .result = result; } public long getTotal () { return total; } public void setTotal (long total) { this .total = total; } public List<T> getResult () { return result; } public void setResult (List<T> result) { this .result = result; } public int getResultSize () { if (this .result == null ) { return 0 ; } return this .result.size(); } }
这样呢我们在AddressServiceImpl中定义的返回就不用List了,而是使用我们自己定义好的ServiceMultiResult了。
修改前:
1 public List<SupportAddressDTO> findAllCities () {}
修改后:
1 2 3 4 5 6 public ServiceMultiResult<SupportAddressDTO> findAllCities () { return new ServiceMultiResult <>(addressDTO.size(), addressDTOS) }
上面传递的两个参数是因为我们定义的ServiceMultiResult需要传入两个类变量,而第二个变量需要传入的是一个List类型。
在设计完service层后,我们去定义controller层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Controller public class HouseController { @Autowired private IAddressService addressService; @GetMapping("address/support/cities") @ResponseBody public ApiResponse getSupportCities () { ServiceMultiResult<SupportAddressDTO> result = addressService.findAllCities(); if (result.getResultSize() == 0 ){ return ApiResponse.ofSuccess(ApiResponse.Status.NOT_FOUND); } return ApiResponse.ofSuccess(result.getResult()); } }
image-20220815164459062
在查询的时候,我们是根据ByLevel进行查询的,传递的是定义好的Enum类型SupportAddress.Level.CITY来进行条件检索的。
1 List<SupportAddress> addresses = supportAddressRepository.findAllByLevel(SupportAddress.Level.CITY.getValue());
另外还有区县、地铁站、地铁线路的接口实现,这里不做过多说明,只列举相关代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 @Controller public class HouseController { @Autowired private IAddressService addressService; @GetMapping("address/support/cities") @ResponseBody public ApiResponse getSupportCities () { ServiceMultiResult<SupportAddressDTO> result = addressService.findAllCities(); if (result.getResultSize() == 0 ) { return ApiResponse.ofSuccess(ApiResponse.Status.NOT_FOUND); } return ApiResponse.ofSuccess(result.getResult()); } @GetMapping("address/support/regions") @ResponseBody public ApiResponse getSupportRegions (@RequestParam(name = "city_name") String cityEnName) { ServiceMultiResult<SupportAddressDTO> addressResult = addressService.findAllRegionsByCityName(cityEnName); if (addressResult.getResult() == null || addressResult.getTotal() < 1 ) { return ApiResponse.ofStatus(ApiResponse.Status.NOT_FOUND); } return ApiResponse.ofSuccess(addressResult.getResult()); } @GetMapping("address/support/subway/line") @ResponseBody public ApiResponse getSupportSubwayLine (@RequestParam(name = "city_name") String cityEnName) { List<SubwayDTO> subways = addressService.findAllSubwayByCity(cityEnName); if (subways.isEmpty()) { return ApiResponse.ofStatus(ApiResponse.Status.NOT_FOUND); } return ApiResponse.ofSuccess(subways); } @GetMapping("address/support/subway/station") @ResponseBody public ApiResponse getSupportSubwayStation (@RequestParam(name = "subway_id") Long subwayId) { List<SubwayStationDTO> stationDTOS = addressService.findAllStationBySubway(subwayId); if (stationDTOS.isEmpty()) { return ApiResponse.ofStatus(ApiResponse.Status.NOT_FOUND); } return ApiResponse.ofSuccess(stationDTOS); } }
从总体上来说,关于其他(地铁站、地铁线等等)的查询以及展示都是类似思想,无非就是通过前端联结查询,当选择城市的时候,会传递城市名称并请求地铁站api,然后再前端并联渲染出来,不过需要注意的地方是我们定义了DTO类,并非原来的Entity实体类直接返回出来,而是定义了其他的一些的返回类,并非原来的List,定义了其他的一些返回类定义了一些关于ResultSize等等之类的信息,非常方便获取。
二十、新增房源信息功能(2)
我们新建了实体类:
House
HouseDetail
HousePicture
HouseSubscribe
HouseTag
然后我们新建新的HouseRepository、HouseDetailRepository、HousePictureRepository、HouseTagRepository
Repository创建完了,我们新建一个IService接口:
1 2 3 public interface IHouseService { save() }
在Service接口中我们去定义一个save房源类,可是它返回什么类型呢?
我们考虑像之前定义的ServiceMultiResult类一样,去定义这个返回类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 public class ServiceResult <T> { private boolean success; private String message; private T result; public ServiceResult (boolean success) { this .success = success; } public ServiceResult (boolean success, String message) { this .success = success; this .message = message; } public ServiceResult (boolean success, String message, T result) { this .success = success; this .message = message; this .result = result; } public boolean isSuccess () { return success; } public void setSuccess (boolean success) { this .success = success; } public String getMessage () { return message; } public void setMessage (String message) { this .message = message; } public T getResult () { return result; } public void setResult (T result) { this .result = result; } }
这样定义完返回的类类型后,重新去service中去设置save方法:
1 2 3 public interface IHouseService { ServiceResult<> save(); }
但是,ServiceResult我们定义的是一个范型类T,直接设置成House
Entity的话就会暴露,所以我们需要重新去定义一个HouseDTO类,并且附带定义了HostDetailDTO类、HousePictrueDTO类。
这样呢service中的save方法就可以设置了:
1 2 3 public interface IHouseService { ServiceResult<HouseDTO> save () ; }
但是这里save方法接收的参数类是什么?实质上是前端页面传过来的form表单,这里我们也重新定义一个类去接收这个表单一一对应。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 public class HouseForm { private Long id; @NotNull(message = "大标题不允许为空!") @Size(min = 1, max = 30, message = "标题长度必须在1~30之间") private String title; @NotNull(message = "必须选中一个城市") @Size(min = 1, message = "非法的城市") private String cityEnName; @NotNull(message = "必须选中一个地区") @Size(min = 1, message = "非法的地区") private String regionEnName; @NotNull(message = "必须填写街道") @Size(min = 1, message = "非法的街道") private String street; @NotNull(message = "必须填写小区") private String district; @NotNull(message = "详细地址不允许为空!") @Size(min = 1, max = 30, message = "详细地址长度必须在1~30之间") private String detailAddress; @NotNull(message = "必须填写卧室数量") @Min(value = 1, message = "非法的卧室数量") private Integer room; private int parlour; @NotNull(message = "必须填写所属楼层") private Integer floor; @NotNull(message = "必须填写总楼层") private Integer totalFloor; @NotNull(message = "必须填写房屋朝向") private Integer direction; @NotNull(message = "必须填写建筑起始时间") @Min(value = 1900, message = "非法的建筑起始时间") private Integer buildYear; @NotNull(message = "必须填写面积") @Min(value = 1) private Integer area; @NotNull(message = "必须填写租赁价格") @Min(value = 1) private Integer price; @NotNull(message = "必须选中一个租赁方式") @Min(value = 0) @Max(value = 1) private Integer rentWay; private Long subwayLineId; private Long subwayStationId; private int distanceToSubway = -1 ; private String layoutDesc; private String roundService; private String traffic; @Size(max = 255) private String description; private String cover; private List<String> tags; private List<PhotoForm> photos; public Long getId () { return id; } public void setId (Long id) { this .id = id; } public String getTitle () { return title; } public void setTitle (String title) { this .title = title; } public String getCityEnName () { return cityEnName; } public void setCityEnName (String cityEnName) { this .cityEnName = cityEnName; } public String getRegionEnName () { return regionEnName; } public void setRegionEnName (String regionEnName) { this .regionEnName = regionEnName; } public String getStreet () { return street; } public void setStreet (String street) { this .street = street; } public String getDistrict () { return district; } public void setDistrict (String district) { this .district = district; } public String getDetailAddress () { return detailAddress; } public void setDetailAddress (String detailAddress) { this .detailAddress = detailAddress; } public Integer getRoom () { return room; } public void setRoom (Integer room) { this .room = room; } public int getParlour () { return parlour; } public void setParlour (int parlour) { this .parlour = parlour; } public Integer getFloor () { return floor; } public void setFloor (Integer floor) { this .floor = floor; } public Integer getTotalFloor () { return totalFloor; } public void setTotalFloor (Integer totalFloor) { this .totalFloor = totalFloor; } public Integer getDirection () { return direction; } public void setDirection (Integer direction) { this .direction = direction; } public Integer getBuildYear () { return buildYear; } public void setBuildYear (Integer buildYear) { this .buildYear = buildYear; } public Integer getArea () { return area; } public void setArea (Integer area) { this .area = area; } public Integer getPrice () { return price; } public void setPrice (Integer price) { this .price = price; } public Integer getRentWay () { return rentWay; } public void setRentWay (Integer rentWay) { this .rentWay = rentWay; } public Long getSubwayLineId () { return subwayLineId; } public void setSubwayLineId (Long subwayLineId) { this .subwayLineId = subwayLineId; } public Long getSubwayStationId () { return subwayStationId; } public void setSubwayStationId (Long subwayStationId) { this .subwayStationId = subwayStationId; } public int getDistanceToSubway () { return distanceToSubway; } public void setDistanceToSubway (int distanceToSubway) { this .distanceToSubway = distanceToSubway; } public String getLayoutDesc () { return layoutDesc; } public void setLayoutDesc (String layoutDesc) { this .layoutDesc = layoutDesc; } public String getRoundService () { return roundService; } public void setRoundService (String roundService) { this .roundService = roundService; } public String getTraffic () { return traffic; } public void setTraffic (String traffic) { this .traffic = traffic; } public String getDescription () { return description; } public void setDescription (String description) { this .description = description; } public String getCover () { return cover; } public void setCover (String cover) { this .cover = cover; } public List<String> getTags () { return tags; } public void setTags (List<String> tags) { this .tags = tags; } public List<PhotoForm> getPhotos () { return photos; } public void setPhotos (List<PhotoForm> photos) { this .photos = photos; } @Override public String toString () { return "HouseForm{" + "id=" + id + ", title='" + title + '\'' + ", cityEnName='" + cityEnName + '\'' + ", regionEnName='" + regionEnName + '\'' + ", district='" + district + '\'' + ", detailAddress='" + detailAddress + '\'' + ", room=" + room + ", parlour=" + parlour + ", floor=" + floor + ", totalFloor=" + totalFloor + ", direction=" + direction + ", buildYear=" + buildYear + ", area=" + area + ", price=" + price + ", rentWay=" + rentWay + ", subwayLineId=" + subwayLineId + ", subwayStationId=" + subwayStationId + ", distanceToSubway=" + distanceToSubway + ", layoutDesc='" + layoutDesc + '\'' + ", roundService='" + roundService + '\'' + ", traffic='" + traffic + '\'' + ", description='" + description + '\'' + ", cover='" + cover + '\'' + ", photos=" + photos + '}' ; } }
现在就变成了:
1 2 3 public interface IHouseService { ServiceResult<HouseDTO> save (HouseForm) ; }
service编写完毕后,房源信息的添加应该是在admin下,所以我们在adminController下编写controller类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @PostMapping("admin/add/house") @ResponseBody public ApiResponse addHouse (@Valid @ModelAttribute("form-house-add") HouseForm houseForm, BindingResult bindingResult) { if (bindingResult.hasErrors()){ return new ApiResponse (HttpStatus.BAD_REQUEST.value(),bindingResult.getAllErrors().get(0 ).getDefaultMessage(),null ); } if (houseForm.getPhotos() ==null || houseForm.getCover() ==null ){ return ApiResponse.ofMessage(HttpStatus.BAD_REQUEST.value(), "必须上传图片" ); } Map<SupportAddress.Level, SupportAddressDTO> cityAndRegionMap = addressService.findCityAndRegion(houseForm.getCityEnName(), houseForm.getRegionEnName()); if (cityAndRegionMap.keySet().size() != 2 ){ return ApiResponse.ofStatus(ApiResponse.Status.NOT_VALID_PARAM); } ServiceResult<HouseDTO> result = houseService.save(houseForm); if (result.isSuccess()){ return ApiResponse.ofSuccess(result.getResult()); } return ApiResponse.ofSuccess(ApiResponse.Status.NOT_VALID_PARAM); }
我们接口的参数名称是form-house-add,并使用ModelAttribute注解来使用,另外使用@Valid来自动的对表单进行验证。
另外我们定义了一个BindingResult类:作用:用于对前端穿进来的参数进行校验,省去了大量的逻辑判断操作,一开始传入的参数没有使用@Validated
修饰,结果绑定不起作用,参数校验不成功,加上此注解即可生效。
所以BingdingResult是要与@Validated同时使用的。
bindingResult类如果entity类校验错误的话,错误信息就会绑定到bindingResult类上面去。
关于bindingResult的详细内容需要后期补充
另外在编辑完毕对传入的地址表单进行验证后(使用的是IAddressService)
我们之前在上面定义的IHouseService,虽然没有定义HouseServiceImpl但是我们是面向接口编程,所以我们先假设使用HouseService执行。
然后我们编辑了对定义的houseForm进行操作后,开始实现IHouseSevice的实现类:
1 2 3 House house = new House ();house.setParlour(houseForm.getParlour());
当然这里我们可以去设置house.set( houseForm.getXXXX)
不过如果我们有很多个变量的话这样写就会变的很麻烦很繁琐。
这样可以直接使用我们之前用过的ModelMapper映射过去。
1 modelMapper.map(houseForm, house);
1 2 3 4 5 6 7 8 9 10 11 12 13 Date now = new Date ();house.setCreateTime(now); house.setLastUpdateTime(now); house.setAdminId(LoginUserUtil.getUserId()); houseRepository.save(house); HouseDetail houseDetail = new HouseDetail ();ServiceResult<HouseDTO> subwayValidtionResult = wrapperSubwayDetailInfo(houseDetail, houseForm);
另外呢不只是house的信息,还有houseDetail的信息需要(围绕houseDetail做一些属性的设置,将地铁以及地铁站的信息设置进去。),所以我们定义了一个W
rapperSubwayDetailInfo类去得到关于地铁的一些信息设置成houseDetail的一些属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 private ServiceResult<HouseDTO> wrapperSubwayDetailInfo (HouseDetail houseDetail, HouseForm houseForm) { Subway subwayInfo = subwayRepository.findOne(houseForm.getSubwayLineId()); if (subwayInfo == null ){ return new ServiceResult <>(false ,"Not valid subway line!" ); } SubwayStation subwayStationInfo = subwayStationRepository.findOne(houseForm.getSubwayStationId()); if (subwayStationInfo == null || subwayInfo.getId() != subwayStationInfo.getSubwayId()){ return new ServiceResult <>(false ,"Not valid subway staition" ); } houseDetail.setSubwayLineId(subwayInfo.getId()); houseDetail.setSubwayLineName(subwayInfo.getName()); houseDetail.setSubwayStationId(subwayStationInfo.getSubwayId()); houseDetail.setSubwayStationName(subwayStationInfo.getName()); houseDetail.setDescription(houseForm.getDescription()); houseDetail.setDetailAddress(houseForm.getDetailAddress()); houseDetail.setLayoutDesc(houseForm.getLayoutDesc()); houseDetail.setRentWay(houseForm.getRentWay()); houseDetail.setRoundService(houseForm.getRoundService()); houseDetail.setTraffic(houseDetail.getTraffic()); return null ; }
上面虽然返回了null,但是houseDetail的属性是添加进去了的。
然后houseDetail.setHouseId(house.getId())
使用houseDetailRepository存储到数据库。
接下来就是housePictrue的实现包装类generatePictures了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private List<HousePicture> generatePictures (HouseForm houseForm, Long houseId) { List<HousePicture> pictures = new ArrayList <>(); if (houseForm.getPhotos() ==null || houseForm.getPhotos().isEmpty()){ return pictures; } for (PhotoForm photo : houseForm.getPhotos()) { HousePicture housePicture = new HousePicture (); housePicture.setHouseId(houseId); housePicture.setCdnPrefix(cdnPrefix); housePicture.setPath(photo.getPath()); housePicture.setWidth(photo.getWidth()); housePicture.setHeight(photo.getHeight()); pictures.add(housePicture); } return pictures; }
其中通过houseForm前端传递的参数获取到photos,然后循环,新建一个HousePicture的List放进去,最后返回出来。然后:
1 Iterable<HousePicture> housePictures = housePictureRepository.save(pictures);
另外返回的都是存储着实体类的List,我们要对其转换成DTO类:
1 2 3 4 5 6 7 8 9 HouseDTO houseDTO = modelMapper.map(house,HouseDTO.class);HouseDetailDTO houseDetailDTO = modelMapper.map(houseDetail, HouseDetailDTO.class);houseDTO.setHouseDetail(houseDetailDTO); List<HousePictureDTO> pictureDTOS = new ArrayList <>(); housePictures.forEach(housePicture -> pictureDTOS.add(modelMapper.map(housePicture,HousePictureDTO.class))); houseDTO.setPictures(pictureDTOS); houseDTO.setCover(this .cdnPrefix + houseDTO.getCover());
其中使用到了peoperties文件中定义的cdnPrefix变量
1 2 @Value("${qiniu.cdn.prefix}") private String cdnPrefix;
另外呢还需要得到前端点击标记的标签,然后存储在houseTagRespository中,最后都在houseDTO类中去定义类型。
最后的最后;
return 一个我们定义好的ServiceResult类:
1 return new ServiceResult <HouseDTO>(true ,null ,houseDTO);
测试插入数据:
image-20220816205011825
二十一、房源信息浏览功能(1)
1)基础开发
2)分页实现
3)多维度排序
image-20220817112443451
每次刷新后会出现页面重复情况,这种情况出现的原因在于:每次项目呢session会存在内存里面,每次重启呢项目内存就会丢失,这样session就过期了,这样为了保证每次登陆都不受此情况烦恼,所以提出了使用redis来保存我们的对话信息 。
我们怎么去实现呢?
1、加一个RedisSessionConfig配置
1 2 3 4 5 @Configuration @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400) public class RedisSessionConfig { }
添加配置类注解、
添加EnableRedisHttpSession,让Springboot自动为我们配置redisSession服务。
并且设置其生效时间为一天86400秒((maxInactiveIntervalInSeconds =
86400))
1 2 3 4 5 6 7 8 @Configuration @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400) public class RedisSessionConfig { @Bean public RedisTemplate<String,String> redisTemplate (RedisConnectionFactory factory) { return new StringRedisTemplate (factory); } }
当然也可以自己进行手动的配置:
1 2 3 4 5 6 spring.redis.database =0 spring.redis.host =localhost spring.redis.port =6379 spring.redis.pool.min-idle =1 spring.redis.timeout =3000
另外呢之前存储缓存在hash_map下也就是在本机内存中,现在需要修改成:
1 2 spring.session.store-type = redis
但是在上面写localhost就出错了,应该调整为127.0.0.1
1 2 3 4 5 6 spring.redis.database =0 spring.redis.host =127.0.0.1 spring.redis.port =6379 spring.redis.pool.min-idle =1 spring.redis.timeout =3000
然后这样呢在登录的时候就会将session自动的存储到redis中了:
image-20220817142440167
但是这些session是怎么被写进redis中的呢?什么时候写进去的呢?
猜想是在RedisSessionConfig的时候后台自动对session进行的操作。
另外在pom.xml 中添加的依赖是:
1 2 3 4 5 6 7 8 <dependency > <groupId > org.springframework.session</groupId > <artifactId > spring-session-data-redis</artifactId > </dependency > <dependency > <groupId > org.springframework.session</groupId > <artifactId > spring-session</artifactId > </dependency >
关于spring-session的更多详细文章见:
https://www.cnblogs.com/54chensongxia/p/12096493.html
在房屋列表页面中我们使用的dataTables这个插件,对请求的接口有一些特殊的要求。
1 2 3 @PostMapping("admin/houses") @ResponseBody public ApiResponse
但是我们就不能用ApiResponse了,因为dataTables有一个固定的格式。
image-20220817171008284
所以我们需要单独给它定制一个格式类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public class ApiDataTableResponse extends ApiResponse { private int draw; private Long recordsTotal; private Long recordsFiltered; public ApiDataTableResponse (int code, String message, Object data, int draw, Long recordsTotal, Long recordsFiltered) { super (code, message, data); } public int getDraw () { return draw; } public void setDraw (int draw) { this .draw = draw; } public Long getRecordsTotal () { return recordsTotal; } public void setRecordsTotal (Long recordsTotal) { this .recordsTotal = recordsTotal; } public Long getRecordsFiltered () { return recordsFiltered; } public void setRecordsFiltered (Long recordsFiltered) { this .recordsFiltered = recordsFiltered; } }
然后就可以在controller中进行定义了:
1 2 3 4 5 @PostMapping("admin/houses") @ResponseBody public ApiDataTableResponse houses () { }
但是我们需要去定义一个dataTables 的表单类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 public class DataTableSearch { private int draw; private int start; private int length; private Integer status; @DateTimeFormat(pattern = "yyyy-MM-dd") private Date createTimeMin; @DateTimeFormat(pattern = "yyyy-MM-dd") private Date createTimeMax; private String city; private String title; private String direction; private String orderBy; public int getDraw () { return draw; } public void setDraw (int draw) { this .draw = draw; } public int getStart () { return start; } public void setStart (int start) { this .start = start; } public int getLength () { return length; } public void setLength (int length) { this .length = length; } public Integer getStatus () { return status; } public void setStatus (Integer status) { this .status = status; } public Date getCreateTimeMin () { return createTimeMin; } public void setCreateTimeMin (Date createTimeMin) { this .createTimeMin = createTimeMin; } public Date getCreateTimeMax () { return createTimeMax; } public void setCreateTimeMax (Date createTimeMax) { this .createTimeMax = createTimeMax; } public String getCity () { return city; } public void setCity (String city) { this .city = city; } public String getTitle () { return title; } public void setTitle (String title) { this .title = title; } public String getDirection () { return direction; } public void setDirection (String direction) { this .direction = direction; } public String getOrderBy () { return orderBy; } public void setOrderBy (String orderBy) { this .orderBy = orderBy; } }
其中,关于start、length都定义的是int类型,但是关于status定义的是Integer类型,这里定义了一个小技巧,Integer当为空的时候,代表返回所有的状态码。
1 2 @DateTimeFormat(pattern = "yyyy-MM-dd") private Date createTimeMin;
DateTimeFormat去定义了字段的格式校验。
定义好了返回类之后,返回到controller中,我们定义@ModelAttribut固定化返回的结构DataTableSearch.
1 2 3 4 5 @PostMapping("admin/houses") @ResponseBody public ApiDataTableResponse houses (@ModelAttribute DataTableSearch SearchBody) { return null ; }
我们先暂时返回null。
这时候我们去定义service:
1 ServiceMultiResult<HouseDTO> adminQuery (DataTableSearch searchBody) ;
在serviceImpl中定义:
1 2 3 4 5 6 7 8 9 10 11 @Override public ServiceMultiResult<HouseDTO> adminQuery (DataTableSearch searchBody) { List<HouseDTO> houseDTOS = new ArrayList <>(); Iterable<House> houses = houseRepository.findAll(); houses.forEach(house -> { HouseDTO houseDTO = modelMapper.map(house,HouseDTO.class); houseDTO.setCover(this .cdnPrefix + house.getCover()); houseDTOS.add(houseDTO); }); return new ServiceMultiResult <>(houseDTOS.size(),houseDTOS); }
上面我们只定义了findAll,并没有用到searchBody做一些比较详细的查找,会在后续中进行查找。
serviceImpl实现了之后我们就返回去去整理controller。
1 ServiceMultiResult<HouseDTO> result = houseService.adminQuery(SearchBody);
1 2 3 @PostMapping("admin/houses") @ResponseBody public ApiDataTableResponse houses (@ModelAttribute DataTableSearch SearchBody) {}
因为我们返回的是ApiDataTableRespose类型,所以我们需要做一个封装:
1 ApiDataTableResponse apiDataTableResponse = new ApiDataTableResponse (ApiResponse.Status.SUCCESS);
然后给ApiDataTableResponese去定义一个构造函数:
1 2 3 public ApiDataTableResponse (ApiResponse.Status status) { this (status.getCode(),status.getStandardMessage(),null ); }
最后我们定义的controller如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 @PostMapping("admin/houses") @ResponseBody public ApiDataTableResponse houses (@ModelAttribute DataTableSearch searchBody) { ServiceMultiResult<HouseDTO> result = houseService.adminQuery(searchBody); ApiDataTableResponse response = new ApiDataTableResponse (ApiResponse.Status.SUCCESS); response.setData(result.getResult()); response.setRecordsFiltered(result.getTotal()); response.setRecordsTotal(result.getTotal()); response.setDraw(searchBody.getDraw()); return response; }
返回的结果界面如下:
image-20220822173105778
但是这里我们方法是findAll,另外分页是在前端做的,如果数据量很多的话,很容易就会请求接口的时候把内存撑爆。所以我们不仅要有前端的分页还得要有后端的分页。
二十二、房源信息浏览功能(2)
我们来实现分页,实现基本的分页和基本的排序,这时候就用到了我们controller中的参数searchBody了。
之前我们定义的事House
Repository继承的是CrudRepository,使用的是findAll方法,用到分页所以我们就不能继承这个了。
1 2 public interface HouseRepository extends PagingAndSortingRepository <House,Long> {}
所以我们重构了Repository方法后,又重新定义了ServiceImpl方法类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Override public ServiceMultiResult<HouseDTO> adminQuery (DataTableSearch searchBody) { List<HouseDTO> houseDTOS = new ArrayList <>(); Sort sort = new Sort (Sort.Direction.fromString(searchBody.getDirection()),searchBody.getOrderBy()); int page = searchBody.getStart() / searchBody.getLength() ; Pageable pageable = new PageRequest (page,searchBody.getLength(),sort); Page<House> houses = houseRepository.findAll(pageable); houses.forEach(house -> { HouseDTO houseDTO = modelMapper.map(house,HouseDTO.class); houseDTO.setCover(this .cdnPrefix + house.getCover()); houseDTOS.add(houseDTO); }); return new ServiceMultiResult <>(houses.getTotalElements(),houseDTOS); }
首先要新建一个排序类:
new Sort()
,里面需要传的第一个参数是排序的方式(正排还是倒排),需要用Sort.Direction.fromString来进行转换。第二个参数是根据什么字段来进行排序,也就是GetOrderBy,
然后再去定义page,固定的公式就是:
1 int page = searchBody.getStart() / searchBody.getLength() ;
然后新建一个Pageable类,需要传递的参数就是page,searchBody.getLength(),sort类,然后把返回的pageable类使用PagingAndSortingRepository的findAll(Pageable )重构方法。
另外呢需要调整的地方还有:
1 return new ServiceMultiResult <>(houses.getTotalElements(),houseDTOS);
之前是houseDTOS.size(),现在是返回结果houses.getTotalElements()。这里因为使用的是findAll,所以getTotalElements返回的是查询的所有的数量总和
另外发现:在前端页面中,配置都定义在了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 var table = $('#data-table' ).DataTable ({ "order" : [[7 , "desc" ]], "pageLength" : 3 , "paging" : true , "lengthChange" : false , "searching" : false , "ordering" : true , "info" : true , "autoWidth" : true , "stateSave" : false , "retrieve" : true , "processing" : true , "serverSide" : true , "pagingType" : "simple_numbers" , "language" : { "sProcessing" : "处理中..." , "sLengthMenu" : "显示 _MENU_ 项结果" , "sZeroRecords" : "没有匹配结果" , "sInfo" : "显示第 _START_ 至 _END_ 项结果,共 _TOTAL_ 项" , "sInfoEmpty" : "显示第 0 至 0 项结果,共 0 项" , "sInfoFiltered" : "(由 _MAX_ 项结果过滤)" , "sInfoPostFix" : "" , "sUrl" : "" , "sEmptyTable" : "未搜索到数据" , "sLoadingRecords" : "载入中..." , "sInfoThousands" : "," , "oPaginate" : {
这里,所以在前端发送ajax请求的时候就会把这些预设好的参数传到服务器url。
image-20220823145625654
定义的Sort类,实际传入的值是:createTime: DESC
定义的Pageable类,实际传入的值是:Page request [number: 0, size 3,
sort: createTime: DESC]
这里对于前端的技术代码只做了浏览,并未对其进行详细阅读,至于说熟练掌握,那还需要更多时间去了解。
这里的翻页,就是使用服务器来完成的。然后使用前端html按钮来发送请求。
image-20220823151109688
二十三、房源信息浏览功能(3)
image-20220823151240457
目前的筛选条件等等只能通过表格的字段进行升序、降序的筛选,而不能通过上面城市、房源、创建时间、标题等进行搜索筛选,本节就解决这个问题。
所以我们在repository类中增加:
1 2 public interface HouseRepository extends PagingAndSortingRepository <House,Long>, JpaSpecificationExecutor<House> {}
这样不仅可以实现分页,也可以实现对指定内容的查询。
然后需要在ServiceImpl中定义一个Specification。
spring data
jpa为我们提供了JpaSpecificationExecutor接口,只要简单实现toPredicate方法就可以实现复杂的查询。JpaSpecification查询的关键在于怎么构建Predicates。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Override public ServiceMultiResult<HouseDTO> adminQuery (DataTableSearch searchBody) { List<HouseDTO> houseDTOS = new ArrayList <>(); Sort sort = new Sort (Sort.Direction.fromString(searchBody.getDirection()),searchBody.getOrderBy()); int page = searchBody.getStart() / searchBody.getLength() ; Pageable pageable = new PageRequest (page,searchBody.getLength(),sort); Specification<House> specification = new Specification <House>() { @Override public Predicate toPredicate (Root root, CriteriaQuery query, CriteriaBuilder cb) { return null ; } }
这里我们使用java8的lambda形式来定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @Override public ServiceMultiResult<HouseDTO> adminQuery (DataTableSearch searchBody) { List<HouseDTO> houseDTOS = new ArrayList <>(); Sort sort = new Sort (Sort.Direction.fromString(searchBody.getDirection()),searchBody.getOrderBy()); int page = searchBody.getStart() / searchBody.getLength() ; Pageable pageable = new PageRequest (page,searchBody.getLength(),sort); Specification<House> specification = (root, query,cb) ->{ Predicate predicate = cb.equal((root.get("adminId" ),LoginUserUtil.getUserId())); predicate = cb.and(predicate,cb.notEqual(root.get("status" ), HouseStatus.DELETED.getValue())); if (searchBody.getCity() !=null ){ predicate = cb.and(predicate, cb.equal(root.get("cityEnName" ), searchBody.getCity())); } if (searchBody.getStatus() !=null ){ predicate = cb.and(predicate, cb.equal(root.get("status" ),searchBody.getStatus())); } if (searchBody.getCreateTimeMin() !=null ){ predicate = cb.and(predicate,cb.greaterThanOrEqualTo(root.get("createTime" ),searchBody.getCreateTimeMin())); } if (searchBody.getCreateTimeMax() !=null ){ predicate = cb.and(predicate,cb.lessThanOrEqualTo(root.get("createTime" ),searchBody.getCreateTimeMax())); } if (searchBody.getTitle() !=null ){ predicate = cb.and(predicate, cb.like(root.get("title" ),"%" +searchBody.getTitle()+"%" )); } return predicate; };
root.get("country"),对应着实体类的相关属性country,root.get属性对应的实体类,我认为从使用该spec的Repository对应的实体类相对应,比如此处的playerRepo对应的实体类PlayerEntity,实际上如果属性对不上,运行时会报错。root.get取得相应实体的操作字段。
而criteriaBuilder.equal和criteriaBuilder.and,则是用来构建复杂查询
criteriaBuilder.add 或者
criteriaBuilder.or等方法的返回值也为Predicate对象。
如果添加一些搜索条件找不到的话,显示结果图如下所示:
image-20220823172508134
二十四、编辑功能实现1
1 2 3 4 5 6 7 8 9 10 11 12 13 @GetMapping("admin/house/edit") public String houseEditPage (@RequestParam(value = "id") Long id, Model model) { if (id == null || id <1 ){ return "404" ; } return "admin/house-edit" ; }
在controller层中新家房源信息编辑代码,然后在serviceImpl中去新建一个service接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 public class ServiceResult <T> { private boolean success; private String message; private T result; public ServiceResult (boolean success) { this .success = success; } public ServiceResult (boolean success, String message) { this .success = success; this .message = message; } public ServiceResult (boolean success, String message, T result) { this .success = success; this .message = message; this .result = result; } public boolean isSuccess () { return success; } public void setSuccess (boolean success) { this .success = success; } public String getMessage () { return message; } public void setMessage (String message) { this .message = message; } public T getResult () { return result; } public void setResult (T result) { this .result = result; } public static <T> ServiceResult<T> success () { return new ServiceResult <>(true ); } public static <T> ServiceResult<T> of (T result) { ServiceResult<T> serviceResult = new ServiceResult <>(true ); serviceResult.setResult(result); return serviceResult; } public static <T> ServiceResult<T> notFound () { return new ServiceResult <>(false , Message.NOT_FOUND.getValue()); } public enum Message { NOT_FOUND("Not Found Resource!" ), NOT_LOGIN("User not login!" ); private String value; Message(String value) { this .value = value; } public String getValue () { return value; } } }
在serviceImpl中去定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override public ServiceResult<HouseDTO> findCompleteOne (Long id) { House house = houseRepository.findOne(id); if (house ==null ){ return ServiceResult.notFound(); } HouseDetail detail; List<HouseTag> tags; return null ; }
因为需要获取到完整的house信息,上面代码部分我们不仅要查询house,还要查询houseDetail、housetag(注意这里HouseTag是List类型)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @Override public ServiceResult<HouseDTO> findCompleteOne (Long id) { House house = houseRepository.findOne(id); if (house ==null ){ return ServiceResult.notFound(); } HouseDetail detail = houseDetailRepository.findOne(id); List<HousePicture> housePictures = housePictureRepository.findAllByhouseId(id); HouseDetailDTO houseDetailDTO = modelMapper.map(detail,HouseDetailDTO.class); List<HousePictureDTO> pictureDTOS = new ArrayList <>(); for (HousePicture housePicture : housePictures) { HousePictureDTO housePictureDTO = modelMapper.map(housePicture,HousePictureDTO.class); pictureDTOS.add(housePictureDTO); } List<HouseTag> tags = houseTagRepository.findAllById(id); List<String> tagList = new ArrayList <>(); for (HouseTag tag : tags) { tagList.add(tag.getName()); } HouseDTO result = modelMapper.map(house, HouseDTO.class); result.setHouseDetail(houseDetailDTO); result.setPictures(pictureDTOS); result.setTags(tagList); ServiceResult<HouseDTO> serviceResult = ServiceResult.of(result); return serviceResult; }
都是一些查询的操作,没有什么好纪录的了,不过有一个类倒是需要纪录一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 package com.zryy.soufangtest.service;public class ServiceResult <T> { private boolean success; private String message; private T result; public ServiceResult (boolean success) { this .success = success; } public ServiceResult (boolean success, String message) { this .success = success; this .message = message; } public ServiceResult (boolean success, String message, T result) { this .success = success; this .message = message; this .result = result; } public boolean isSuccess () { return success; } public void setSuccess (boolean success) { this .success = success; } public String getMessage () { return message; } public void setMessage (String message) { this .message = message; } public T getResult () { return result; } public void setResult (T result) { this .result = result; } public static <T> ServiceResult<T> success () { return new ServiceResult <>(true ); } public static <T> ServiceResult<T> of (T result) { ServiceResult<T> serviceResult = new ServiceResult <>(true ); serviceResult.setResult(result); return serviceResult; } public static <T> ServiceResult<T> notFound () { return new ServiceResult <>(false , Message.NOT_FOUND.getValue()); } public enum Message { NOT_FOUND("Not Found Resource!" ), NOT_LOGIN("User not login!" ); private String value; Message(String value) { this .value = value; } public String getValue () { return value; } } }
这个封装好的返回类的of方法
1 2 3 4 5 public static <T> ServiceResult<T> of (T result) { ServiceResult<T> serviceResult = new ServiceResult <>(true ); serviceResult.setResult(result); return serviceResult; }
ServiceResult这里不是很理解,为什么后面加一个范型类T
最后测试,点击编辑按钮,返回页面:
image-20220824204543117
image-20220824204607629
但是我们点击更新的时候会报如下错误:
image-20220824205928885
我们重新定义@GetMapping("admin/house/edit")
使用post请求,即重载
1 2 3 4 5 @PostMapping("admin/house/edit") @ResponseBody public ApiResponse saveHouse (@Valid @ModelAttribute("form-house-edit") HouseForm houseForm, BindingResult bindingResult) {}
我们定义了@valid验证注解
并设置model参数为form-house-edit,还有设定一个绑定的变量参数bindingResult。
并在IHouseService中定义updae接口:
1 2 3 4 5 6 ServiceResult update (HouseForm houseForm) ;
因为是修改更新,所以我们不同于add一样需要验证,所以我们不需要进行返回参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @Override @Transactional public ServiceResult<HouseDTO> update (HouseForm houseForm) { House house = houseRepository.findOne(houseForm.getId()); if (house == null ){ return ServiceResult.notFound(); } HouseDetail houseDetail = houseDetailRepository.findOne(houseForm.getId()); if (houseDetail == null ){ return ServiceResult.notFound(); } ServiceResult wrapperResult = wrapperDetailInfo(houseDetail,houseForm); if (wrapperResult != null ){ return wrapperResult; } houseDetailRepository.save(houseDetail); List<HousePicture> pictures = generatePictures(houseForm, houseDetail.getHouseId()); housePictureRepository.save(pictures); if (houseForm.getCover() == null ){ houseForm.setCover(house.getCover()); } modelMapper.map(houseForm,house); house.setLastUpdateTime(new Date ()); houseRepository.save(house); return ServiceResult.success(); }
最后去编辑controller层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @PostMapping("admin/house/edit") @ResponseBody public ApiResponse saveHouse (@Valid @ModelAttribute("form-house-edit") HouseForm houseForm, BindingResult bindingResult) { if (bindingResult.hasErrors()){ return new ApiResponse (HttpStatus.BAD_REQUEST.value(), bindingResult.getAllErrors().get(0 ).getDefaultMessage(),null ); } Map<SupportAddress.Level, SupportAddressDTO> cityAndRegion = addressService.findCityAndRegion(houseForm.getCityEnName(), houseForm.getRegionEnName()); if (cityAndRegion.size() !=2 ){ return ApiResponse.ofSuccess(ApiResponse.Status.NOT_VALID_PARAM); } ServiceResult result = houseService.update(houseForm); if (result.isSuccess()){ return ApiResponse.ofSuccess(null ); } ApiResponse apiResponse = ApiResponse.ofStatus(ApiResponse.Status.BAD_REQUEST); apiResponse.setMessage(result.getMessage()); return apiResponse; }
二十五、编辑功能实现2
虽然上节中我们实现了更新的操作,但是对于房源标签修改,移除图片等操作还没有具体实现。
1 2 3 4 5 6 7 8 ServiceResult removePhoto (Long id ) ; ServiceResult addTag (Long houseId, String tag) ; ServiceResult updateCover (Long coverId, Long targetId) ;
然后在serviceImpl实现:
1 2 3 4 @Override public ServiceResult removePhoto (Long id) { return null ; }
暂列返回为null。
然后定义修改封面接口、移除图片接口、增加标签接口的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @DeleteMapping("admin/house/photo") @ResponseBody public ApiResponse removeHousePhoto (@RequestParam(value = "id") Long id) { ServiceResult result = this .houseService.removePhoto(id); if (result.isSuccess()) { return ApiResponse.ofStatus(ApiResponse.Status.SUCCESS); } else { return ApiResponse.ofMessage(HttpStatus.BAD_REQUEST.value(), result.getMessage()); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @PostMapping("admin/house/cover") @ResponseBody public ApiResponse updateCover (@RequestParam(value = "cover_id") Long coverId, @RequestParam(value = "target_id") Long targetId) { ServiceResult result = this .houseService.updateCover(coverId, targetId); if (result.isSuccess()) { return ApiResponse.ofStatus(ApiResponse.Status.SUCCESS); } else { return ApiResponse.ofMessage(HttpStatus.BAD_REQUEST.value(), result.getMessage()); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @PostMapping("admin/house/tag") @ResponseBody public ApiResponse addHouseTag (@RequestParam(value = "house_id") Long houseId, @RequestParam(value = "tag") String tag) { if (houseId < 1 || Strings.isNullOrEmpty(tag)) { return ApiResponse.ofStatus(ApiResponse.Status.BAD_REQUEST); } ServiceResult result = this .houseService.addTag(houseId, tag); if (result.isSuccess()) { return ApiResponse.ofStatus(ApiResponse.Status.SUCCESS); } else { return ApiResponse.ofMessage(HttpStatus.BAD_REQUEST.value(), result.getMessage()); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @DeleteMapping("admin/house/tag") @ResponseBody public ApiResponse removeHouseTag (@RequestParam(value = "house_id") Long houseId, @RequestParam(value = "tag") String tag) { if (houseId < 1 || Strings.isNullOrEmpty(tag)) { return ApiResponse.ofStatus(ApiResponse.Status.BAD_REQUEST); } ServiceResult result = this .houseService.removeTag(houseId, tag); if (result.isSuccess()) { return ApiResponse.ofStatus(ApiResponse.Status.SUCCESS); } else { return ApiResponse.ofMessage(HttpStatus.BAD_REQUEST.value(), result.getMessage()); } }
然后就到service实现类中去实现移除图片具体逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Override public ServiceResult removePhoto (Long id) { HousePicture picture = housePictureRepository.findOne(id); if (picture == null ){ return ServiceResult.notFound(); } Response response = null ; try { response = iQiNiuService.delete(picture.getPath()); if (response.isOK()){ return ServiceResult.success(); } else { return new ServiceResult (false ,response.error); } } catch (QiniuException e) { e.printStackTrace(); return new ServiceResult (false ,e.getMessage()); } }
另外发现一个问题缺陷
在房源编辑的时候,上传完毕图片,在封面设置那里是一个错误的视图。所以找了半天,发现在upload.js文件下有一个设置封面图片的地方,需要拼接src+Photopath,需要修改成在七牛云申请的域名。
1 2 3 4 5 6 7 8 $("#upload-cover-container" ).append ( '<div style="float: left; margin: 2px; padding: 2px; border: 1px dashed; width:' + ' 120px; height: 100px;">' + '<span><img src="http://rge19896q.hb-bkt.clouddn.com//' + photo_path + '?imageView2/1/w/100/h/100" title="待选封面" />' + '<input style="margin-left: 5px;" type="radio" name="cover" value="' + photo_path + '"/></span></div>' );
才可以正常显示,显示成功的图如下所示:
image-20220825162919557
service实现类中去实现移除图片具体逻辑后再去实现updateCover的具体逻辑:
1 2 3 4 5 6 7 8 9 10 @Override @Transactional public ServiceResult updateCover (Long coverId, Long targetId) { HousePicture cover = housePictureRepository.findOne(coverId); if (cover == null ) { return ServiceResult.notFound(); } houseRepository.updateCover(targetId, cover.getPath()); return null ; }
然后在repository中去定义增删改查的语句,注意:
要增加@Modify注解,告诉其这是一个修改的方法。
要增加@Query注解,自定义一些crud的方法:
1 2 3 4 5 public interface HouseRepository extends PagingAndSortingRepository <House,Long>, JpaSpecificationExecutor<House> { @Modifying @Query(value ="update House as house set house.cover = :cover where house.id = :id" ) void updateCover (@Param(value = "id") Long id, @Param(value = "cover") String cover) ; }
其中关于jpa的语法需要更多的去学习了解
然后返回写serviceImpl类:
1 2 3 4 5 6 7 8 9 10 @Override @Transactional public ServiceResult updateCover (Long coverId, Long targetId) { HousePicture cover = housePictureRepository.findOne(coverId); if (cover == null ) { return ServiceResult.notFound(); } houseRepository.updateCover(targetId, cover.getPath()); return ServiceResult.success(); }
因为是update操作,所以不需要返回值。但是这里似乎并没有添加什么校验。
效果图:
image-20220825172443129
image-20220825172455963
另外呢还需要添加增加标签和删除标签serviceImpl
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Override @Transactional public ServiceResult addTag (Long houseId, String tag) { House house = houseRepository.findOne(houseId); if (house == null ) { return ServiceResult.notFound(); } HouseTag houseTag = houseTagRepository.findByNameAndHouseId(tag, houseId); if (houseTag != null ) { return new ServiceResult (false , "标签已存在" ); } houseTagRepository.save(new HouseTag (houseId, tag)); return ServiceResult.success(); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Override @Transactional public ServiceResult removeTag (Long houseId, String tag) { House house = houseRepository.findOne(houseId); if (house == null ) { return ServiceResult.notFound(); } HouseTag houseTag = houseTagRepository.findByNameAndHouseId(tag, houseId); if (houseTag == null ) { return new ServiceResult (false , "标签不存在" ); } houseTagRepository.delete(houseTag.getId()); return ServiceResult.success(); }
二十六、审核功能实现
1 2 3 4 5 6 7 8 9 10 11 @PostMapping("admin/house/operate/{id}/{operation}") @ResponseBody public ApiResponse operateHouse (@PathVariable(value = "id") Long id , @PathVariable(value = "operation") int operation) {}
我们先设定审核通过这个简单功能测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @PutMapping("admin/house/operate/{id}/{operation}") @ResponseBody public ApiResponse operateHouse (@PathVariable(value = "id") Long id , @PathVariable(value = "operation") int operation) { if (id <=1 ){ return ApiResponse.ofStatus(ApiResponse.Status.NOT_VALID_PARAM); } if (operation ==1 ){ this .houseService.updateStatus(id, HouseStatus.PASSES.getValue()); return ApiResponse.ofStatus(ApiResponse.Status.SUCCESS); } return ApiResponse.ofStatus(ApiResponse.Status.BAD_REQUEST); }
在编辑的时候把@PutMapping写成了@PostMapping ,导致了错误
原因在于:@PutMapping和 @PostMapping的区别 ?
如果执行添加操作, 后面的添加请求不会覆盖前面的请求,
所以使用@Postmapping
如果执行修改操作, 后面的修改请求会把前面的请求给覆盖掉,
所以使用@PutMapping
然后我们在serviceImpl类中去实现updateStatus。
因为在controller层中我们对传过来的id进行了校验。
1 2 3 if (id <=1 ){ return ApiResponse.ofStatus(ApiResponse.Status.NOT_VALID_PARAM); }
我们也只是对id做个简单的校验,而没有对查询出来的house进行校验。
所以我们在serviceImpl层进行了校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Override @Transactional public ServiceResult updateStatus (Long id, int status) { House house = houseRepository.findOne(id); if (house == null ){ return ServiceResult.notFound(); } if (house.getStatus() == status){ return new ServiceResult (false ,"状态没有发生变化!" ); } if (house.getStatus() == HouseStatus.RENTED.getValue()) { return new ServiceResult (false ,"已出租的房屋不允许发生状态改变!" ); } if (house.getStatus() == HouseStatus.DELETED.getValue()){ return new ServiceResult (false ,"已删除的房屋不允许操作!" ); } houseRepository.updateStatus(); return null ; }
当然了,在update之前我们要做一些其他的校验,比如说状态是否发生了改变?是否已出租房屋?是否已删除房屋?等等
然后在repository中执行数据层的操作:
1 2 3 @Modifying @Query(value = "update House as house set house.status = :status where house.id = :id") void updateStatus (@Param(value ="id") Long id, @Param(value = "status") int status) ;
同样的,在repositpory中并非简单的增删查操作,所以需要单独去编写@Modifying注解,和@Query注解去自定义JPA的语句。
然后都编辑好了之后,就可以在serviceImpl中完善了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Override @Transactional public ServiceResult updateStatus (Long id, int status) { House house = houseRepository.findOne(id); if (house == null ){ return ServiceResult.notFound(); } if (house.getStatus() == status){ return new ServiceResult (false ,"状态没有发生变化!" ); } if (house.getStatus() == HouseStatus.RENTED.getValue()) { return new ServiceResult (false ,"已出租的房屋不允许发生状态改变!" ); } if (house.getStatus() == HouseStatus.DELETED.getValue()){ return new ServiceResult (false ,"已删除的房屋不允许操作!" ); } houseRepository.updateStatus(id,status); return ServiceResult.success(); }
最后实现的审核:发布效果如下所示:
image-20220826150416712
当然了,在controller中我们不能定义operation=1、2、3、4这样的操作,所以要去定义一些通用的类库:
1 2 3 4 5 6 7 8 9 10 11 12 @PutMapping("admin/house/operate/{id}/{operation}") @ResponseBody public ApiResponse operateHouse (@PathVariable(value = "id") Long id , @PathVariable(value = "operation") int operation) { if (id <=1 ){ return ApiResponse.ofStatus(ApiResponse.Status.NOT_VALID_PARAM); } if (operation ==1 ){ this .houseService.updateStatus(id, HouseStatus.PASSES.getValue()); return ApiResponse.ofStatus(ApiResponse.Status.SUCCESS); } return ApiResponse.ofStatus(ApiResponse.Status.BAD_REQUEST); }
1 2 3 4 5 6 7 8 9 10 11 12 13 public class HouseOperation { public static final int PASS = 1 ; public static final int PULL_OUT = 0 ; public static final int DELETE = 3 ; public static final int RENT = 4 ; }
定义好之后,就修改验证代码:
1 2 3 4 if (operation == HouseOperation.PASS){ this .houseService.updateStatus(id, HouseStatus.PASSES.getValue()); return ApiResponse.ofStatus(ApiResponse.Status.SUCCESS); }
那既然这样,我们也可以换成swich( ) case的形式了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 @PutMapping("admin/house/operate/{id}/{operation}") @ResponseBody public ApiResponse operateHouse (@PathVariable(value = "id") Long id , @PathVariable(value = "operation") int operation) { if (id <=1 ){ return ApiResponse.ofStatus(ApiResponse.Status.NOT_VALID_PARAM); } ServiceResult result; switch (operation){ case HouseOperation.PASS: result = this .houseService.updateStatus(id, operation); break ; case HouseOperation.PULL_OUT: result = this .houseService.updateStatus(id, operation); break ; case HouseOperation.DELETE: result = this .houseService.updateStatus(id, operation); break ; case HouseOperation.RENT: result = this .houseService.updateStatus(id, operation); break ; default : return ApiResponse.ofStatus(ApiResponse.Status.BAD_REQUEST); } if (result == null ){ return ApiResponse.ofSuccess(null ); } return ApiResponse.ofMessage(HttpStatus.BAD_REQUEST.value(), result.getMessage()); } }
然而在我们设置PASS=1、PULL_OUT=2、DELETE=3、RENT=4的时候,发现点击上架按钮,前端显示的是已出租。所以找前端发现是这样设计的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 targets : 8 , render : function (data, type, row, meta ) { var html = '' ; if (data === 0 ) { html = '<td class="td-status"><span class="label label-danger radius">待审核</span></td>' ; } else if (data === 1 ) { html = '<td class="td-status"><span class="label label-success radius">已发布</span></td>' ; } else if (data === 2 ) { html = '<td class="td-status"><span class="label label-warning radius">已出租</span></td>' ; } else { html = '<td class="td-status"><span class="label label-danger radius">未知状态</span></td>' ; } return html; }
然后在house-list.js中我们又调整了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 function house_stop (obj, id ) { layer.confirm ('确认要下架吗?' , function (index ) { $.ajax ({ type : 'PUT' , url : '/admin/house/operate/' + id + '/' + '0' , success : function (data ) { if (data.code === 200 ) { $(obj).parents ("tr" ).find (".td-manage" ).prepend ('<a style="text-decoration:none"' + ' onClick="house_pass(this,id)" href="javascript:;" title="发布"><i' + ' class="Hui-iconfont"></i></a>' ); $(obj).parents ("tr" ).find (".td-status" ).html ('<span class="label label-defaunt radius">已下架</span>' ); $(obj).remove (); layer.msg ('已下架!' , {icon : 5 , time : 1000 }); reloadTable (); } else { layer.msg ('下架失败!' + data.message , {icon : 5 , time : 1000 }); } }, error : function (jqXHR, textStatus, errorThrown ) { console .log (jqXHR); layer.msg ('下架失败!' + jqXHR.responseText , {icon : 5 , time : 3000 }); } }); }); }
这里对下架的按钮修改为发送/admin/house/operate/' + id + '/' + '0'
的请求,也就是重新设置为状态待审核
现在点击下架按钮就变为状态:待审核,而不是已出租的状态了。
image-20220826171519957
二十七、基础功能分析
1、业务与功能分析
作为一个租房网站,要有基本的房源浏览功能,让用户在我们的网站可以浏览房源信息,并在找到目标房源后,能够查看房源的详细信息,这一章节就要实现租房网站的核心基础功能。
2、实现目标
二十八、基础功能实现
1、房源信息浏览功能
首先我们定义form结构体类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 package com.zryy.soufangtest.web.form;public class RentSearch { private String cityEnName; private String regionEnName; private String priceBlock; private String areaBlock; private int room; private int direction; private String keywords; private int rentWay = -1 ; private String orderBy = "lastUpdateTime" ; private String orderDirection = "desc" ; public int getDirection () { return direction; } public void setDirection (int direction) { this .direction = direction; } private int start = 0 ; private int size = 5 ; public String getCityEnName () { return cityEnName; } public void setCityEnName (String cityEnName) { this .cityEnName = cityEnName; } public int getStart () { return start > 0 ? start : 0 ; } public void setStart (int start) { this .start = start; } public int getSize () { if (this .size < 1 ) { return 5 ; } else if (this .size > 100 ) { return 100 ; } else { return this .size; } } public void setSize (int size) { this .size = size; } public String getRegionEnName () { return regionEnName; } public void setRegionEnName (String regionEnName) { this .regionEnName = regionEnName; } public String getPriceBlock () { return priceBlock; } public void setPriceBlock (String priceBlock) { this .priceBlock = priceBlock; } public String getAreaBlock () { return areaBlock; } public void setAreaBlock (String areaBlock) { this .areaBlock = areaBlock; } public int getRoom () { return room; } public void setRoom (int room) { this .room = room; } public String getKeywords () { return keywords; } public void setKeywords (String keywords) { this .keywords = keywords; } public int getRentWay () { if (rentWay > -2 && rentWay < 2 ) { return rentWay; } else { return -1 ; } } public void setRentWay (int rentWay) { this .rentWay = rentWay; } public String getOrderBy () { return orderBy; } public void setOrderBy (String orderBy) { this .orderBy = orderBy; } public String getOrderDirection () { return orderDirection; } public void setOrderDirection (String orderDirection) { this .orderDirection = orderDirection; } @Override public String toString () { return "RentSearch {" + "cityEnName='" + cityEnName + '\'' + ", regionEnName='" + regionEnName + '\'' + ", priceBlock='" + priceBlock + '\'' + ", areaBlock='" + areaBlock + '\'' + ", room=" + room + ", direction=" + direction + ", keywords='" + keywords + '\'' + ", rentWay=" + rentWay + ", orderBy='" + orderBy + '\'' + ", orderDirection='" + orderDirection + '\'' + ", start=" + start + ", size=" + size + '}' ; } }
然后定义HouseController类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 package com.zryy.soufangtest.web.controller.house;import com.zryy.soufangtest.base.ApiResponse;import com.zryy.soufangtest.base.RentValueBLock;import com.zryy.soufangtest.service.ServiceMultiResult;import com.zryy.soufangtest.service.ServiceResult;import com.zryy.soufangtest.service.house.IAddressService;import com.zryy.soufangtest.service.house.IHouseService;import com.zryy.soufangtest.web.dto.HouseDTO;import com.zryy.soufangtest.web.dto.SubwayDTO;import com.zryy.soufangtest.web.dto.SubwayStationDTO;import com.zryy.soufangtest.web.dto.SupportAddressDTO;import com.zryy.soufangtest.web.form.RentSearch;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.ModelAttribute;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.ResponseBody;import org.springframework.web.servlet.mvc.support.RedirectAttributes;import javax.servlet.http.HttpSession;import java.util.ArrayList;import java.util.List;@Controller public class HouseController { @Autowired private IAddressService addressService; @Autowired private IHouseService houseService; @GetMapping("address/support/cities") @ResponseBody public ApiResponse getSupportCities () { ServiceMultiResult<SupportAddressDTO> result = addressService.findAllCities(); if (result.getResultSize() == 0 ) { return ApiResponse.ofSuccess(ApiResponse.Status.NOT_FOUND); } return ApiResponse.ofSuccess(result.getResult()); } @GetMapping("address/support/regions") @ResponseBody public ApiResponse getSupportRegions (@RequestParam(name = "city_name") String cityEnName) { ServiceMultiResult<SupportAddressDTO> addressResult = addressService.findAllRegionsByCityName(cityEnName); if (addressResult.getResult() == null || addressResult.getTotal() < 1 ) { return ApiResponse.ofStatus(ApiResponse.Status.NOT_FOUND); } return ApiResponse.ofSuccess(addressResult.getResult()); } @GetMapping("address/support/subway/line") @ResponseBody public ApiResponse getSupportSubwayLine (@RequestParam(name = "city_name") String cityEnName) { List<SubwayDTO> subways = addressService.findAllSubwayByCity(cityEnName); if (subways.isEmpty()) { return ApiResponse.ofStatus(ApiResponse.Status.NOT_FOUND); } return ApiResponse.ofSuccess(subways); } @GetMapping("address/support/subway/station") @ResponseBody public ApiResponse getSupportSubwayStation (@RequestParam(name = "subway_id") Long subwayId) { List<SubwayStationDTO> stationDTOS = addressService.findAllStationBySubway(subwayId); if (stationDTOS.isEmpty()) { return ApiResponse.ofStatus(ApiResponse.Status.NOT_FOUND); } return ApiResponse.ofSuccess(stationDTOS); } @GetMapping("rent/house") public String rentHousePage (@ModelAttribute RentSearch rentSearch, Model model, HttpSession httpSession, RedirectAttributes redirectAttributes) { if (rentSearch.getCityEnName() ==null ){ String cityEnNameInSession = (String) httpSession.getAttribute("cityEnName" ); if (cityEnNameInSession == null ){ redirectAttributes.addAttribute("msg" ,"must_chose_city" ); return "redirect:/index" ; }else { rentSearch.setCityEnName(cityEnNameInSession); } }else { httpSession.setAttribute("cityEnName" ,rentSearch.getCityEnName()); } ServiceResult<SupportAddressDTO> city = addressService.queryCity(rentSearch.getCityEnName()); if ( !city.isSuccess()){ redirectAttributes.addAttribute("msg" ,"must_chose_city" ); return "redirect:/index" ; } model.addAttribute("currentCity" , city.getResult()); ServiceMultiResult<SupportAddressDTO> addressResult = addressService.findAllRegionsByCityName(rentSearch.getCityEnName()); if (addressResult.getTotal() < 1 ||addressResult.getResult() == null ){ redirectAttributes.addAttribute("msg" ,"must_chose_city" ); return "redirect:/index" ; } ServiceMultiResult<HouseDTO> serviceMultiResult = houseService.query(rentSearch); model.addAttribute("total" ,10 ); model.addAttribute("houses" ,new ArrayList <>()); if (rentSearch.getRegionEnName() ==null ){ rentSearch.setRegionEnName("*" ); } model.addAttribute("searchBody" ,rentSearch); model.addAttribute("regions" , addressResult.getResult()); model.addAttribute("priceBlocks" , RentValueBLock.PRICE_BLOCK); model.addAttribute("areaBlocks" ,RentValueBLock.AREA_BLOCK); model.addAttribute("currentPriceBlock" ,RentValueBLock.matchPrice(rentSearch.getPriceBlock())); model.addAttribute("currentAreaBlock" ,RentValueBLock.matchArea(rentSearch.getAreaBlock())); return "rent-list" ; } }
然后我们去新建一个区间范围类,参数,以及方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 package com.zryy.soufangtest.base;import com.google.common.collect.ImmutableMap;import java.util.Map;public class RentValueBLock { public static final Map<String, RentValueBLock> PRICE_BLOCK; public static final Map<String, RentValueBLock> AREA_BLOCK; public static final RentValueBLock ALL = new RentValueBLock ("*" ,-1 ,-1 ); static { PRICE_BLOCK = ImmutableMap.<String, RentValueBLock>builder() .put("*-1000" ,new RentValueBLock ("*-1000" ,-1 ,1000 )) .put("1000-3000" , new RentValueBLock ("1000-3000" ,1000 ,3000 )) .put("3000-*" , new RentValueBLock ("3000-*" ,3000 ,-1 )) .build(); AREA_BLOCK = ImmutableMap.<String, RentValueBLock>builder() .put("*-30" ,new RentValueBLock ("*-30" ,-1 ,30 )) .put("30-50" , new RentValueBLock ("30-50" ,30 ,50 )) .put("50-*" , new RentValueBLock ("50-*" ,50 ,-1 )) .build(); } private String key; private int min; private int max; public RentValueBLock (String key, int min, int max) { this .key = key; this .min = min; this .max = max; } public String getKey () { return key; } public void setKey (String key) { this .key = key; } public int getMin () { return min; } public void setMin (int min) { this .min = min; } public int getMax () { return max; } public void setMax (int max) { this .max = max; } public static RentValueBLock matchPrice (String key) { RentValueBLock block = PRICE_BLOCK.get(key); if (block == null ){ return ALL; } return block; } public static RentValueBLock matchArea (String key) { RentValueBLock block = AREA_BLOCK.get(key); if (block == null ){ return ALL; } return block; } }
然后返回到controller中去编辑范围区间的代码:
1 2 3 4 5 6 7 8 9 10 11 12 model.addAttribute("priceBlocks" , RentValueBLock.PRICE_BLOCK); model.addAttribute("areaBlocks" ,RentValueBLock.AREA_BLOCK); model.addAttribute("currentPriceBlock" ,RentValueBLock.matchPrice(rentSearch.getPriceBlock())); model.addAttribute("currentAreaBlock" ,RentValueBLock.matchArea(rentSearch.getAreaBlock())); return "rent-list" ;
因为我们的houseServiceImpl中的query并未具体实现,所以先测试一下其他代码,结果如下:
image-20220830185856406
其中model.addAttribute("total",10);设置了一个固定的值,原来是model.addAttribute("total",serviceMultiResult.getTotal());
因为结果是空的,所以getTotal的话会报错。暂时先设定一个静态值。
注意这里的一个细节
1 2 3 4 if (addressResult.getTotal() < 1 ||addressResult.getResult() == null ){ redirectAttributes.addAttribute("msg" ,"must_chose_city" ); return "redirect:/index" ; }
当判断条件成立的时候,那么url链接地址显示的是:http://localhost:8080/index?msg=must_chose_city
也就是redirect到了index.html页,然后添加了msg参数。重新请求url。
上面我们定义的query是一个空的,所以现在我们去定义具体的serviceImpl实现类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @Override public ServiceMultiResult<HouseDTO> query (RentSearch rentSearch) { Sort sort = new Sort (Sort.Direction.DESC, "lastUpdateTime" ); int page = rentSearch.getStart() / rentSearch.getSize(); Pageable pageable = new PageRequest (page, rentSearch.getSize(), sort); Specification<House> specification = (root, criteriaQuery, criteriaBuilder) ->{ Predicate predicate = criteriaBuilder.equal(root.get("status" ), HouseStatus.PASSES.getValue()); criteriaBuilder.and(predicate,criteriaBuilder.equal(root.get("cityEnName" ),rentSearch.getCityEnName())); return predicate; }; Page<House> houses = houseRepository.findAll(specification, pageable); ArrayList<HouseDTO> houseDTOS = new ArrayList <>(); houses.forEach(house -> { HouseDTO houseDTO = modelMapper.map(house, HouseDTO.class); houseDTO.setCover(this .cdnPrefix + house.getCover()); houseDTOS.add(houseDTO); }); return new ServiceMultiResult <>(houses.getTotalElements(), houseDTOS); }
serviceimpl实现了,那么我们返回controller层中对返回的结果进行修改(之前定义的是一个null值):
1 2 3 4 model.addAttribute("total" ,10 ); model.addAttribute("houses" ,new ArrayList <>());
修改后:
1 2 3 4 ServiceMultiResult<HouseDTO> serviceMultiResult = houseService.query(rentSearch); model.addAttribute("total" , serviceMultiResult.getTotal()); model.addAttribute("houses" , serviceMultiResult.getResult());
但是这里的houses并未把houseDetail的信息添加进去,所以导致了前端页面的错乱:
image-20220901173616255
1 2 3 2022-09-01 17:35:48.071 ERROR 29197 --- [nio-8080-exec-5] org.thymeleaf.TemplateEngine : [THYMELEAF][http-nio-8080-exec-5] Exception processing template "rent-list": Exception evaluating SpringEL expression: "house.houseDetail.subwayLineName != null" (template: "rent-list" - line 269, col 55) org.thymeleaf.exceptions.TemplateProcessingException: Exception evaluating SpringEL expression: "house.houseDetail.subwayLineName != null" (template: "rent-list" - line 269, col 55)
也就是说在传给前端参数的时候并没有house.houseDetail这个变量,所以导致了页面的错乱。
所以暂时把house.houseDetail这个属性给注释掉,因为点击房子后详情的信息才能够展示出来,在rent-list这个页面上并无房屋详情页的信息展示。后续会做一个封装的操作类把houseDetail传递给前端页面进行渲染。
我们对房源列表的排序设计一个排序类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 package com.zryy.soufangtest.base;import com.google.common.collect.Sets;import org.springframework.data.domain.Sort;import java.util.Set;public class HouseSort { public static final String DEFAULT_SORT_KEY = "lastUpdateTime" ; public static final String DISTANCE_TO_SUBWAY_KEY = "distanceToSubway" ; private static final Set<String> SORT_KEYS = Sets.newHashSet( DEFAULT_SORT_KEY, "createTime" , "price" , "area" , DISTANCE_TO_SUBWAY_KEY ); public static Sort generateSort (String key, String directionKey) { key = getSortKey(key); Sort.Direction direction = Sort.Direction.fromStringOrNull(directionKey); if (direction == null ){ direction = Sort.Direction.DESC; } return new Sort (direction, key); } public static String getSortKey (String key) { if (!SORT_KEYS.contains(key)){ key = DEFAULT_SORT_KEY; } return key; } }
这样在houseServiceimpl中我们就可以修改排序变量:
1 2 3 4 5 6 7 8 @Override public ServiceMultiResult<HouseDTO> query (RentSearch rentSearch) { int page = rentSearch.getStart() / rentSearch.getSize();
修改后的:
1 2 3 4 5 6 7 8 9 @Override public ServiceMultiResult<HouseDTO> query (RentSearch rentSearch) { Sort sort = HouseSort.generateSort(rentSearch.getOrderBy(), rentSearch.getOrderDirection()); int page = rentSearch.getStart() / rentSearch.getSize();
另外呢,我们现在查询出来展示的只是house的信息,但是对于houseDetail的信息并未查询出来。
然后就把houseId加进去了,然后通过id去查询出相关的详情信息,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 List<Long> houseIds = new ArrayList <>(); Map<Long, HouseDTO> idToHouseMap = Maps.newHashMap(); Page<House> houses = houseRepository.findAll(specification, pageable); ArrayList<HouseDTO> houseDTOS = new ArrayList <>(); houses.forEach(house -> { HouseDTO houseDTO = modelMapper.map(house, HouseDTO.class); houseDTO.setCover(this .cdnPrefix + house.getCover()); houseDTOS.add(houseDTO); houseIds.add(house.getId()); idToHouseMap.put(house.getId(),houseDTO); }); wrapperHouseList(houseIds,idToHouseMap);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private void wrapperHouseList (List<Long> houseIds, Map<Long, HouseDTO> idToHouseMap) { List<HouseDetail> details = houseDetailRepository.findAllByHouseIdIn(houseIds); details.forEach(houseDetail -> { HouseDTO houseDTO = idToHouseMap.get(houseDetail.getHouseId()); HouseDetailDTO houseDetailDTO = modelMapper.map(houseDetail, HouseDetailDTO.class); houseDTO.setHouseDetail(houseDetailDTO); }); List<HouseTag> houseTags = houseTagRepository.findAllByHouseIdIn(houseIds); houseTags.forEach(houseTag -> { HouseDTO houseDTO = idToHouseMap.get(houseTag.getHouseId()); houseDTO.getTags().add(houseTag.getName()); }); }
另外:
对于距离地铁最近的排序,要加一个判断条件,就是如果要按照地铁距离进行排序的话,那么就判断HouseSort.DISTANCE_to_SUBWAY_KEY字段必须大于-1,如果不添加这个判断的话,那么倒排的话,-1始终是在最前面的。
1 2 3 4 5 6 7 8 9 10 Specification<House> specification = (root, criteriaQuery, criteriaBuilder) -> { Predicate predicate = criteriaBuilder.equal(root.get("status"), HouseStatus.PASSES.getValue()); predicate = criteriaBuilder.and(predicate, criteriaBuilder.equal(root.get("cityEnName"), rentSearch.getCityEnName())); if (HouseSort.DISTANCE_TO_SUBWAY_KEY.equals(rentSearch.getOrderBy())) { predicate = criteriaBuilder.and(predicate, criteriaBuilder.gt(root.get(HouseSort.DISTANCE_TO_SUBWAY_KEY), -1)); } return predicate; };
二十九、房源信息详情页
上节中我们把房源信息列表查询出来了,那么接下来用户需要点击链接查看详情。
首先我们定义一个GetMapping的url:
1 @GetMapping("rent/house/show/{id}")
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @GetMapping("rent/house/show/{id}") public String show (@PathVariable(value = "id") , Long houseId) { if (houseId <= 0 ){ return "404" ; } ServiceResult<HouseDTO> serviceResult = houseService.findCompleteOne(houseId); if (!serviceResult.isSuccess()){ return "404" ; } HouseDTO houseDTO = serviceResult.getResult(); Map<SupportAddress.Level, SupportAddressDTO> addressMap = addressService.findCityAndRegion(houseDTO.getCityEnName(), houseDTO.getRegionEnName()); SupportAddressDTO city = addressMap.get(SupportAddress.Level.CITY); SupportAddressDTO region = addressMap.get(SupportAddress.Level.REGION); userService.findUserById(houseDTO.getAdminId()); return "house-detail" ; }
然后在userService接口类中去定义:
1 2 3 4 5 6 7 8 public interface IUserService { User findUserByUsername (String username) ; ServiceResult<UserDTO> findUserById (Long userId) ; }
但是这里UserDTO并未定义,所以我们去定义UserDTO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 package com.zryy.soufangtest.web.dto;public class UserDTO { private Long id; private String name; private String avatar; private String phoneNumber; private String lastLoginTime; public Long getId () { return id; } public void setId (Long id) { this .id = id; } public String getName () { return name; } public void setName (String name) { this .name = name; } public String getAvatar () { return avatar; } public void setAvatar (String avatar) { this .avatar = avatar; } public String getPhoneNumber () { return phoneNumber; } public void setPhoneNumber (String phoneNumber) { this .phoneNumber = phoneNumber; } public String getLastLoginTime () { return lastLoginTime; } public void setLastLoginTime (String lastLoginTime) { this .lastLoginTime = lastLoginTime; } }
然后去实现UserService的Impl实现类:
1 2 3 4 5 6 7 8 9 10 11 @Override public ServiceResult<UserDTO> findUserById (Long userId) { User user = userRepository.findOne(userId); if (user == null ){ return ServiceResult.notFound(); } UserDTO userDTO = modelMapper.map(user, UserDTO.class); return ServiceResult.of(userDTO); }
最终的houseController的show方法代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @GetMapping("rent/house/show/{id}") public String show (@PathVariable(value = "id") Long houseId, Model model) { if (houseId <= 0 ){ return "404" ; } ServiceResult<HouseDTO> serviceResult = houseService.findCompleteOne(houseId); if (!serviceResult.isSuccess()){ return "404" ; } HouseDTO houseDTO = serviceResult.getResult(); Map<SupportAddress.Level, SupportAddressDTO> addressMap = addressService.findCityAndRegion(houseDTO.getCityEnName(), houseDTO.getRegionEnName()); SupportAddressDTO city = addressMap.get(SupportAddress.Level.CITY); SupportAddressDTO region = addressMap.get(SupportAddress.Level.REGION); model.addAttribute("city" ,city); model.addAttribute("region" ,region); ServiceResult<UserDTO> userDTOServiceResult = userService.findUserById(houseDTO.getAdminId()); model.addAttribute("agent" ,userDTOServiceResult.getResult()); model.addAttribute("house" ,houseDTO); return "house-detail" ; }
最后的结果如下图所示:
image-20220907182800817
不过其中关于详情中,共有*套出租中
这个接口并未实现。这个会在es中进行聚合的字段。
三十、搜索引擎实现-业务与功能分析
搜索引擎实现
在我们网站实现了基础信息浏览功能 以后,用户就可以看到网站上的所有房源,但是在网站房源信息量爆炸 的时候,用户是很难找到自己想要的信息的,这时候,网站就必须要有站内搜索引擎功能 ,帮助用户根据用户自身的需求快速找到想要的房源信息。那么,我们这一章节实现网站的搜索引擎 。
实现目标
构建ES房源索引
基于ES构建搜索引擎
解决中文分词问题
Search-as-you-type(搜索提示)
使搜索引擎结果集最优
三十一、ES与MySQL技术选型
ElasticSearch与MySQL实现搜索对比
mysql是做数据存储,来实现数据事务的特性。那么使用mysql构建搜索引擎的困境:
如果想要复杂查询就需要写很复杂的sql语句去做联表查询:
1 2 select * from house as a left join house_detail as b on a.id = b.house_idwhere a.title like '%国贸%' AND b.round_service like '%故宫%'
那么就可以看到我们查询两个条件就需要用到了两个联表查询。非常麻烦。
如果还有其他条件,比如暖气、路线等等,那么就非常的麻烦了。
当然可以使用phoneix加mysql来实现搜索,具体的链接可以查看:https://baijiahao.baidu.com/s?id=1708618149251629430&wfr=spider&for=pc
Phoenix是一个基于HBase的开源SQL引擎 ,可以使用标准的JDBC
API代替HBase客户端API来创建表,插入数据,查询你的HBase数据,它是完全使用Java编写,作为HBase内嵌的JDBC驱动使用。
Phoenix查询引擎会将SQL查询转换为一个或多个HBase扫描,并编排执行以生成标准的JDBC结果集。
直接使用HBase
API、协同处理器与自定义过滤器,对于简单查询来说,其性能量级是毫秒,对于百万级别的行数来说,其性能量级
是秒。
三十二、索引结构设计
目标房源信息的索引结构如何设计?
首先要实现对信息的检索,哪些信息是用户比较需要的,是他们想要去检索的。
然后启动es和es-head
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 { "mappings" : { "house" : { "dynamic" : false , "properties" : { "title" : { "type" : "text" , "index" : "analyzed" } , "price" : { "type" : "integer" } , "area" : { "type" : "integer" } , "createTime" : { "type" : "date" , "format" : "strict_date_optional_time||epoch_millis" } , "lastUpdateTime" : { "type" : "date" , "format" : "strict_date_optional_time||epoch_millis" } , "cityEnName" : { "type" : "keyword" } , "regionEnName" : { "type" : "keyword" } , "direction" : { "type" : "integer" } , "distanceToSubway" : { "type" : "integer" } , "subwayLineName" : { "type" : "keyword" } , "subwayStationName" : { "type" : "keyword" } , "tags" : { "type" : "text" } , "street" : { "type" : "keyword" } , "district" : { "type" : "keyword" } , "description" : { "type" : "text" , "index" : "analyzed" } , "layoutDesc" : { "type" : "text" , "index" : "analyzed" } , "traffic" : { "type" : "text" , "index" : "analyzed" } , "roundService" : { "type" : "text" , "index" : "analyzed" } , "rentWay" : { "type" : "integer" } } } } }
基本的索引结构就如上面这个样子。
另外呢还不能直接创建,目前只有一个节点,默认是有5个分片、1个备份的。如果备份没有另外一台机器去存放的话,就会报状态是黄色。所以呢我们需要在前面添加一个:
1 2 3 "settings" : { "number_of_replicas" : 0 }
所以完整的json是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 { "settings" : { "number_of_replicas" : 0 } , "mappings" : { "house" : { "dynamic" : false , "properties" : { "houseId" : { "type" : "long" } , "title" : { "type" : "text" , "index" : "analyzed" } , "price" : { "type" : "integer" } , "area" : { "type" : "integer" } , "createTime" : { "type" : "date" , "format" : "strict_date_optional_time||epoch_millis" } , "lastUpdateTime" : { "type" : "date" , "format" : "strict_date_optional_time||epoch_millis" } , "cityEnName" : { "type" : "keyword" } , "regionEnName" : { "type" : "keyword" } , "direction" : { "type" : "integer" } , "distanceToSubway" : { "type" : "integer" } , "subwayLineName" : { "type" : "keyword" } , "subwayStationName" : { "type" : "keyword" } , "tags" : { "type" : "text" } , "street" : { "type" : "keyword" } , "district" : { "type" : "keyword" } , "description" : { "type" : "text" , "index" : "analyzed" } , "layoutDesc" : { "type" : "text" , "index" : "analyzed" } , "traffic" : { "type" : "text" , "index" : "analyzed" } , "roundService" : { "type" : "text" , "index" : "analyzed" } , "rentWay" : { "type" : "integer" } } } } }
注意上面的是number_of_replicas,而不是number_of_replica。
然后在IDEA编译器中发送request请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 PUT http: Accept: text/json { "settings" : { "number_of_replicas" : 0 } , "mappings" : { "house" : { "dynamic" : false , "properties" : { "houseId" : { "type" : "Long" } , "title" : { "type" : "text" , "index" : "analyzed" } , "price" : { "type" : "integer" } , "area" : { "type" : "integer" } , "createTime" : { "type" : "date" , "format" : "strict_date_optional_time||epoch_millis" } , "lastUpdateTime" : { "type" : "date" , "format" : "strict_date_optional_time||epoch_millis" } , "cityEnName" : { "type" : "keyword" } , "regionEnName" : { "type" : "keyword" } , "direction" : { "type" : "integer" } , "distanceToSubway" : { "type" : "integer" } , "subwayLineName" : { "type" : "keyword" } , "subwayStationName" : { "type" : "keyword" } , "tags" : { "type" : "text" } , "street" : { "type" : "keyword" } , "district" : { "type" : "keyword" } , "description" : { "type" : "text" , "index" : "analyzed" } , "layoutDesc" : { "type" : "text" , "index" : "analyzed" } , "traffic" : { "type" : "text" , "index" : "analyzed" } , "roundService" : { "type" : "text" , "index" : "analyzed" } , "rentWay" : { "type" : "integer" } } } } }
得到的结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 http: HTTP/1.1 200 OK Warning: 299 Elasticsearch-5.6 .1 -667 b497 "Content type detection for rest requests is deprecated. Specify the content type using the [Content-Type] header." "Tue, 13 Sep 2022 06:14:50 GMT" Warning: 299 Elasticsearch-5.6 .1 -667 b497 "Expected a boolean [true/false] for property [index] but got [analyzed]" "Tue, 13 Sep 2022 06:14:50 GMT" content-type: application/json; charset=UTF-8 { "acknowledged" : true , "shards_acknowledged" : true , "index" : "house" } Response file saved. > 2022 -09 -13 T141450.200 .json Response code: 200 (OK); Time: 392 ms; Content length: 64 bytes
在es-head中查看结果:
image-20220913141723776
发现house索引已经被创建成功。
然后将这个house_index_mapping.json保存在db文件夹下。
另外,我们不能直接使用es把数据存入进去,因为es是弱事务性的,在有增删改的时候会导致其产生很多乱糟糟的数据,当然这里我们使用es只是为了查出其索引来,并不是查询出其每个字段来。所以es对于我们来说只是检索其id,所以我们要得到的其实是他们的house_id,然后再到mysql中去查询出来。
另外呢我们需要单独在java代码中新建一个类去操纵es代码,而不是去操作json代码。
我们在service中新建一个search包,并新建一个索引结构模板:HouseIndexTemplate
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 package com.zryy.soufangtest.service.search;import java.util.Date;import java.util.List;public class HouseIndexTemplate { private Long houseId; private String title; private int price; private int area; private Date createTime; private Date lastUpdateTime; private String cityEnName; private String regionEnName; private int direction; private int distanceToSubway; private String subwayLineName; private String subwayStationName; private String street; private String district; private String description; private String layoutDesc; private String traffic; private String roundService; private int rentWay; private List<String> tags; public Long getHouseId () { return houseId; } public void setHouseId (Long houseId) { this .houseId = houseId; } public String getTitle () { return title; } public void setTitle (String title) { this .title = title; } public int getPrice () { return price; } public void setPrice (int price) { this .price = price; } public int getArea () { return area; } public void setArea (int area) { this .area = area; } public Date getCreateTime () { return createTime; } public void setCreateTime (Date createTime) { this .createTime = createTime; } public Date getLastUpdateTime () { return lastUpdateTime; } public void setLastUpdateTime (Date lastUpdateTime) { this .lastUpdateTime = lastUpdateTime; } public String getCityEnName () { return cityEnName; } public void setCityEnName (String cityEnName) { this .cityEnName = cityEnName; } public String getRegionEnName () { return regionEnName; } public void setRegionEnName (String regionEnName) { this .regionEnName = regionEnName; } public int getDirection () { return direction; } public void setDirection (int direction) { this .direction = direction; } public int getDistanceToSubway () { return distanceToSubway; } public void setDistanceToSubway (int distanceToSubway) { this .distanceToSubway = distanceToSubway; } public String getSubwayLineName () { return subwayLineName; } public void setSubwayLineName (String subwayLineName) { this .subwayLineName = subwayLineName; } public String getSubwayStationName () { return subwayStationName; } public void setSubwayStationName (String subwayStationName) { this .subwayStationName = subwayStationName; } public String getStreet () { return street; } public void setStreet (String street) { this .street = street; } public String getDistrict () { return district; } public void setDistrict (String district) { this .district = district; } public String getDescription () { return description; } public void setDescription (String description) { this .description = description; } public String getLayoutDesc () { return layoutDesc; } public void setLayoutDesc (String layoutDesc) { this .layoutDesc = layoutDesc; } public String getTraffic () { return traffic; } public void setTraffic (String traffic) { this .traffic = traffic; } public String getRoundService () { return roundService; } public void setRoundService (String roundService) { this .roundService = roundService; } public int getRentWay () { return rentWay; } public void setRentWay (int rentWay) { this .rentWay = rentWay; } public List<String> getTags () { return tags; } public void setTags (List<String> tags) { this .tags = tags; } }
另外我们还需要创建一个常量类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package com.zryy.soufangtest.service.search;public class HouseIndexKey { public static final String HOUSE_ID = "houseId" ; public static final String TITLE = "title" ; public static final String PRICE = "price" ; public static final String AREA = "area" ; public static final String CREATE_TIME = "createTime" ; public static final String LAST_UPDATE_TIME = "lastUpdateTime" ; public static final String CITY_EN_NAME = "cityEnName" ; public static final String REGION_EN_NAME = "regionEnName" ; public static final String DIRECTION = "direction" ; public static final String DISTANCE_TO_SUBWAY = "distanceToSubway" ; public static final String STREET = "street" ; public static final String DISTRICT = "district" ; public static final String DESCRIPTION = "description" ; public static final String LAYOUT_DESC = "layoutDesc" ; public static final String TRAFFIC = "traffic" ; public static final String ROUND_SERVICE = "roundService" ; public static final String RENT_WAY = "rentWay" ; public static final String SUBWAY_LINE_NAME = "subwayLineName" ; public static final String SUBWAY_STATION_NAME = "subwayStationName" ; public static final String TAGS = "tags" ; }
建立这样一个类方便我们后面在java代码中使用代码的形式去编辑,而不是使用json的方式去操作。
ES更适合用来检索,Mysql更适合用来做存储
三十三、索引构建-核心逻辑
1、新建esConfig类
然后设置目标地址 注意这里使用tcp的9300,而不是http的9200
原因不知.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Configuration public class ElasticSearchConfig { @Bean public TransportClient esClient () throws UnknownHostException{ Settings settings = Settings.builder() .put("cluster.name" , "elasticsearch" ) .put("client.transport.sniff" , true ) .build(); InetSocketTransportAddress master = new InetSocketTransportAddress ( InetAddress.getByName("127.0.0.1" ), 9300 ); TransportClient client = new PreBuiltTransportClient (settings) .addTransportAddress(master); return client; } }
然后新建ISearchService类,暂时增加检索、移除接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public interface ISearchService { void index (Long houseId) ; void remove (Long houseId) ; }
然后编辑SearchServiceImpl实现类。
注意要在里面添加logger的话,使用
private static final Logger logger =
LoggerFactory.getLogger(ISearchService.class);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public class SearchServiceImpl implements ISearchService { private static final Logger logger = LoggerFactory.getLogger(ISearchService.class); @Autowired private HouseRepository houseRepository; @Autowired private ModelMapper modelMapper; @Override public void index (Long houseId) { House house = houseRepository.findOne(houseId); if (house == null ){ logger.error("index house {} does not exist" ,houseId); return ; } HouseIndexTemplate indexTemplate = new HouseIndexTemplate (); modelMapper.map(house,indexTemplate); } @Override public void remove (Long houseId) { } }
另外呢我们还是用了log把错误输出,使用了modelMapper来把house类转换成houseIndexTemplate类。
另外我们不仅仅有index检索索引、移除索引,还得有创建索、更新索引。
1 2 3 4 5 6 7 8 9 private boolean create (HouseIndexTemplate houseIndexTemplate) {} private boolean update (HouseIndexTemplate houseIndexTemplate) {} private boolean deleteAndCreate (HouseIndexTemplate houseIndexTemplate) {}
这时候呢,我们需要用到刚才定义的esClient
1 2 @Autowired private TransportClient esclient;
这里关联到了我们之前定义的ElasticSearchConfig类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Configuration public class ElasticSearchConfig { @Bean public TransportClient esClient () throws UnknownHostException{ Settings settings = Settings.builder() .put("cluster.name" , "elasticsearch" ) .put("client.transport.sniff" , true ) .build(); InetSocketTransportAddress master = new InetSocketTransportAddress ( InetAddress.getByName("127.0.0.1" ), 9300 ); TransportClient client = new PreBuiltTransportClient (settings) .addTransportAddress(master); return client; } }
定义索引名称,定义索引类型,然后在
1 2 3 4 private boolean create (HouseIndexTemplate houseIndexTemplate) { esclient.prepareIndex(INDEX_NAME,INDEX_TYPE) .setSource() }
需要使用TransportClient的esclient的prepareIndex方法,传入的参数是index_name、index_type,然后使用setSource来定义json代码,这里我们使用定义的HouseIndexTemplate类来使用json方法。我们使用obejctMapper来转换json。
所以需要自动注解:
1 2 @Autowired private ObjectMapper objectMapper;
使用objectMapper.writeValueAsBytes(houseIndexTemplate)来对houseIndexTemplate转换格式,另外在es5.6中需要转换成json格式,所以我们在setSource中定义了一个XContentType.JSON的参数,然后其返回值是一个IndexResponse我们获取到这个response。
在下面进行判断,其有一个response.status,然后我们使用其与RestStatus.CREATED做比较。
其中对于RestStatus.CREATED,其enum值有很多。比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public enum RestStatus { CONTINUE(100 ), SWITCHING_PROTOCOLS(101 ), OK(200 ), CREATED(201 ), ACCEPTED(202 ), NON_AUTHORITATIVE_INFORMATION(203 ), NO_CONTENT(204 ), RESET_CONTENT(205 ), PARTIAL_CONTENT(206 ), MULTI_STATUS(207 ), MULTIPLE_CHOICES(300 ), MOVED_PERMANENTLY(301 ), FOUND(302 ), SEE_OTHER(303 ), NOT_MODIFIED(304 ), USE_PROXY(305 ), TEMPORARY_REDIRECT(307 ), BAD_REQUEST(400 ),
都是一些状态码什么的。
完整的create方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private boolean create (HouseIndexTemplate houseIndexTemplate) { try { IndexResponse response = esclient.prepareIndex(INDEX_NAME,INDEX_TYPE) .setSource(objectMapper.writeValueAsBytes(houseIndexTemplate), XContentType.JSON).get(); logger.debug("Create index with house: " + houseIndexTemplate.getHouseId()); if (response.status() == RestStatus.CREATED){ return true ; }else { return false ; } } catch (JsonProcessingException e) { logger.error("Error to index house " + houseIndexTemplate.getHouseId(), e); return false ; } }
然后我们去定义update方法,其逻辑同create大同小异。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private boolean update (String esId,HouseIndexTemplate houseIndexTemplate) { try { UpdateResponse response = esclient.prepareUpdate(INDEX_NAME,INDEX_TYPE,esId) .setDoc(objectMapper.writeValueAsBytes(houseIndexTemplate), XContentType.JSON).get(); logger.debug("update index with house: " + houseIndexTemplate.getHouseId()); if (response.status() == RestStatus.CREATED){ return true ; }else { return false ; } } catch (JsonProcessingException e) { logger.error("Error to update house " + houseIndexTemplate.getHouseId(), e); return false ; } }sss
只不过esclient不使用prepareIndex了而是使用prepareUpdate,然后需要多传入一个esId的参数,另外在json串那里也不是使用setSource,而是使用setDoc,另外返回的是UpdateResponse也不是IndexResponse了。另外RestStatus.CREATED也需要换成RestStatus.OK。
然后就是编写delete方法了,在5.0版本已经没有delete相关的直接接口了,所以我们使用deleteByQuery方法。
我们需要使用new
RequestBuilder方法,然后使用其filter方法,filter方法中query出来的东西都会被删除掉,
souce方法返回的是DeleteByQueryRequestBuilder,然后需要//通过buider.get来获得返回值,通过返回值去得到已删除的数量(long
deleted = response.getDeleted()
)所以我们需要在参数中接收一个总的需要删除的数量,最后再去做一个比较,如果不相等的话就报一个logger的警告,如果相等,那么就去调用create方法,把houseId当做参数传入进去创建。
最后的deleteAndCreate方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private boolean deleteAndCreate (long totalHit,HouseIndexTemplate houseIndexTemplate) { DeleteByQueryRequestBuilder builder = DeleteByQueryAction.INSTANCE .newRequestBuilder(esclient) .filter(QueryBuilders.termQuery(HouseIndexKey.HOUSE_ID, houseIndexTemplate.getHouseId())) .source(INDEX_NAME); logger.debug("Delete by query for house:" + builder); BulkByScrollResponse response = builder.get(); long deleted = response.getDeleted(); if (deleted != totalHit){ logger.warn("Need delete {} , but {} was deleted" ,totalHit, deleted); return false ; }else { return create(houseIndexTemplate); } }
三十四、索引构建-核心逻辑2
在我们的houseIndexTemplate中除了houseId之外,还有一些关于subway、subwayStation等在其他的表中。我们需要查出来,然后放进来。
所以我们把:
1 2 3 4 5 @Autowired private HouseDetailRepository houseDetailRepository;@Autowired private HouseTagRepository houseTagRepository;
两个repository先自动装配进来。
然后把相关的信息查询出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 HouseDetail houseDetail = houseDetailRepository.findByHouseId(houseId);if (houseDetail == null ){ } modelMapper.map(houseDetail,indexTemplate); List<HouseTag> tags = houseTagRepository.findAllById(houseId); if (tags !=null && !tags.isEmpty()){ List<String> tagStrings = new ArrayList <>(); tags.forEach(houseTag -> tagStrings.add(houseTag.getName())); indexTemplate.setTags(tagStrings); }
从这里来说,整个业务逻辑的查询就完成了,然后需要我们去查一下异常情况,比如数据是否在es中已经存在了,再去决定进行下一步的操作。
然后我们去esClient查询
1 2 3 4 5 6 7 8 SearchRequestBuilder requestBuilder = this .esclient.prepareSearch(INDEX_NAME).setTypes(INDEX_TYPE) .setQuery(QueryBuilders.termQuery(HouseIndexKey.HOUSE_ID, houseId)); logger.debug(requestBuilder.toString()); SearchResponse searchResponse = requestBuilder.get();long totalHit = searchResponse.getHits().totalHits;
然后再去判断totalHit命中的数量,如果0则创建、如果1则更新、如果其他则删除重建。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 @Override public boolean index (Long houseId) { House house = houseRepository.findOne(houseId); if (house == null ){ logger.error("index house {} does not exist" ,houseId); return false ; } HouseIndexTemplate indexTemplate = new HouseIndexTemplate (); modelMapper.map(house,indexTemplate); HouseDetail houseDetail = houseDetailRepository.findByHouseId(houseId); if (houseDetail == null ){ } modelMapper.map(houseDetail,indexTemplate); List<HouseTag> tags = houseTagRepository.findAllById(houseId); if (tags !=null && !tags.isEmpty()){ List<String> tagStrings = new ArrayList <>(); tags.forEach(houseTag -> tagStrings.add(houseTag.getName())); indexTemplate.setTags(tagStrings); } SearchRequestBuilder requestBuilder = this .esclient.prepareSearch(INDEX_NAME).setTypes(INDEX_TYPE) .setQuery(QueryBuilders.termQuery(HouseIndexKey.HOUSE_ID, houseId)); logger.debug(requestBuilder.toString()); SearchResponse searchResponse = requestBuilder.get(); long totalHit = searchResponse.getHits().totalHits; boolean success; if (totalHit == 0 ){ success = create(indexTemplate); }else if (totalHit ==1 ){ String esId = searchResponse.getHits().getAt(0 ).getId(); success = update(esId,indexTemplate); }else { success = deleteAndCreate(totalHit,indexTemplate); } if (success){ logger.debug("Index success with house " + houseId); } return success; }
测试的时候报了这个错误:
NoNodeAvailableException None of the configured nodes are
available:
然后去追踪问题发现:
image-20220914171955528
image-20220914172248936
没有匹配上,所以修改elasticsearch为xunwu,再次尝试。
image-20220914174557710
测试成功,总结:
在mapping中我们定义的是索引的名称:
然后在代码中去定义索引的类型,这里当然是可以随意输入的,经测试输入house2,然后可以创建。
1 2 SearchRequestBuilder requestBuilder = this .esclient.prepareSearch(INDEX_NAME).setTypes(INDEX_TYPE) .setQuery(QueryBuilders.termQuery(HouseIndexKey.HOUSE_ID, houseId));
哦对了,在test中因为我们使用的是application-test.properties
,所以是使用的h2内存数据库,直接在db文件夹下的sql文件修改就可以。
然后我们去定义remove方法逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Override public void remove (Long houseId) { DeleteByQueryRequestBuilder builder = DeleteByQueryAction.INSTANCE .newRequestBuilder(esclient) .filter(QueryBuilders.termQuery(HouseIndexKey.HOUSE_ID, houseId)) .source(INDEX_NAME); logger.debug("Delete by query for house: " + builder); BulkByScrollResponse response = builder.get(); long deleted = response.getDeleted(); logger.debug("Delete total: " + deleted); }
测试程序:
1 2 3 4 5 6 @Test public void testRemove () { Long houseId = 15L ; searchService.remove(houseId); }
测试成功,删除。
index 和remove接口是在特定情况下去执行的
首先数组刚增加的时候,是不需要建立索引的,因为管理员需要进行审核,数据经过出租、下架都是一个状态的改变,所以在接口的必要路径去改变就可以了。
在HouseServiceImpl的update方法中:
1 2 3 4 if (house.getStatus() == HouseStatus.PASSES.getValue()){ searchService.index(house.getId()); }
另外在HouseServiceImpl的updateStatus方法中,在更新状态的时候也要去对索引进行操作。
1 2 3 4 5 6 if (status == HouseStatus.PASSES.getValue()){ searchService.index(id); } else { searchService.remove(id); }
页面测试数据正常,不过对于下架上架这里,有一些0、1、2、3的操作码不太正确,需要进行调整。
另外对于SearchServiceImpl中
image-20220914202807798
这里的异常情况需要进行调整,如果houseDetail==null,return
false虽然没办法走下面的代码逻辑,但是前端返回值仍然是发布成功的状态。当然,对于索引的操作肯定不会去执行。但是在前端页面中有一些错误的显示,未作调整。
不止测试了下架上架功能,同样的测试了编辑功能,都可以成功的创建索引。
本节我们实现了通过java代码对es索引操作,下一节就要实现异步的创建索引,也就是增加kafka消息队列。
三十五、索引构建-消息中间件
Kafka,一个分布式发布订阅消息系统,同时也是一个强大的消息队列,可以处理大量的数据,可以将一个消息从一个端点传送到另外一个端点。
Kafka适合离线和在线的消息消费,消息是保存在磁盘上的,和其他消息队列比较:很多队列是基于内存的模型来制作的。
Spark Streaming实时流处理项目实战,可以学习一下
kafka是基于zookeeper的,zookeeper是一个分布式配置,和同步服务,zookeeper是kafka代理和消费者之间的协调接口,kafka就是通过zookeeper集群来共享信息的。
我们进入zookeeper的文件夹下,然后先启动zookeeper服务,
其默认是自动开放2181端口的。
1 ./bin/zookeeper-server-start.sh config/zookeeper.properties
启动后的结果:
当然我们可以使用命令在后台运行。
1 nohup ./bin/zookeeper-server-start.sh config/zookeeper.peoperties &
然后
去查看后台运行输出的情况。
然后就是启动kafka,启动一个单实例
1 ./bin/kafka-server-start.sh config/server.properties
结果图如下:
我们可以查看server.properties的详情:
其中broker.id在整个集群中是唯一值, (broker:经纪人、掮客)
另外还有,如果想让外网访问的话,可以把listeners=PLAINTEXT://localhost:9092打开,然后把localhost换成自己的外网ip即可。
advertised.listeners=PLAINTEXT://localhost:9092中的localhost也替换。
接下来我们创建一个topic:
1 ./bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic hello
1 2 (base) zhiqiang@zhiQiangdeMacBook-Pro kafka_2.11-1.0.0 % ./bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic hello Created topic "hello".
获取主题列表:
1 2 3 ./bin/kafka-topics.sh --list --zookeeper localhost:2181 //结果就是 hello
模拟生产消息(9092是kafka的地址)
1 2 3 (base) zhiqiang@zhiQiangdeMacBook-Pro kafka_2.11-1.0.0 % ./bin/kafka-console-producer.sh --broker-list localhost:9092 --topic hello > hello zhiqiang > hello zhiqiang , two
模拟消费者(2181是zookeeper地址,添加参数--from-beginning是从头开始消费消息)
1 2 3 4 (base) zhiqiang@zhiQiangdeMacBook-Pro kafka_2.11-1.0.0 % ./bin/kafka-console-consumer.sh --zookeeper localhost:2181 --topic hello --from-beginning Using the ConsoleConsumer with old consumer is deprecated and will be removed in a future major release. Consider using the new consumer by passing [bootstrap-server] instead of [zookeeper]. hello zhiqiang hello zhiqiang , two
然后我们同时启动生产消息和消费消息,界面如下:
image-20220916140729770
我们可以实时的把消息发送到中间件中,然后再实时的消费掉。
我们用的话怎么用呢?
用户在创建数据,我们把数据发送到kafka的队列,通过异步的模型把消息消费掉,再去构建我们的索引。dengzhe
三十六、索引构建-代码实践
代码水平的高低不在于能不能实现功能,而在于对异常情况的处理水平。
当管理员在处理数据的时候,比如说上架下架的时候,es有一些不稳定,或者说当下时间会很久的话,就很影响用户的一个体验。这时候就需要考虑这个情况,比如说index创建索引的时候需要时间很久,用户不希望等待这么久,这时候就可能需要消息队列来进行。
然后进行kafka的代码实践:
首先得有kafka的properties的配置:
1 2 3 spring.kafka.bootstrap-servers =localhost:9092 spring.kafka.consumer.group-id =xunwu
kafka得有一个group-id,这里我们定义为xunwu
然后去searchServiceImpl中构建kafka相关的代码逻辑:
1 2 private static final String INDEX_TOPIC = "house_ build" ;
另外呢,还需要去定义一个kafka的template:
1 2 @Autowired private KafkaTemplate<String, String> kafkaTemplate;
编辑一个index_topic的方法类,传入的是一个消息结构体(content)
1 2 3 4 5 @KafkaListener(topics = INDEX_TOPIC) private void handleMessage (String content) {}
另外呢,因为我们传入的是一个content,也就是请求消息结构体,index和remove的操作都包含在其中,所以我们新建一个HouseIndexMessage类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package com.zryy.soufangtest.service.search;public class HouseIndexMessage { public static final String REMOVE = "remove" ; public static final String INDEX = "index" ; public static final int MAX_RETRY = 3 ; public HouseIndexMessage () { } private Long houseId; private String operation; private int retry = 0 ; public HouseIndexMessage (Long houseId, String operation, int retry) { this .houseId = houseId; this .operation = operation; this .retry = retry; } public Long getHouseId () { return houseId; } public void setHouseId (Long houseId) { this .houseId = houseId; } public String getOperation () { return operation; } public void setOperation (String operation) { this .operation = operation; } public int getRetry () { return retry; } public void setRetry (int retry) { this .retry = retry; } }
里面主要的字段是设置了houseId、operation、retry,
虽然我们传了houseId,但是实质上我们其实是对message进行的操作。
然后我们去定义了handleMessage方法,使用objectMapper来读取content消息结构体,转换成HouseIndexMessage类,
然后对其operation进行不同的操作:(index或者remove)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @KafkaListener(topics = INDEX_TOPIC) private void handleMessage (String content) { try { HouseIndexMessage message = objectMapper.readValue(content, HouseIndexMessage.class); switch (message.getOperation()){ case HouseIndexMessage.INDEX: this .createOrUpdateIndex(message); break ; case HouseIndexMessage.REMOVE: this .removeIndex(message); default : logger.warn("Not support message content" + content); break ; } } catch (IOException e) { e.printStackTrace(); logger.error("Cannot parse json for " +content , e); } }
这样呢,我们新建了createOrUpdateIndex和removeIndex方法。
需要把之前的index方法直接嵌套在里面:
然后在这里:
1 2 3 4 5 6 7 8 9 private void createOrUpdateIndex (HouseIndexMessage message) { Long houseId = message.getHouseId(); House house = houseRepository.findOne(houseId); if (house == null ){ logger.error("index house {} does not exist" ,houseId); this .index(houseId, message.getRetry() + 1 ); return false ; }
然后在this.index中重新进行消息入列。所以这里的逻辑是我们增加了一个retry的次数,不然的话就是一个死循环了。
我们定义了这样的两个index函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Override public void index(Long houseId) { this.index(houseId,0); } private void index(Long houseId, int retry){ if(retry > HouseIndexMessage.MAX_RETRY){ logger.error("Retry index times over 3 for house: " + houseId + "Please check it!" ); return; } HouseIndexMessage message = new HouseIndexMessage(houseId, HouseIndexMessage.INDEX, retry); try { //kafka重新对topic发送消息体 kafkaTemplate.send(INDEX_TOPIC, objectMapper.writeValueAsString(message)); } catch (JsonProcessingException e) { logger.error("Json encode error for " + message); } }
即,在ISearchService定义的接口index中,我们去访问自己新定义的index(带了retry次数)然后去判断retry次数有没有超过设置的最大次数,如果没有超过那么就去执行kafkaTemplate.send(INDEX_TOPIC,
objectMapper.writeValueAsString(message));
然后执行kafkaTemplate.send后,又发送给了
1 kafkaTemplate.send(INDEX_TOPIC, objectMapper.writeValueAsString(message));
1 2 3 @KafkaListener(topics = INDEX_TOPIC) private void handleMessage (String content) {}
因为send的时候是发送了一个参数,发送给了Index_topic,所以在handleMessage函数中我们又设置了@KafkaListener监听。
这样的话我们的createOrUpdate就实现了,接下来去实现remove的逻辑。
remove同样是一样的逻辑:
我们定义了removeIndex:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private void removeIndex (HouseIndexMessage message) { Long houseId = message.getHouseId(); DeleteByQueryRequestBuilder builder = DeleteByQueryAction.INSTANCE .newRequestBuilder(esclient) .filter(QueryBuilders.termQuery(HouseIndexKey.HOUSE_ID, houseId)) .source(INDEX_NAME); logger.debug("Delete by query for house: " + builder); BulkByScrollResponse response = builder.get(); long deleted = response.getDeleted(); logger.debug("Delete total: " + deleted); if ( deleted <= 0 ){ this .remove(houseId, message.getRetry() +1 ); } }
然后去获得了responese.getDeleted,得到了删除的数量,如果删除失败的话,其返回值是负值的,所以进行了this.remove重试的操作。
测试通过,另外需要注意的一点是,在switch的时候,要注意加上break;另外对于kafka的index_topic不能中间有空格,不然会导致错误:
1 Error while fetching metadata with correlation id 0 INVALID_TOPIC_EXCEPTION
三十七、搜索引擎实现
搜索功能
搜索业务简单集成ES
ES与MySQL结合
正常情况下是通过mysql去查出相关信息的,但是在关键词搜索框中,需要通过es去查询出相关的信息,再通过mysql查询出更多详细的信息进行展示。
首先去定义一下我们的接口ISearchService
1 2 3 4 5 6 7 ServiceMultiResult<Long> query (RentSearch rentSearch) ;
然后,就定义实现类:
首先构建QueryBuilders.boolQuery,(QueryBuilder
是es中提供的一个查询接口, 可以对其进行参数设置来进行数据查询)
然后对构建好的boolQuery进行filter
对city_en_name和Region_en_name
面积和价格范围区间进行筛选等等
然后使用esclient进行查询,然后返回结果等等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 @Override public ServiceMultiResult<Long> query (RentSearch rentSearch) { BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); boolQuery.filter( QueryBuilders.termQuery(HouseIndexKey.CITY_EN_NAME,rentSearch.getCityEnName()) ); if (rentSearch.getRegionEnName() !=null && !"*" .equals(rentSearch.getRegionEnName())){ boolQuery.filter( QueryBuilders.termQuery(HouseIndexKey.REGION_EN_NAME,rentSearch.getRegionEnName()) ); } RentValueBLock area = RentValueBLock.matchArea(rentSearch.getAreaBlock()); if (!RentValueBLock.ALL.equals(area)){ RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery(HouseIndexKey.AREA); if (area.getMax() > 0 ){ rangeQueryBuilder.lte(area.getMax()); } if (area.getMin() > 0 ){ rangeQueryBuilder.gte(area.getMin()); } boolQuery.filter(rangeQueryBuilder); } RentValueBLock price = RentValueBLock.matchArea(rentSearch.getPriceBlock()); if (!RentValueBLock.ALL.equals(price)){ RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery(HouseIndexKey.PRICE); if (price.getMax() > 0 ){ rangeQueryBuilder.lte(price.getMax()); } if (price.getMin() > 0 ){ rangeQueryBuilder.gte(price.getMin()); } boolQuery.filter(rangeQueryBuilder); } if (rentSearch.getDirection() > 0 ){ boolQuery.filter( QueryBuilders.termQuery(HouseIndexKey.DIRECTION, rentSearch.getDirection()) ); } SearchRequestBuilder requestBuilder = this .esclient.prepareSearch(INDEX_NAME) .setTypes(INDEX_TYPE) .setQuery(boolQuery) .addSort( HouseSort.getSortKey(rentSearch.getOrderBy()), SortOrder.fromString(rentSearch.getOrderDirection()) ) .setFrom(rentSearch.getStart()) .setSize(rentSearch.getSize()); logger.debug("es请求:\n" + requestBuilder.toString()); List<Long> houseIds = new ArrayList <>(); SearchResponse response = requestBuilder.get(); if (response.status() != RestStatus.OK){ logger.warn("Search status is no ok for" + requestBuilder); return new ServiceMultiResult <>(0 ,houseIds); } response.getHits().forEach(hit ->{ System.out.println(hit.getSource()); houseIds.add(Longs.tryParse(String.valueOf(hit.getSource().get(HouseIndexKey.HOUSE_ID)))); }); return new ServiceMultiResult <>(response.getHits().totalHits,houseIds); }
另外在我们的controller中rent/house接口下,我们调用的是
1 ServiceMultiResult<HouseDTO> serviceMultiResult = houseService.query(rentSearch);
这里是查询的mysql数据库,刚才我们定义的并不是houseService的query,而是searchService的query。
所以我们要想办法让其结合起来。
我们在HouseServiceImpl中的query进行添加:
1 2 3 4 5 6 7 8 9 10 11 12 @Override public ServiceMultiResult<HouseDTO> query (RentSearch rentSearch) { if (rentSearch.getKeywords() !=null && !rentSearch.getKeywords().isEmpty()){ ServiceMultiResult<Long> serviceResult = searchService.query(rentSearch); if (serviceResult.getTotal() == 0 ){ return new ServiceMultiResult <>(0 ,new ArrayList <>()); } return new ServiceMultiResult <>(serviceResult.getTotal(), wrapperHouseResult(serviceResult.getResult())); }
然后去实现wrapperHouseResult。
1 2 3 4 5 6 7 8 9 private List<HouseDTO> wrapperHouseResult (List<Long> houseIds) { ArrayList<HouseDTO> houseDTOS = new ArrayList <>(); houseIds.forEach(houseId->{ House house = houseRepository.findOne(houseId); HouseDTO houseDTO = modelMapper.map(house,HouseDTO.class); houseDTOS.add(houseDTO); }); }
起初我是这么去编辑的这里面的逻辑,但是用ArraryList不太合适,使用HashMap更加合适一些。
因为可以做一个id To House的映射,给后面渲染house更多详情做准备。
1 2 3 4 5 6 7 8 9 10 private List<HouseDTO> wrapperHouseResult (List<Long> houseIds) { Map<Long, HouseDTO> idToHouseMap = new HashMap <>(); Iterable<House> houses = houseRepository.findAll(houseIds); houses.forEach(house->{ HouseDTO houseDTO = modelMapper.map(house,HouseDTO.class); houseDTO.setCover(this .cdnPrefix+house.getCover()); idToHouseMap.put(house.getId(),houseDTO); }); }
这样的话就建立了一个idToHouseMap做了一个id到houseDTO的映射
然后需要把houseDetails加进来
1 wrapperHouseList(houseIds,idToHouseMap);
因为在之前已经写好了这个方法,所以现在也明白了为什么需要建一个id 2
house的变量了,
另外需要矫正顺序,因为es查出来的houseid和mysql查出来的id顺序并不完全一致,所以这里需要进行一个矫正顺序。
所以最后HouseServiceImpl中的query方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 @Override public ServiceMultiResult<HouseDTO> query (RentSearch rentSearch) { if (rentSearch.getKeywords() !=null && !rentSearch.getKeywords().isEmpty()){ ServiceMultiResult<Long> serviceResult = searchService.query(rentSearch); if (serviceResult.getTotal() == 0 ){ return new ServiceMultiResult <>(0 ,new ArrayList <>()); } return new ServiceMultiResult <>(serviceResult.getTotal(), wrapperHouseResult(serviceResult.getResult())); } Sort sort = HouseSort.generateSort(rentSearch.getOrderBy(), rentSearch.getOrderDirection()); int page = rentSearch.getStart() / rentSearch.getSize(); Pageable pageable = new PageRequest (page, rentSearch.getSize(), sort); Specification<House> specification = (root, criteriaQuery, criteriaBuilder) ->{ Predicate predicate = criteriaBuilder.equal(root.get("status" ), HouseStatus.PASSES.getValue()); criteriaBuilder.and(predicate,criteriaBuilder.equal(root.get("cityEnName" ),rentSearch.getCityEnName())); return predicate; }; List<Long> houseIds = new ArrayList <>(); Map<Long, HouseDTO> idToHouseMap = Maps.newHashMap(); Page<House> houses = houseRepository.findAll(specification, pageable); ArrayList<HouseDTO> houseDTOS = new ArrayList <>(); houses.forEach(house -> { HouseDTO houseDTO = modelMapper.map(house, HouseDTO.class); houseDTO.setCover(this .cdnPrefix + house.getCover()); houseDTOS.add(houseDTO); houseIds.add(house.getId()); idToHouseMap.put(house.getId(),houseDTO); }); wrapperHouseList(houseIds,idToHouseMap); return new ServiceMultiResult <>(houses.getTotalElements(), houseDTOS); }
但是上面显得比较复杂,es查询和mysql查询的应该区分开,所以我们把mysql的查询给封装起来。
另外呢,我们对单独的类打了debug级别的日志,用于输出。
1 logging.level.com.zryy.soufangtest.service.search=debug
另外呢,这里我们对于es查询的设置了multiMatchQuery查询:
1 2 3 4 5 6 7 8 9 10 11 boolQuery.must( QueryBuilders.multiMatchQuery( rentSearch.getKeywords(), HouseIndexKey.TITLE, HouseIndexKey.TRAFFIC, HouseIndexKey.DISTRICT, HouseIndexKey.ROUND_SERVICE, HouseIndexKey.SUBWAY_LINE_NAME, HouseIndexKey.SUBWAY_STATION_NAME ) );
rentSearch.getKeywords()是关键词,
HouseIndexKey.TITLE TRAFFIC DISTRICT 等等都是需要查询的字段。
另外呢,当我们搜索富力城的时候,像华侨城这样的也会被搜索出来,因为这是中文分词的问题,所以下一节我们需要处理中文分词的问题。
三十八、中文分词-问题描述
我们先测一下分词的效果:
1 2 3 4 GET http://localhost:9200/_analyze?analyzer=standard&pretty=true&text=Well,zhiqiang is a handsome teacher Accept: application/json <> 2022-09-20T110032.200.json
结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 http: HTTP/1.1 200 OK Warning: 299 Elasticsearch-5.6 .1 -667 b497 "text request parameter is deprecated and will be removed in the next major release. Please use the JSON in the request body instead request param" "Tue, 20 Sep 2022 03:00:31 GMT" Warning: 299 Elasticsearch-5.6 .1 -667 b497 "analyzer request parameter is deprecated and will be removed in the next major release. Please use the JSON in the request body instead request param" "Tue, 20 Sep 2022 03:00:31 GMT" content-type: application/json; charset=UTF-8 { "tokens" : [ { "token" : "well" , "start_offset" : 0 , "end_offset" : 4 , "type" : "<ALPHANUM>" , "position" : 0 } , { "token" : "zhiqiang" , "start_offset" : 5 , "end_offset" : 13 , "type" : "<ALPHANUM>" , "position" : 1 } , { "token" : "is" , "start_offset" : 14 , "end_offset" : 16 , "type" : "<ALPHANUM>" , "position" : 2 } , { "token" : "a" , "start_offset" : 17 , "end_offset" : 18 , "type" : "<ALPHANUM>" , "position" : 3 } , { "token" : "handsome" , "start_offset" : 19 , "end_offset" : 27 , "type" : "<ALPHANUM>" , "position" : 4 } , { "token" : "teacher" , "start_offset" : 28 , "end_offset" : 35 , "type" : "<ALPHANUM>" , "position" : 5 } ] }
当然呢这里是英文,分词效果好很多。我们这里测试一下中文的情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 { "tokens" : [ { "token" : "志" , "start_offset" : 0 , "end_offset" : 1 , "type" : "<IDEOGRAPHIC>" , "position" : 0 } , { "token" : "强" , "start_offset" : 1 , "end_offset" : 2 , "type" : "<IDEOGRAPHIC>" , "position" : 1 } , { "token" : "是" , "start_offset" : 2 , "end_offset" : 3 , "type" : "<IDEOGRAPHIC>" , "position" : 2 } , { "token" : "青" , "start_offset" : 3 , "end_offset" : 4 , "type" : "<IDEOGRAPHIC>" , "position" : 3 } , { "token" : "岛" , "start_offset" : 4 , "end_offset" : 5 , "type" : "<IDEOGRAPHIC>" , "position" : 4 } , { "token" : "国" , "start_offset" : 5 , "end_offset" : 6 , "type" : "<IDEOGRAPHIC>" , "position" : 5 } , { "token" : "际" , "start_offset" : 6 , "end_offset" : 7 , "type" : "<IDEOGRAPHIC>" , "position" : 6 } , { "token" : "创" , "start_offset" : 7 , "end_offset" : 8 , "type" : "<IDEOGRAPHIC>" , "position" : 7 } , { "token" : "新" , "start_offset" : 8 , "end_offset" : 9 , "type" : "<IDEOGRAPHIC>" , "position" : 8 } , { "token" : "园" , "start_offset" : 9 , "end_offset" : 10 , "type" : "<IDEOGRAPHIC>" , "position" : 9 } , { "token" : "最" , "start_offset" : 10 , "end_offset" : 11 , "type" : "<IDEOGRAPHIC>" , "position" : 10 } , { "token" : "帅" , "start_offset" : 11 , "end_offset" : 12 , "type" : "<IDEOGRAPHIC>" , "position" : 11 } , { "token" : "的" , "start_offset" : 12 , "end_offset" : 13 , "type" : "<IDEOGRAPHIC>" , "position" : 12 } , { "token" : "男" , "start_offset" : 13 , "end_offset" : 14 , "type" : "<IDEOGRAPHIC>" , "position" : 13 } , { "token" : "人" , "start_offset" : 14 , "end_offset" : 15 , "type" : "<IDEOGRAPHIC>" , "position" : 14 } ] }
所以,我们新建了一个测试的索引用来测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 PUT http: Content-Type: application/json { "settings" : { "number_of_replicas" : 0 } , "mappings" : { "house" : { "dynamic" : false , "properties" : { "value" : { "type" : "text" } } } } }
然后增加一些内容进去:
1 2 3 4 5 6 POST http: Content-Type: application/json { "value" : "你从中学到了什么" }
等等
三十九、中文分词-IK配置
下载elasticSearch-IK放置在es文件夹的plugins目录下:
或者当版本大于5.5.1的时候,可以直接执行命令进行安装。
1 ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analusis-ik/releases/download/v5.6.1/elasticsearch-analysisi-ik-5.6.1.zip
注意,一定要选择对应的版本。
然后需要重新启动es。
这个时候虽然ik分词器被加载进来了,但是还需要我们去重新定义索引,之前我们定义的索引是标准分词器。
这时候我们就需要把之前创建的索引删除掉了:
1 2 DELETE http://localhost:9200/test Accept: application/json
然后重新创建索引:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 PUT http: Content-Type: application/json { "settings" : { "number_of_replicas" : 0 }, "mappings" : { "house" : { "dynamic" : false , "properties" : { "value" : { "type" : "text" , "analyzer" : "ik_max_word" , "search_analyzer" : "ik_max_word" } } } } }
然后再发送get请求进行测试:
1 2 3 ### GET http: Accept: application/json
结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 { "tokens" : [ { "token" : "志" , "start_offset" : 0 , "end_offset" : 1 , "type" : "CN_CHAR" , "position" : 0 } , { "token" : "强" , "start_offset" : 1 , "end_offset" : 2 , "type" : "CN_CHAR" , "position" : 1 } , { "token" : "是" , "start_offset" : 2 , "end_offset" : 3 , "type" : "CN_CHAR" , "position" : 2 } , { "token" : "青岛" , "start_offset" : 3 , "end_offset" : 5 , "type" : "CN_WORD" , "position" : 3 } , { "token" : "岛国" , "start_offset" : 4 , "end_offset" : 6 , "type" : "CN_WORD" , "position" : 4 } , { "token" : "国际" , "start_offset" : 5 , "end_offset" : 7 , "type" : "CN_WORD" , "position" : 5 } , { "token" : "创新" , "start_offset" : 7 , "end_offset" : 9 , "type" : "CN_WORD" , "position" : 6 } , { "token" : "园" , "start_offset" : 9 , "end_offset" : 10 , "type" : "CN_CHAR" , "position" : 7 } , { "token" : "最" , "start_offset" : 10 , "end_offset" : 11 , "type" : "CN_CHAR" , "position" : 8 } , { "token" : "帅" , "start_offset" : 11 , "end_offset" : 12 , "type" : "CN_CHAR" , "position" : 9 } , { "token" : "的" , "start_offset" : 12 , "end_offset" : 13 , "type" : "CN_CHAR" , "position" : 10 } , { "token" : "男人" , "start_offset" : 13 , "end_offset" : 15 , "type" : "CN_WORD" , "position" : 11 } ] }
另外呢,可以在/Users/zhiqiang/Downloads/elasticsearch-5.6.1/config/analysis-ik目录下,找到IKAnalyzer.cfg.xml文件,扩展自己的分词词典。
image-20220920170025078
所以我们重新创建了一份建立索引的mapping.json文件:
在需要分词的字段中都加入(我们的建立索引和搜索索引都要使用分词器 ):
1 2 "analyzer" : "ik_smart" , "search_analyzer" : "ik_smart"
还有就是新建的索引,需要把之前建立的索引删除掉.
我们在test索引中进行了测试:
1 2 3 4 5 6 7 8 9 10 POST http: Content-Type: application/json { "query" : { "match" : { "value" : "中国人" } } }
搜索结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 { "took" : 159 , "timed_out" : false , "_shards" : { "total" : 5 , "successful" : 5 , "skipped" : 0 , "failed" : 0 } , "hits" : { "total" : 1 , "max_score" : 0.6548752 , "hits" : [ { "_index" : "test" , "_type" : "house" , "_id" : "AYNaU2YlB6J9J6YWOWJ0" , "_score" : 0.6548752 , "_source" : { "value" : "我是中国人" } } ] } }
我们在重新删除之前没有分词设置的mapping—index后,重新创建带有分词设置的mapping-index,然后启动程序重新下架上架,重新倒入到es中,经过测试:
1 2 3 4 5 6 7 8 9 10 POST http: Content-Type: application/json { "query" : { "match" : { "title" : "精装修" } } }
结果成功:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 http: HTTP/1.1 200 OK content-type: application/json; charset=UTF-8 { "took" : 24 , "timed_out" : false , "_shards" : { "total" : 5 , "successful" : 5 , "skipped" : 0 , "failed" : 0 } , "hits" : { "total" : 1 , "max_score" : 0.68640786 , "hits" : [ { "_index" : "house" , "_type" : "house2" , "_id" : "AYNad2YTN7uKa3tweIKJ" , "_score" : 0.68640786 , "_source" : { "houseId" : 21 , "title" : "新康园 正规三居室 精装修 家电家具齐全1" , "price" : 1900 , "area" : 18 , "createTime" : 1504716767000 , "lastUpdateTime" : 1663670117000 , "cityEnName" : "bj" , "regionEnName" : "hdq" , "direction" : 3 , "distanceToSubway" : 1302 , "subwayLineName" : "13号线" , "subwayStationName" : "霍营" , "street" : "龙域西二路" , "district" : "融泽嘉园" , "description" : "房子是正规三室一厅一厨一卫,装修保持的不错,家电家具都齐全。\n" , "layoutDesc" : "房子客厅朝北面积比较大,主卧西南朝向,次卧朝北,另一个次卧朝西,两个次卧面积差不多大。" , "traffic" : "小区出南门到8号线育新地铁站614米,交通便利,小区500米范围内有物美,三旗百汇,龙旗广场等几个比较大的商场,生活购物便利,出小区北门朝东952米是地铁霍营站,是8号线和 13号线的换乘站,同时还有个S2线,通往怀来。(数据来源百度地图)" , "roundService" : "小区西边300米就是物美超市和三旗百汇市场(日常百货、粮油米面、瓜果蔬菜、生鲜海货等等,日常生活很便利,消费成本低),北边200米是龙旗购物广场和永辉超市(保利影院,KFC,麦当劳等,轻松满足娱乐消费)。小区里还有商店,饭店,家政等。" , "rentWay" : 0 , "tags" : [ "独立阳台" ] } } ] } }
我在搜索的时候,输入“吃饭”也能检索出来,在roundService中”吃“这个字没有,但是”饭“这个字有。所以被检索出来了。
另外呢,在查看kakfa的时候,如果出现了:
1 Exception thrown when sending a message with key='null' and
这样就是kafka挂掉了,然后在检查的时候收获了:
输入了
1 ./bin/kafka-console-consumer.sh --zookeeper localhost:2181 --topic house_build --from-beginning
来监控“house_build" 这个topic内容,发现:
image-20220920183750153
kafka — house_build里面存放的是这样的数值。 也就是传递的message。
不过不理解的是为什么这样的内容传递到了kafka中,虽然在
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @KafkaListener(topics = INDEX_TOPIC) private void handleMessage (String content) {try { HouseIndexMessage message = objectMapper.readValue(content, HouseIndexMessage.class); switch (message.getOperation()){ case HouseIndexMessage.INDEX: this .createOrUpdateIndex(message); break ; case HouseIndexMessage.REMOVE: this .removeIndex(message); break ; default : logger.warn("Not support message content" + content); break ; } } catch (IOException e) { e.printStackTrace(); logger.error("Cannot parse json for " +content , e); } }
中进行了es的处理,但是kafka这里面的值什么时候删除掉呢?
四十、Search-as-you-type
主要是基于es的suggest来实现的
我们简单定义了一个controller的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @GetMapping("rent/house/autocomplete") @ResponseBody public ApiResponse autocomplete (@RequestParam(value = "prefix") String prefix) { if (prefix.isEmpty()){ return ApiResponse.ofStatus(ApiResponse.Status.NOT_FOUND); } List<String> result = new ArrayList <>(); result.add("超棒" ); result.add("志强" ); return ApiResponse.ofSuccess(result); }
显示的效果如下:
在ISearchService中新建suggest,然后在impl中去新建:
1 2 3 4 @Override public ServiceResult<List<String>> suggest (String prefix) { return null ; }
当然在执行任何操作之前,我们需要对suggest新建一个专门的类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.zryy.soufangtest.service.search;public class HouseSuggest { private String input; private int weight = 10 ; public String getInput () { return input; } public void setInput (String input) { this .input = input; } public int getWeight () { return weight; } public void setWeight (int weight) { this .weight = weight; } }
然后也要在HouseIndexTemplate模版中添加这个字段,并且设置set
get方法:
1 2 3 4 5 6 7 8 9 private HouseSuggest houseSuggest;public HouseSuggest getHouseSuggest () { return houseSuggest; } public void setHouseSuggest (HouseSuggest houseSuggest) { this .houseSuggest = houseSuggest; }
然后,既然我们在houseIndexTempalte中增加了一个新的字段,所以在索引的时候我们的es中也需要去增加一个新的字段,所以我们需要在创建的index索引mapping中重新创建一个新的。
1 2 3 "suggest" : { "type" : "completion" }
也就是在最后增加了这个字段。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 { "settings" : { "number_of_replicas" : 0 } , "mappings" : { "house" : { "dynamic" : false , "properties" : { "houseId" : { "type" : "long" } , "title" : { "type" : "text" , "index" : "analyzed" , "analyzer" : "ik_smart" , "search_analyzer" : "ik_smart" } , "price" : { "type" : "integer" } , "area" : { "type" : "integer" } , "createTime" : { "type" : "date" , "format" : "strict_date_optional_time||epoch_millis" } , "lastUpdateTime" : { "type" : "date" , "format" : "strict_date_optional_time||epoch_millis" } , "cityEnName" : { "type" : "keyword" } , "regionEnName" : { "type" : "keyword" } , "direction" : { "type" : "integer" } , "distanceToSubway" : { "type" : "integer" } , "subwayLineName" : { "type" : "keyword" } , "subwayStationName" : { "type" : "keyword" } , "tags" : { "type" : "text" } , "street" : { "type" : "keyword" } , "district" : { "type" : "keyword" } , "description" : { "type" : "text" , "index" : "analyzed" } , "layoutDesc" : { "type" : "text" , "index" : "analyzed" } , "traffic" : { "type" : "text" , "index" : "analyzed" } , "roundService" : { "type" : "text" , "index" : "analyzed" } , "rentWay" : { "type" : "integer" } , "suggest" : { "type" : "completion" , "analyzer" : "ik_smart" , "search_analyzer" : "ik_smart" } } } } }
在获取到关键词之前呢,还需要对suggest进行一些填充操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 private boolean updateSuggest (HouseIndexTemplate indexTemplate) { AnalyzeRequestBuilder requestBuilder = new AnalyzeRequestBuilder (this .esclient, AnalyzeAction.INSTANCE, indexTemplate.getTitle(), indexTemplate.getLayoutDesc(), indexTemplate.getRoundService(), indexTemplate.getDescription(), indexTemplate.getSubwayLineName(), indexTemplate.getSubwayStationName()); requestBuilder.setAnalyzer("ik_smart" ); AnalyzeResponse analyzeResponse = requestBuilder.get(); List<AnalyzeResponse.AnalyzeToken> tokens = analyzeResponse.getTokens(); if (tokens == null ){ logger.warn("Can not analyze token for house: " + indexTemplate.getHouseId()); return false ; } List<HouseSuggest> suggests = new ArrayList <>(); for (AnalyzeResponse.AnalyzeToken token: tokens){ if ( "<NUM>" .equals(token.getType()) || token.getTerm().length() < 2 ){ continue ; } HouseSuggest suggest = new HouseSuggest (); suggest.setInput(token.getTerm()); suggests.add(suggest); } HouseSuggest suggest = new HouseSuggest (); suggest.setInput(indexTemplate.getDistrict()); suggests.add(suggest); indexTemplate.setSuggests(suggests); return true ; }
然后在定义的suggest方法中,首先构建一个suggestBuilders.completionSuggestion
第一个参数传递的是在es索引中我们创建的字段,这里我们定义了“suggest”
所以第一个参数传递的是fieldname类型,然后.prefix .size(5)
设置补全的数量。
1 2 3 4 5 @Override public ServiceResult<List<String>> suggest (String prefix) { CompletionSuggestionBuilder suggestion = SuggestBuilders.completionSuggestion("suggest" ) .prefix(prefix) .size(5 );
出现了错误,最终在排查问题的时候,找到了这里:
1 2 3 4 5 6 7 8 9 10 private boolean updateSuggest (HouseIndexTemplate indexTemplate) { AnalyzeRequestBuilder requestBuilder = new AnalyzeRequestBuilder (this .esclient, AnalyzeAction.INSTANCE, indexTemplate.getTitle(), indexTemplate.getLayoutDesc(), indexTemplate.getRoundService(), indexTemplate.getDescription(), indexTemplate.getSubwayLineName(), indexTemplate.getSubwayStationName());
第一个参数是client,第二个参数是AnalyzeAction.INSTANCE,第三个参数是INDEX_NAME(索引的名称)
当我没有写INDEX_NAME的时候,会默认的把延后的参数即:indexTemplate.getTitle()当作了index_name
于是出现的结果是虽然在kafka中加入了message,但是es中没有添加任何数据,错误如下:
1 2 3 4 5 6 7 8 9 10 11 12 rg.springframework.kafka.listener.ListenerExecutionFailedException: Listener method 'private void com.zryy.soufangtest.service.search.SearchServiceImpl.handleMessage(java.lang.String)' threw exception; nested exception is [大标题-编辑] IndexNotFoundException[no such index] at org.springframework.kafka.listener.adapter.MessagingMessageListenerAdapter.invokeHandler(MessagingMessageListenerAdapter.java:188) ~[spring-kafka-1.1.6.RELEASE.jar:na] at org.springframework.kafka.listener.adapter.RecordMessagingMessageListenerAdapter.onMessage(RecordMessagingMessageListenerAdapter.java:72) ~[spring-kafka-1.1.6.RELEASE.jar:na] at org.springframework.kafka.listener.adapter.RecordMessagingMessageListenerAdapter.onMessage(RecordMessagingMessageListenerAdapter.java:47) ~[spring-kafka-1.1.6.RELEASE.jar:na] at org.springframework.kafka.listener.KafkaMessageListenerContainer$ListenerConsumer.invokeRecordListener(KafkaMessageListenerContainer.java:794) [spring-kafka-1.1.6.RELEASE.jar:na] at org.springframework.kafka.listener.KafkaMessageListenerContainer$ListenerConsumer.invokeListener(KafkaMessageListenerContainer.java:738) [spring-kafka-1.1.6.RELEASE.jar:na] at org.springframework.kafka.listener.KafkaMessageListenerContainer$ListenerConsumer.run(KafkaMessageListenerContainer.java:570) [spring-kafka-1.1.6.RELEASE.jar:na] at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) [na:1.8.0_321] at java.util.concurrent.FutureTask.run$$$capture(FutureTask.java:266) [na:1.8.0_321] at java.util.concurrent.FutureTask.run(FutureTask.java) [na:1.8.0_321] at java.lang.Thread.run(Thread.java:750) [na:1.8.0_321] Caused by: org.elasticsearch.index.IndexNotFoundException: no such index
当时没有找到问题的原因,可以看到第二行中描述了:handleMessage中抛出的异常。nested
exception is [大标题-编辑] IndexNotFoundException[no such
index],[大标题-编辑]这个index没有找到,
然后在最下面,es也报出了这样的错误:
Caused by: org.elasticsearch.index.IndexNotFoundException: no such
index
没有这个index
所以修改之后的为下:(添加了index—name)
1 2 3 4 5 6 7 8 9 10 11 private boolean updateSuggest (HouseIndexTemplate indexTemplate) { AnalyzeRequestBuilder requestBuilder = new AnalyzeRequestBuilder (this .esclient, AnalyzeAction.INSTANCE, INDEX_NAME, indexTemplate.getTitle(), indexTemplate.getLayoutDesc(), indexTemplate.getRoundService(), indexTemplate.getDescription(), indexTemplate.getSubwayLineName(), indexTemplate.getSubwayStationName());
这样的话,结果就正确了,在控制台中显示的如下:
1 2 3 4 5 6 7 8 9 10 11 12 2022-09-22 14:57:29.500 DEBUG 80951 --- [ntainer#0-0-C-1] c.z.s.service.search.ISearchService : { "query" : { "term" : { "houseId" : { "value" : 29, "boost" : 1.0 } } } } 2022-09-22 14:57:30.175 DEBUG 80951 --- [ntainer#0-0-C-1] c.z.s.service.search.ISearchService : Create index with house: 29 2022-09-22 14:57:30.175 DEBUG 80951 --- [ntainer#0-0-C-1] c.z.s.service.search.ISearchService : Index success with house 29
然后在输入提示中,设置的suggest,默认是simple这个analyzer的:
1 2 3 4 5 6 "suggest" : {"max_input_length" : 50 ,"analyzer" : "simple" ,"preserve_position_increments" : true ,"type" : "completion" ,"preserve_separators" : true
然后在代码中设置的分析器是:
1 2 3 4 5 6 7 8 private boolean updateSuggest (HouseIndexTemplate indexTemplate) { AnalyzeRequestBuilder requestBuilder = new AnalyzeRequestBuilder ( this .esClient, AnalyzeAction.INSTANCE, INDEX_NAME, indexTemplate.getTitle(), indexTemplate.getLayoutDesc(), indexTemplate.getRoundService(), indexTemplate.getDescription(), indexTemplate.getSubwayLineName(), indexTemplate.getSubwayStationName()); requestBuilder.setAnalyzer("ik_smart" );
所以就导致了报错:找不到这个index
考虑如何在mapping中设置suggest这个字段的analyzer_type?
大体的逻辑就是在handleMessage的时候判断是否是create还是update操作,然后在create和update的一开始更新suggest(updateSuggest(houseIndexTemplate))。也就是给传递的参数indexTemplate更新suggest字段操作
在updateSuggest函数中:
首先定义一个AnalyzeRequestBuilder,实质上就是分词分析器,结果是分好词的。传递的参数是indexTemplate.getTitle、LayoutDesc等等。
requestBuilder.get().getTokens()获取的是List
<AnalyzeResponse.AnalyzeToken>
然后循环遍历把suggests加入到indexTemplate中,然后返回出来,
返回出来后,其实就是在create和updateindex方法中了,然后就是把indexTemplate转成json格式,然后创建或者更新索引。
然后这是创建索引中的suggest字段。
接下来就是suggest接口,请求的suggest返回的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @Override public ServiceResult<List<String>> suggest (String prefix) { CompletionSuggestionBuilder suggestion = SuggestBuilders.completionSuggestion("suggest" ) .prefix(prefix) .size(5 ); SuggestBuilder suggestBuilder = new SuggestBuilder (); suggestBuilder.addSuggestion("autocomplete" , suggestion); SearchRequestBuilder requestBuilder = this .esclient.prepareSearch(INDEX_NAME) .setTypes(INDEX_TYPE) .suggest(suggestBuilder); logger.debug(requestBuilder.toString()); SearchResponse response = requestBuilder.get(); Suggest suggest = response.getSuggest(); Suggest.Suggestion result = suggest.getSuggestion("autocomplete" ); int maxSuggest = 0 ; Set<String> suggestSet = new HashSet <>(); for (Object term : result.getEntries()){ if (term instanceof CompletionSuggestion.Entry){ CompletionSuggestion.Entry item = (CompletionSuggestion.Entry) term; if (item.getOptions().isEmpty()){ continue ; } for (CompletionSuggestion.Entry.Option option : item.getOptions()){ String tip = option.getText().string(); if (suggestSet.contains(tip)){ continue ; } suggestSet.add(tip); maxSuggest++; } } if (maxSuggest > 5 ){ break ; } } List<String> suggests = Lists.newArrayList(suggestSet.toArray(new String []{})); return ServiceResult.of(suggests); }
首先新建一个SuggestBuilders,然后实现completionSuggestion(),并且把suggest这个index字段当作参数扔进去。
然后设置prefix 和设置size参数
然后执行esclient去prepareSearch对suggestBuilder进行请求,返回的是SearchRequestBuilder的变量,
然后通过get方法获得SearchResponse,然后
对SearchResponse执行getSuggest()方法,得到基于prefix的suggest字段,然后suggest.getSuggestion("autocomplete");就得到补全的单词结果了。
然后呢可能获取到的结果有重复,所以我们做一个过滤操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 int maxSuggest = 0 ;Set<String> suggestSet = new HashSet <>(); for (Object term : result.getEntries()){ if (term instanceof CompletionSuggestion.Entry){ CompletionSuggestion.Entry item = (CompletionSuggestion.Entry) term; if (item.getOptions().isEmpty()){ continue ; } for (CompletionSuggestion.Entry.Option option : item.getOptions()){ String tip = option.getText().string(); if (suggestSet.contains(tip)){ continue ; } suggestSet.add(tip); maxSuggest++; } } if (maxSuggest > 5 ){ break ; } }
1 2 List<String> suggests = Lists.newArrayList(suggestSet.toArray(new String []{})); return ServiceResult.of(suggests);
然后转换格式,把这个返回出去。
最终的效果图如下:
image-20220923111040881
另外呢,我们会把用户常输入的一些关键词、热词等等,最常出现的词单独去新建一个索引 放进去,这样做的话效果会更好一些。
他妈的搞了半天,在houseIndexTemplate中把suggest定义成了suggests了,所以导致在补全接口的时候一直找不到相关内容。
四十一、小区房源统计功能
image-20220923113046846
我们之前在HouseController中的show方法中把houseCountInDistrict固定成了0了:
1 model.addAttribute("houseCountInDistrict" , 0 );
需要增加这样一个静态变量
1 public static final String AGG_DISTRICT = "agg_district" ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @Override public ServiceResult<Long> aggregateDistrictHouse (String cityEnName, String regionEnName, String district) { BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery() .filter(QueryBuilders.termQuery(HouseIndexKey.CITY_EN_NAME,cityEnName)) .filter(QueryBuilders.termQuery(HouseIndexKey.REGION_EN_NAME,regionEnName)) .filter(QueryBuilders.termQuery(HouseIndexKey.DISTRICT,district)); SearchRequestBuilder requestBuilder = this .esclient.prepareSearch(INDEX_NAME) .setTypes(INDEX_TYPE) .setQuery(boolQueryBuilder) .addAggregation( AggregationBuilders.terms(HouseIndexKey.AGG_DISTRICT) .field(HouseIndexKey.DISTRICT) ).setSize(0 ); logger.debug(requestBuilder.toString()); SearchResponse response = requestBuilder.get(); if (response.status() == RestStatus.OK){ Terms terms = response.getAggregations().get(HouseIndexKey.AGG_DISTRICT); if (terms.getBuckets() !=null && !terms.getBuckets().isEmpty()){ return ServiceResult.of(terms.getBucketByKey(district).getDocCount()); }; }else { logger.warn("Failed to Aggregate for " + HouseIndexKey.AGG_DISTRICT); } return ServiceResult.of(0L ); }
其结果的json形式是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 "aggregations" : { "agg_district" : { "terms" : { "field" : "district" , "size" : 10 , "min_doc_count" : 1 , "shard_min_doc_count" : 0 , "show_term_doc_count_error" : false , "order" : [ { "_count" : "desc" } , { "_term" : "asc" } ] } } } }
思路就是先进行filter的筛选过滤,然后在进行聚合,然后把response中的
结果拿出来,就是我们定义了agg_district这样一个聚合字段,然后get(HouseIndexKey.AGG_DISTRICT)
拿出来,然后terms.getBucketByKey(district).getDocCount()把这个数量取出来。
最终效果如下:
四十二、搜索引擎优化
当我们搜索的时候
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 { "from" : 0 , "size" : 5 , "query" : { "bool" : { "must" : [ { "multi_match" : { "query" : "融泽嘉园" , "fields" : [ "district^1.0" , "roundService^1.0" , "subwayLineName^1.0" , "subwayStationName^1.0" , "title^1.0" , "traffic^1.0" ] , "type" : "best_fields" , "operator" : "OR" , "slop" : 0 , "prefix_length" : 0 , "max_expansions" : 50 , "lenient" : false , "zero_terms_query" : "NONE" , "boost" : 1.0 } } ] , "filter" : [ { "term" : { "cityEnName" : { "value" : "bj" , "boost" : 1.0 } } } ] , "disable_coord" : false , "adjust_pure_negative" : true , "boost" : 1.0 } } , "sort" : [ { "lastUpdateTime" : { "order" : "desc" } } ] }
在query方法中我们使用了boolQuery.must
方法,对Title、traffic、district等字段进行了查询,但是这几个字段的权重都是相同的也就是默认1.0的方式。
1 2 3 4 5 boolQuery.must( QueryBuilders.matchQuery(HouseIndexKey.TITLE,rentSearch.getKeywords()) .boost(2.0f ) );
然后把QueryBuilder.multimatchQuery中的title字段删除掉:
1 2 3 4 5 6 7 8 9 10 boolQuery.must( QueryBuilders.multiMatchQuery( rentSearch.getKeywords(), HouseIndexKey.TRAFFIC, HouseIndexKey.DISTRICT, HouseIndexKey.ROUND_SERVICE, HouseIndexKey.SUBWAY_LINE_NAME, HouseIndexKey.SUBWAY_STATION_NAME ) );
这样的搜索json就变成了title权重为2.0了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 { "from" : 0 , "size" : 5 , "query" : { "bool" : { "must" : [ { "match" : { "title" : { "query" : "融泽嘉园" , "operator" : "OR" , "prefix_length" : 0 , "max_expansions" : 50 , "fuzzy_transpositions" : true , "lenient" : false , "zero_terms_query" : "NONE" , "boost" : 2.0 } } } , { "multi_match" : { "query" : "融泽嘉园" , "fields" : [ "district^1.0" , "roundService^1.0" , "subwayLineName^1.0" , "subwayStationName^1.0" , "title^1.0" , "traffic^1.0" ] , "type" : "best_fields" , "operator" : "OR" , "slop" : 0 , "prefix_length" : 0 , "max_expansions" : 50 , "lenient" : false , "zero_terms_query" : "NONE" , "boost" : 1.0 } } ] , "filter" : [ { "term" : { "cityEnName" : { "value" : "bj" , "boost" : 1.0 } } } ] , "disable_coord" : false , "adjust_pure_negative" : true , "boost" : 1.0 } } , "sort" : [ { "lastUpdateTime" : { "order" : "desc" } } ] }
另外呢,很多我们筛选的条件没必要都是must,可以修改成should。只要满足一些搜索的条件就可以:
所以我们把两个matchQuery修改成should的形式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 boolQuery.should( QueryBuilders.matchQuery(HouseIndexKey.TITLE,rentSearch.getKeywords()) .boost(2.0f ) ); boolQuery.should( QueryBuilders.multiMatchQuery( rentSearch.getKeywords(), HouseIndexKey.TRAFFIC, HouseIndexKey.DISTRICT, HouseIndexKey.ROUND_SERVICE, HouseIndexKey.SUBWAY_LINE_NAME, HouseIndexKey.SUBWAY_STATION_NAME ) );
都修改为should后反而什么乱七八糟的都查出来了。建议修改为must
另外一个优化的点是:
我们的es是需要检索出重要的数据字段即可,然后再去mysql中查询,而现在是在es中把所有的数据都查询出来了,如果有海量的大数据,我们该怎么处理?
1 2 3 4 5 6 7 8 9 10 SearchRequestBuilder requestBuilder = this .esclient.prepareSearch(INDEX_NAME) .setTypes(INDEX_TYPE) .setQuery(boolQuery) .addSort( HouseSort.getSortKey(rentSearch.getOrderBy()), SortOrder.fromString(rentSearch.getOrderDirection()) ) .setFrom(rentSearch.getStart()) .setSize(rentSearch.getSize()) .setFetchSource(HouseIndexKey.HOUSE_ID, null );
我们在prepareSearch这里设置FetchSource,把House_ID扔进去,只返回house_Id的字段:
1 2 3 4 { houseId=21 } { houseId=29 } { houseId=19 } { houseId=24 }
这样的话查询的量级也比较小一些。
setFetchSource
四十三、基于百度地图的找房功能
业务与需求分析:
在网站拥有了搜索引擎后,用户就可以非常方便的搜到自己想要的房源信息了,但是有很多时候,用户并不能想清楚自己想要什么,那么这就就迫切的需要一个具体提示性质 的功能,来方便用户查询信息,所以我们的地图找房功能就很有必要了。我们的目标就是能够在指定区域显示用户想要的房源,方便用户根据地理位置寻找房源信息,提升网站的竞争力。
实现目标:
构建房源地理坐标
聚合地图范围内房源
根据地图视野查询房源
地图事件绑定数据源
地图展示房源数据麻点图
基于ES的功能开发:
地图房源信息聚合功能
根据视野变化动态更新数据源功能
地图事件更新数据源功能
基于地址获取经纬度的开发:
百度地图浏览器端:NwrFdXX7d9ulyeUvuvrRL1pRwHEHl7qU
百度地图服务端:vVo0GpUwrXGIyq7Xpv6U7Bui5dNGDjaL
对AK值做一个替换:
image-20220925105711798
然后在HouseController中去定义一个map的新接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @GetMapping("rent/house/map") public String rentMapPage (@RequestParam(value = "cityEnName") String cityEnName, Model model, HttpSession httpSession, RedirectAttributes redirectAttributes) { ServiceResult<SupportAddressDTO> city = addressService.queryCity(cityEnName); if (!city.isSuccess()){ redirectAttributes.addAttribute("msg" ,"must_chose_city" ); return "redirect:/index" ; }else { httpSession.setAttribute("cityName" , cityEnName); model.addAttribute("city" ,city.getResult()); } ServiceMultiResult<SupportAddressDTO> regions = addressService.findAllRegionsByCityName(cityEnName); model.addAttribute("total" , 0 ); model.addAttribute("regions" , regions.getResult()); return "rent-map" ; }
另外有时候springmvc的动态cache不跟着配置走,怎么办呢?
在WebMvcConfig中
1 2 3 4 5 @Configuration public class WebMvcConfig extends WebMvcConfigurerAdapter implements ApplicationContextAware { @Value("${spring.thymeleaf.cache}") private boolean thymeleafCacheEnable = true ;
然后在springResourceTemplateResolver中设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Bean @ConfigurationProperties(prefix = "spring.thymeleaf") public SpringResourceTemplateResolver templateResolver () { SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver (); templateResolver.setApplicationContext(this .applicationContext); templateResolver.setCharacterEncoding("UTF-8" ); templateResolver.setCacheable(thymeleafCacheEnable); return templateResolver; }
这样在前端开发的时候,就不需要重启,也不需要重新加载了。
保持cache是true。
然后再去实现城市的聚合操作:
1 2 3 4 5 ServiceMultiResult<HouseBucketDTO> mapAggregate (String cityEnName) ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Override public ServiceMultiResult<HouseBucketDTO> mapAggregate (String cityEnName) { BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); boolQuery.filter(QueryBuilders.termQuery(HouseIndexKey.CITY_EN_NAME,cityEnName)); AggregationBuilder aggBuilder = AggregationBuilders.terms(HouseIndexKey.AGG_REGION) .field(HouseIndexKey.REGION_EN_NAME); SearchRequestBuilder requestBuilder = this .esclient.prepareSearch(INDEX_NAME) .setTypes(INDEX_TYPE) .setQuery(boolQuery) .addAggregation(aggBuilder); logger.debug(requestBuilder.toString()); SearchResponse searchResponse = requestBuilder.get(); List<HouseBucketDTO> buckets = new ArrayList <>(); if (searchResponse.status() != RestStatus.OK){ logger.warn("Aggregate status is not ok for " + requestBuilder); return new ServiceMultiResult <>(0 ,buckets); } Terms terms = searchResponse.getAggregations().get(HouseIndexKey.AGG_REGION); for (Terms.Bucket bucket : terms.getBuckets()){ buckets.add(new HouseBucketDTO (bucket.getKeyAsString(),bucket.getDocCount())); } return new ServiceMultiResult <>(searchResponse.getHits().getTotalHits(),buckets); }
这样呢service就完善了,然后再去controller中去配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @GetMapping("rent/house/map") public String rentMapPage (@RequestParam(value = "cityEnName") String cityEnName, Model model, HttpSession httpSession, RedirectAttributes redirectAttributes) { ServiceResult<SupportAddressDTO> city = addressService.queryCity(cityEnName); if (!city.isSuccess()){ redirectAttributes.addAttribute("msg" ,"must_chose_city" ); return "redirect:/index" ; }else { httpSession.setAttribute("cityName" , cityEnName); model.addAttribute("city" ,city.getResult()); } ServiceMultiResult<SupportAddressDTO> regions = addressService.findAllRegionsByCityName(cityEnName); ServiceMultiResult<HouseBucketDTO> mapAggResult = searchService.mapAggregate(cityEnName); model.addAttribute("aggData" ,mapAggResult.getResult()); model.addAttribute("total" , mapAggResult.getTotal()); model.addAttribute("regions" , regions.getResult()); return "rent-map" ; }
基于ES的地图点聚合
剩下的基于时间紧张,并没有详细去学习研究,记录在此。
七、搜索引擎实现
1、业务与功能分析
在我们网站实现了基础信息浏览功能以后,用户就可以看到网站上的所有房源,但是在网站房源信息量爆炸的时候,用户是很难找到自己想要的信息的,这时候,网站就必须要有站内搜索引擎 功能,帮助用户根据用户自身的需求快速的找到想要的房源信息。
实现目标
2、实现目标
构建ES房源索引
基于ES构建搜索引擎
解决中文分词问题
search-as-you-type
使搜索引擎结果集最优
3、搜索引擎实现
索引结构设计
为什么不直接使用es来存数据呢?因为es是弱事务的,并不能使用事务来处理,很容易产生很多脏数据。