SpringMVC:设计现实世界的 Web 应用(二)

原文:zh.annas-archive.org/md5/AB3510E97B9E20602840C849773D49C6

译者:飞龙

协议:CC BY-NC-SA 4.0

第三章:使用 Spring 访问数据

数据访问或持久化是数据驱动应用程序的一个重要技术特性。这是一个需要仔细设计和专业知识的关键领域。现代企业系统使用各种各样的数据存储机制,从传统的关系数据库(如 Oracle、SQL Server 和 Sybase)到更灵活的无模式数据库(如 MongoDB、Cassandra 和 Couchbase)。Spring 框架为多种机制的数据持久化提供了全面的支持,从方便的模板组件到流行的ORM对象关系映射)工具和库的智能抽象,使它们更容易使用。Spring 的数据访问支持是选择其开发 Java 应用程序的另一个重要原因。

Spring 框架为开发人员提供了以下主要数据持久化机制的选择:

  • Spring JDBC

  • ORM 数据访问

  • Spring Data

此外,Spring 将前述方法统一标准化为称为@Repository的统一DAO数据访问对象)表示法。

使用 Spring 的另一个引人注目的原因是它的一流事务支持。Spring 提供一致的事务管理,抽象了不同的事务 API,如 JTA、JDBC、JPA、Hibernate、JDO 和其他特定于容器的事务实现。

为了使开发和原型设计更容易,Spring 提供了嵌入式数据库支持、智能抽象(DataSource)和优秀的测试集成。本章探讨了 Spring 框架提供的各种数据访问机制以及其在独立和 Web 环境中对事务管理的全面支持,同时提供相关示例。

注意

为什么要使用 Spring 数据访问而不是 JDBC?

JDBC(Java 数据库连接)是 Java 标准版 API,用于从 Java 到关系数据库的数据连接,是一个非常低级的框架。通过 JDBC 访问数据通常很麻烦;开发人员需要编写的样板代码使得代码容易出错。此外,JDBC 异常处理对于大多数用例来说是不够的;数据访问需要简化但广泛和可配置的异常处理。Spring JDBC 封装了经常重复的代码,极大地简化了开发人员的代码,并让他/她直接专注于业务逻辑。Spring 数据访问组件抽象了技术细节,包括查找和管理持久性资源,如连接、语句和结果集,并接受特定的 SQL 语句和相关参数来执行操作。Spring 数据访问组件在底层使用相同的 JDBC API,同时为客户端提供了简化、直接的接口。这种方法为 Spring 应用程序提供了更清晰、因此更易于维护的数据访问层。

配置 DataSource

从任何 Java 应用程序连接到数据库的第一步是获取由 JDBC 指定的连接对象。DataSource是 Java SE 的一部分,是java.sql.Connection对象的通用工厂,表示与数据库的物理连接,并且是生成连接的首选方式。DataSource处理事务管理、连接查找和池功能,解除了开发人员对这些基础设施问题的负担。

DataSource对象通常由数据库驱动程序供应商实现,并通常通过 JNDI 查找。应用服务器和 Servlet 引擎提供了它们自己的DataSource实现和/或连接器到由数据库供应商提供的DataSource对象。通常在基于 XML 的服务器描述符文件中配置,服务器提供的DataSource对象通常提供内置的连接池和事务支持。作为开发人员,您只需在服务器配置文件中以 XML 方式声明式地配置您的数据源,并通过 JNDI 从应用程序中查找它们。

在 Spring 应用程序中,您将DataSource引用配置为 Spring bean,并将其作为依赖项注入到 DAO 或其他持久性资源中。Spring 的<jee:jndi-lookup/>标签(来自www.springframework.org/schema/jee命名空间)允许您轻松查找和构建 JNDI 资源,包括从应用服务器内部定义的DataSource对象。对于部署在 J2EE 应用服务器中的应用程序,建议使用容器提供的 JNDIDataSource对象。

<jee:jndi-lookup id="taskifyDS" jndi-name="java:jboss/datasources/taskify"/>

对于独立应用程序,您需要创建自己的DataSource实现或使用第三方实现,如 Apache Commons DBCP、C3P0 或 BoneCP。以下是使用 Apache Commons DBCP2 的示例DataSource配置。它还提供了可配置的连接池功能。

<bean id="taskifyDS" class="org.apache.commons.dbcp2.BasicDataSource" destroy-method="close">
    <property name="driverClassName" value="${driverClassName}" />
    <property name="url" value="${url}" />
    <property name="username" value="${username}" />
    <property name="password" value="${password}" />
    <property name="initialSize" value="3" />
    <property name="maxTotal" value="50" />
    ...
</bean>

确保在构建文件中添加相应的依赖项以使用您的DataSource实现。以下是 DBCP2 的示例:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-dbcp2</artifactId>
    <version>2.1.1</version>
</dependency>

Spring 提供了DriverManagerDataSource,这是DataSource的一个简单实现,仅用于测试和开发目的,而不用于生产。请注意,它不提供连接池。以下是如何在应用程序中配置它。

<bean id="taskifyDS" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
    <property name="driverClassName" value="${driverClassName}" />
    <property name="url" value="${url}" />
    <property name="username" value="${username}" />
    <property name="password" value="${password}" />
</bean>

也可以使用基于 Java 的配置进行配置,如下所示:

@Bean
DataSource getDatasource() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource(pgDsProps.getProperty("url"));
    dataSource.setDriverClassName( pgDsProps.getProperty("driverClassName"));
    dataSource.setUsername(pgDsProps.getProperty("username"));
    dataSource.setPassword(pgDsProps.getProperty("password"));
    return dataSource;
}

注意

不要在生产环境中使用DriverManagerDataSource。对于独立应用程序,请使用 DBCP、C3P0 和 BoneCP 等第三方数据源,对于 J2EE 容器,请使用容器提供的 JNDI DataSource。它们更可靠,并且提供了高效的连接池功能。

使用嵌入式数据库

对于原型和测试环境,使用基于 Java 的嵌入式数据库是一个好主意,可以快速启动项目并轻松配置。它们轻量且易于测试。Spring 原生支持 HSQL、H2 和 Derby 数据库引擎。以下是嵌入式 HSQL 数据库的示例DataSource配置:

@Bean
DataSource getHsqlDatasource() {
    return new 
        EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.HSQL)
         .addScript("db-scripts/hsql/db-schema.sql")
         .addScript("db-scripts/hsql/data.sql")
         .addScript("db-scripts/hsql/storedprocs.sql")
         .addScript("db-scripts/hsql/functions.sql")
         .setSeparator("/").build();
}

其 XML 版本如下所示:

<jdbc:embedded-database id="dataSource" type="HSQL">
  <jdbc:script location="classpath:db-scripts/hsql/ db-schema.sql" />
    . . . 
</jdbc:embedded-database>

在 Spring Data 层处理异常

在传统的基于 JDBC 的应用程序中,异常处理基于java.sql.SQLException,这是一个受检异常。它强制开发人员仔细编写catchfinally块,以便正确处理并避免资源泄漏,比如保持数据库连接处于打开状态。Spring 基于RuntimeException的智能异常层次结构使开发人员摆脱了这一噩梦。以DataAccessException为根,Spring 捆绑了一系列有意义的异常,翻译了传统的 JDBC 异常。Spring 还以一致的方式涵盖了 Hibernate、JPA 和 JDO 异常。

Spring 使用SQLErrorCodeExceptionTranslator,它继承自SQLExceptionTranslator,用于将SQLException翻译为DataAccessExceptions。我们可以扩展此类以自定义默认翻译。我们可以通过将自定义实现注入到持久性资源(如稍后将介绍的JdbcTemplate)中来替换默认的翻译器。请参阅以下代码清单,了解如何在代码中定义SQLExceptionTranslator类:

String userQuery = "select * from TBL_NONE where name = ?";
SQLExceptionTranslator excTranslator = new SQLExceptionTranslator() {

  @Override
  public DataAccessException translate(String task, String sql, SQLException ex) {
    logger.info("SUCCESS --- SQLExceptionTranslator.translate invoked !!");
    return new BadSqlGrammarException("Invalid Query", userQuery, ex){};
  }
};

前面的代码片段捕获任何SQLException并将其转换为基于 Spring 的BadSqlGrammarException实例。然后,这个自定义的SQLExceptionTranslator需要在使用之前传递给Jdbctemplate,如下面的代码所示:

JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
jdbcTemplate.setExceptionTranslator(excTranslator);
Map<String, Object> result = jdbcTemplate.queryForMap(userQuery, new Object[] {"abc"});

现在,任何无效的查询都将调用自定义的SQLExceptionTranslator类。您可以根据自己的需求自定义其行为。

DAO 支持和@Repository 注释

访问数据的标准方式是通过执行数据访问层下的持久性功能的专门 DAO。Spring 通过提供 DAO 组件并允许开发人员将其数据访问组件标记为 DAO,使用注释@Repository来遵循相同的模式。这种方法确保了在各种数据访问技术(如 JDBC、Hibernate、JPA 和 JDO)和项目特定存储库上的一致性。Spring 在所有这些方法中一致地应用SQLExceptionTranslator

Spring 建议将数据访问组件注释为@Repository。术语“repository”最初在Domain-Driven DesignEric EvansAddison Wesley中定义为“一种模拟对象集合的存储、检索和搜索行为的机制”。此注释使类有资格由 Spring 框架进行DataAccessException翻译。

Spring Data 是 Spring 提供的另一种标准数据访问机制,围绕@Repository组件展开。我们将在后面的部分中更多地讨论这个问题。

Spring JDBC 抽象

Spring JDBC 组件通过封装样板代码并使用一组简单的接口隐藏与 JDBC API 组件的交互,简化了基于 JDBC 的数据访问。这些接口处理了 JDBC 资源(连接、语句、结果集)的打开和关闭。它们准备和执行语句,从结果集中提取结果,提供回调钩子以转换、映射和处理数据,处理事务,并将 SQL 异常转换为更合理和有意义的 DataAccessException 层次结构。

Spring JDBC 提供了三种便利的方法来访问关系数据库:

  • JdbcTemplate

  • SimpleJDBC

  • RDBMS Sql*

每个 Spring JDBC 类别下都有多种组件的变种,您可以根据自己的方便和技术选择进行混合和匹配。您可以在 org.springframework.jdbc 包及其子包下探索它们。

JdbcTemplate

JdbcTemplate 是 Spring JDBC 抽象下的核心组件。这个强大的组件通过其简单而有意义的方法执行几乎所有可能的 JDBC 操作,接受一组令人印象深刻的数据访问变种的参数。它属于 org.springframework.jdbc.core 包,其中包含许多其他支持类,帮助 JdbcTemplate 完成其 JDBC 操作。这个组件的唯一依赖是 DataSource 实例。所有其他 Spring JDBC 组件在内部使用 JdbcTemplate 进行操作。

通常,您将 JdbcTemplate 配置为另一个 Spring bean,并将其注入到您的 DAO 或任何其他您想要调用其方法的 bean 中。

<bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
  <constructor-arg ref="dataSource"/>
</bean>
<bean id="userDAO" class="com.springessentials.chapter3.dao.impl.UserJdbcDAO">
  <constructor-arg ref="jdbctemplate"/>
</bean>

注意

JdbcTemplate 是 Spring 中模板模式的实现之一。模板模式是 Gang of Four 设计模式目录中列出的行为模式。它在一个称为 模板方法 的方法或操作中定义了算法的骨架,将一些步骤推迟到子类中,而不改变算法的结构。JdbcTemplate 是这些模板方法的集合;用户可以扩展它并根据特定要求覆盖一些行为。JMSTemplateJpaTemplate 也是模板模式实现的例子。

JdbcTemplate 执行 SQL 查询(SELECT)、更新语句(INSERTUPDATEDELETE)、存储过程和函数调用,返回提取的结果(对于 SELECT 查询),并调用回调方法进行结果集提取和将行映射到域对象。它具有一套全面的查询和执行方法,用于不同方法的结果集提取。以下表介绍了一些非常有用的 JdbcTemplate 方法:

方法描述
execute一组重载的方法,用于执行 SQL 更新(INSERTUPDATEDELETE)语句,包括要执行的 SQL 语句、绑定参数、语句创建者和回调方法。
query一组重载的方法,用于查询给定 SQL SELECT 语句的 PreparedStatement,包括多种参数集,包括绑定参数、参数类型、RowMapperResultSetExtractorPreparedStatementCreatorRowCallbackHandler 等。虽然带有回调的方法是 void 方法,其他方法返回指定类型 <T> 的对象列表,该类型由相应的 RowMapperResultSetExtractor 指定,或者返回类型为 <T> 的填充实例。
queryForList一组重载的查询方法,执行 SELECT 查询并返回作为参数指定的类型 <T> 的对象列表,Class<T> elementType。未指定 elementType 的方法返回 List<Map<String, Object>>
queryForMap执行(SELECT)查询并将结果作为 Map<String, Object> 返回。
queryForObject一组重载方法,查询给定的 SQL SELECT语句,包括绑定参数、参数类型、RowMapper和所需的返回类型<T>
update一组重载方法,用于发出带有绑定参数、参数类型、PreparedStatementCreator等的更新(INSERTUPDATEDELETE)语句。它返回一个整数,表示受影响的记录数。
batchUpdate一组重载方法,用于执行多个 SQL 更新(INSERTUPDATE和 DELETE),包括 SQL 语句数组和许多PreparedStatementSetter等参数的组合。
execute一组重载方法,用于执行 SQL 更新(INSERTUPDATEDELETE)语句,包括 SQL 语句的执行、绑定参数、StatementCreator和回调方法等不同的参数集。
query一组重载方法,用于查询给定 SQL SELECT语句的PreparedStatement,包括绑定参数、参数类型、RowMapperResultSetExtractorPreparedStatementCreatorRowCallbackHandler等多种参数集。虽然具有回调的方法是 void 方法,其他方法返回与相应的RowMapperResultSetExtractor指定的对象类型<T>的对象列表,或者类型<T>的填充实例。

JdbcTemplate的超强功能背后是一组回调接口,作为前面表中列出的方法的参数传递。这些执行钩子帮助JdbcTemplate以纯面向对象和可重用的方式处理关系数据。对这些接口的深入理解对于正确使用JdbcTemplate至关重要。请参阅以下表格以了解这些回调接口:

回调接口回调方法职责
CallableStatementCreatorexecute构造java.sql.CallableStatement,用于在其createCallableStatement(Connection)方法中执行存储过程。
PreparedStatementCreatorexecute, update, query构造java.sql.PreparedStatement,给定一个连接,在方法createPreparedStatement (Connection)中。
PreparedStatementSetterupdate, query在执行之前为PreparedStatement设置值,在JdbcTemplate.setValues (PreparedStatement)中。
CallableStatementCallbackexecute准备CallableStatement。通常在实际执行之前设置存储过程或函数的INOUT参数,在JdbcTemplate.doInCallableStatement(CallableStatement)中:
PreparedStatementCallbackexecuteJdbcTemplate的执行方法使用,用于准备PreparedStatement。通常在实际执行之前设置绑定参数,在doInPreparedStatement(PreparedStatement)方法中:
ResultSetExtractorqueryResultSet中提取结果并返回一个域对象,在extractData(ResultSet)方法中:
RowCallbackHandlerquery以有状态的方式处理ResultSet的每一行,在processRow(Resultset)方法中,不返回任何内容。
RowMapperqueryResultSet的每一行映射到一个域对象中,在mapRow(Resultset, int rowNum)方法中返回创建的域对象。

现在让我们尝试一些JdbcTemplate的实际用法。以下是使用JdbcTemplate执行计数查询的简单方法。

@Override
public int findAllOpenTasksCount() {
  return jdbcTemplate.queryForObject("select count(id) from tbl_user where status = ?", new Object[]{"Open"}, Integer.class);
}

你看到了吗,这条简单的一行代码如何帮你摆脱了在典型的 JDBC 代码中需要编写的样板和异常处理代码?

以下代码片段稍微复杂,演示了如何从表中查询唯一行,并使用RowMapper将其映射到一个域对象(在本例中为User):

public User findByUserName(String userName) {
  return jdbcTemplate.queryForObject("SELECT ID, NAME, USER_NAME, PASSWORD, DOB, PROFILE_IMAGE_ID, PROFILE_IMAGE_NAME FROM TBL_USER WHERE USER_NAME = ?", new Object[] { userName }, 
    new RowMapper<User>() {
      @Override
      public User mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new User(rs.getLong("ID"), 
        rs.getString("NAME"), 
        userName, 
        rs.getString("PASSWORD"), 
        rs.getDate("DOB"));
    }
  });
}

使用JdbcTemplate处理数据集合要容易得多。以下代码片段说明了JdbcTemplate的查询方法,其中使用绑定参数和RowMapperResultSet转换为类型为<Task>的列表。

@Override
public List<Task> findCompletedTasksByAssignee(Long assigneeId) {
  String query = "SELECT * FROM TBL_TASK WHERE STATUS = ? AND 
  ASSIGNEE_USER_ID = ? ";

  return this.jdbcTemplate.query(query, new Object[] {"Complete", 
    assigneeId }, new RowMapper<Task>() {
    @Override
    public Task mapRow(ResultSet rs, int rowNum) throws SQLException{
      Task task = new Task();
      task.setId(rs.getLong("id"));
      Long assigneeId = rs.getLong("assignee_user_id");

      if (assigneeId != null)
        task.setAssignee(userDAO.findById(assigneeId));
      task.setComments(rs.getString("comments"));
      task.setName(rs.getString("name"));
      ...
      return task;
    }
  });
}

JdbcTemplate会为您处理所有重复的代码,您只需要编写特定的代码,即如何将行的数据与您的领域对象进行映射。

另一种使用ResultSetExtractor接口从ResultSet中提取单行的行映射的变化在以下代码中说明:

@Transactional(readOnly = true)
public User findUserById(Long userId) {
  return jdbcTemplate.query("SELECT NAME, USER_NAME, PASSWORD, DOB, PROFILE_IMAGE_ID, PROFILE_IMAGE_NAME FROM TBL_USER WHERE ID = ?",
    new Object[] { userId }, new ResultSetExtractor<User>() {
    @Override
    public User extractData(ResultSet rs) throws SQLException, DataAccessException {
      if (rs.next()) {
        return new User(userId, rs.getString("NAME"), rs.getString("USER_NAME"), rs.getString("PASSWORD"), rs.getDate("DOB"));
      } else {
        return null;
      }
    }
  });
}

现在让我们来看一些更新语句。以下是执行简单INSERT语句的一行代码。SQL UPDATEDELETE语句遵循相同的模式。

@Override
public void createUser(User user) {
  jdbcTemplate.update("INSERT INTO TBL_USER(NAME, USER_NAME, PASSWORD, DOB) VALUES(?,?,?,?)", new Object[] { user.getName(), user.getUserName(), user.getPassword(), user.getDateOfBirth()});
}

前面的方法有一个缺点。虽然它将新用户记录插入表中,但生成的 ID(可能是通过数据库序列)没有返回;您需要发出另一个查询来单独检索它。但是,JdbcTemplate提供了解决这个问题的好方法:使用KeyHolder类。这是update方法的另一个variation,在下面的代码中进行了解释;您可以使用KeyHolder类与PreparedStatementCreator结合,以单个执行中检索生成的键(在本例中是 ID):

public void createUser(User user) {
  KeyHolder keyHolder = new GeneratedKeyHolder();
  jdbcTemplate.update( new PreparedStatementCreator() {
    public PreparedStatement createPreparedStatement(Connection connection) throws SQLException {
      PreparedStatement ps = connection.prepareStatement(
      "INSERT INTO TBL_USER(NAME,USER_NAME,PASSWORD,DOB) VALUES(?,?,?,?)", new String[]{"ID"});

        ps.setString(1, user.getName());
        ps.setString(2, user.getUserName());
        ps.setString(3, user.getPassword());
        ps.setDate(4, new java.sql.Date(user.getDateOfBirth().getTime()));
        return ps;
    }
  }, keyHolder);

  user.setId(keyHolder.getKey().longValue());
}

JdbcTemplate使批量更新变得容易,遵循与前面相同的模式。看一下以下代码:它在数据集合上执行单个PreparedStatement

@Override
public void createUsers(List<User> users) {
    int[] updateCounts = jdbcTemplate.batchUpdate("INSERT INTO TBL_USER(NAME, USER_NAME, PASSWORD, DOB) VALUES(?,?,?,?)", new BatchPreparedStatementSetter() {
        public void setValues(PreparedStatement ps, int idx) throws SQLException {
            ps.setString(1, users.get(idx).getName());
            ps.setString(2, users.get(idx).getUserName());
            ps.setString(3, users.get(idx).getPassword());
            ps.setDate(4, new java.sql.Date(users.get(idx) .getDateOfBirth().getTime()));
        }

        public int getBatchSize() {
            return users.size();
        }
    });
}

NamedParameterJdbcTemplate

到目前为止,我们已经使用JdbcTemplate使用?占位符进行绑定参数。当涉及更多的参数时,使用命名参数对于可读性和可维护性更好。NamedParameterJdbcTemplateJdbcTemplate的专门版本,支持使用命名参数而不是传统的?占位符。NamedParameterJdbcTemplate不是从JdbcTemplate扩展,而是使用底层的JdbcTemplate进行其操作。

您可以以与经典JdbcTemplate相同的方式定义NamedParameterJdbcTemplate,将DataSource对象作为强制依赖项传递。然后,您可以像使用命名参数一样使用它,而不是使用绑定参数(?)。以下代码片段说明了使用NamedParameterJdbcTemplate查询方法,该方法使用RowMapper进行对象关系映射。

public User findByUserName(String userName, DataSource dataSource) {

  NamedParameterJdbcTemplate jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
  SqlParameterSource namedParameters = new MapSqlParameterSource("USER_NAME", userName);

  return jdbcTemplate.queryForObject("SELECT ID, NAME, USER_NAME, PASSWORD, DOB, PROFILE_IMAGE_ID, PROFILE_IMAGE_NAME FROM TBL_USER WHERE USER_NAME = :USER_NAME", namedParameters, new RowMapper<User>() {

    @Override
    public User mapRow(ResultSet rs, int rowNum) throws SQLException {
      return new User(rs.getLong("ID"), rs.getString("NAME"), userName, rs.getString("PASSWORD"), rs.getDate("DOB"));
    }
  });
}

SimpleJdbc 类

SimpleJdbc类是以更面向对象的方式访问数据的另一种不错的方法,但仍然在内部使用相同的JdbcTemplate。它们属于org.springframework.jdbc.core.simple包。其中有两个类:

  • SimpleJdbcCall

  • SimpleJdbcInsert

SimpleJdbcCall处理对存储过程和函数的调用,SimpleJdbcInsert处理对数据库表的 SQL INSERT命令。两者都具有DatabaseMetadata意识,因此它们会自动检测或映射领域对象的同名字段。它们都充当了执行关系实体(存储过程或函数和数据库表分别)周围的 JDBC 操作的模板,接受一次性确定操作行为的参数(在全局声明),然后在运行时重复使用具有动态数据集的参数。

SimpleJdbcCall类声明如下:

SimpleJdbcCall createTaskStoredProc = new SimpleJdbcCall(dataSource)
    .withFunctionName("CREATE_TASK")
    .withSchemaName("springessentials")
    .declareParameters(new SqlOutParameter("v_newID", Types.INTEGER),
        new SqlParameter("v_name", Types.VARCHAR), 
        new SqlParameter("v_STATUS", Types.VARCHAR),
        new SqlParameter("v_priority", Types.INTEGER),
        new SqlParameter("v_createdUserId", Types.INTEGER),
        new SqlParameter("v_createdDate", Types.DATE),
        new SqlParameter("v_assignedUserId", Types.INTEGER),
        new SqlParameter("v_comment", Types.VARCHAR));

前面的代码声明了SimpleJdbcCall,它调用存储过程(在 PostgreSQL 中,存储过程也称为函数)及其所有参数。一旦声明了这个,它可以在运行时重复使用任意次数。通常,您在类级别(您的 DAO)上声明它。以下代码说明了我们如何在运行时调用它:

@Override
public void createTask(Task task) {
    SqlParameterSource inParams = new 
        MapSqlParameterSource().addValue("v_name", task.getName())
        .addValue("v_STATUS", task.getStatus())
        .addValue("v_priority", task.getPriority())
        .addValue("v_createdUserId", task.getCreatedBy().getId())
        .addValue("v_createdDate", task.getCreatedDate())
        .addValue("v_assignedUserId", task.getAssignee() == null ?         null : task.getAssignee().getId())
        .addValue("v_comment", task.getComments());

    Map<String, Object> out = createTaskStoredProc.execute(inParams);
    task.setId(Long.valueOf(out.get("v_newID").toString()));
}

SimpleJdbcInsert通常声明如下所示:

SimpleJdbcInsert simpleInsert = new SimpleJdbcInsert(dataSource)
  .withTableName("tbl_user")
  .usingGeneratedKeyColumns("id");

请注意以下代码片段中表名旁边生成的键列的声明。同样,这通常在类级别声明以便更好地重用。现在,看看这在运行时是如何调用的。

public void createUser(User user) {
   Map<String, Object> parameters = new HashMap<>(4);
   parameters.put("name", user.getName());
   parameters.put("user_name", user.getUserName());
   parameters.put("password", user.getPassword());
   parameters.put("dob", user.getDateOfBirth());

   Number newId = simpleInsert.executeAndReturnKey(parameters);
   user.setId(newId.longValue());
}

您可以看到在执行后返回生成的键,该键设置回User对象。SimpleJdbcCallSimpleJdbcInsertJdbcTemplate的方便替代品;您可以一致使用这些解决方案,或者在同一个应用程序中混合使用它们。

使用 Sql*类的 JDBC 操作

org.springframework.jdbc.object包中的一组类以更面向对象的方式执行 JDBC 操作。以下表格列出了其中最常见的类:

组件职责
MappingSqlQuerySQL 查询的具体表示,支持RowMapper,并具有各种方便的executefind*方法。也支持命名参数。
SqlUpdate执行 SQL 更新(INSERTUPDATEDELETE)操作,支持命名参数和键持有者(用于检索生成的键)。
SqlCall执行存储过程和函数的基于 SQL 的调用,支持命名参数和键持有者(用于检索生成的键)。

下面的代码示例了MappingSqlQuery的使用:

public Task findById(Long taskId) {
   MappingSqlQuery<Task> query = new MappingSqlQuery<Task>() {

      @Override
      protected Task mapRow(ResultSet rs, int rowNum) throws SQLException {
         return new RowMapper<Task>() {
            @Override
            public Task mapRow(ResultSet rs, int rowNum) throws SQLException {
               Task task = new Task();
               task.setId(rs.getLong("id"));
               ...
               return task;
            }
         }.mapRow(rs, rowNum);
      }
   };

   query.setJdbcTemplate(jdbcTemplate);
   query.setSql("select id, name, status, priority, created_user_id," + " created_date, assignee_user_id, completed_date, comments " + "from tbl_task where id = ?");
   query.declareParameter(new SqlParameter("id", Types.INTEGER));

   return query.findObject(taskId);
}

可以使用SqlUpdate执行 SQL 更新(INSERTUPDATEDELETE),代码更具描述性,如下面的示例代码所示:

@Override
public void deleteTask(Task task) {
   SqlUpdate sqlUpdate = new SqlUpdate(this.jdbcTemplate.getDataSource(), "DELETE FROM TBL_TASK WHERE ID = ?");
   sqlUpdate.declareParameter(new SqlParameter("ID", Types.NUMERIC));
   sqlUpdate.compile();
   sqlUpdate.update(task.getId());
}

SqlUpdate提供了各种方便的更新方法,适用于许多参数组合。您可以根据自己的方便和首选的编程风格混合使用前面列出的任何 Spring JDBC 组件。

Spring Data

Spring Data 是 Spring 系列下的一个总称项目,旨在提供跨多种不同数据存储的一致数据访问,包括关系型和 NoSQL 数据库,以及其他类型的数据存储,如 REST(HTTP)、搜索引擎和 Hadoop。在 Spring Data 下,有针对每种特定方法和数据存储的子项目,由这些技术的公司或开发人员组合而成。Spring Data 显著简化了数据层的构建,无论底层数据库和持久化技术如何。

以下表格列出了一些 Spring Data 子项目及其简要描述:

项目描述
Spring Data Commons包含核心 Spring Data 存储库规范和所有 Spring Data 项目的支持类。指定存储库、查询、审计和历史等概念。
Spring Data JPA处理基于 JPA 的存储库。
Spring Data MongoDB提供与 MongoDB 的轻松集成,包括对查询、条件和更新 DSL 的支持。
Spring Data Redis与 Redis 内存数据结构存储集成,来自 Spring 应用程序。
Spring Data Solr提供与 Apache Solr 的集成,这是一个基于 Apache Lucene 的强大的开源搜索平台。
Spring Data Gemfire提供与 Pivotal Gemfire 的轻松集成,这是一个提供实时数据访问、可靠的异步事件通知和保证消息传递的数据管理平台。
Spring Data KeyValue处理基于键值的数据存储。
Spring Data REST用 REST API 公开存储库。

Spring Data 组合包含了更多官方 Spring Data 项目未涵盖的数据存储的社区模块。几个非常流行的开源和专有数据库的社区正在为这些项目做出贡献,这使得 Spring Data 成为构建企业应用程序数据访问层的经过验证的解决方案的绝佳来源,无论底层数据存储是什么。Cassandra、Neo4J、Couchbase 和 ElasticSearch 是基于 Spring Data 的社区项目的一些例子。

Spring Data Commons

Spring Data 通过名为 Spring Data Commons 的一致 API 标准化了所有存储特定模块(子项目)的数据访问。Spring Data Commons 是所有 Spring Data 模块的基本规范和指南。所有 Spring Data 子项目都是 Spring Data Commons 的存储特定实现。

Spring Data Commons 定义了 Spring Data 模块的核心组件和一般行为。

  • Spring Data 存储库规范

  • 查询派生方法

  • Web 支持

  • 审计

我们将在以下部分检查每个组件及其设置和用法。

Spring Data 存储库规范

org.springframework.data.repository.Repository是 Spring Data 抽象的中心接口。这个标记接口是 Spring Data Commons 的一部分,有两个专门的扩展,CrudRepositoryPagingAndSortingRepository

public interface CrudRepository<T, ID extends Serializable>
    extends Repository<T, ID> {
    ...
}

存储库管理域实体(设计为 POJO)。CrudRepository为实体提供了以下 CRUD 操作。

  • save(One)save(List)

  • findfindOnefindAll

  • deletedeleteAll

  • count

  • exists

PagingAndSortingRepositoryCrudRepository上添加了分页和排序功能。它有以下两种方法:

  • Page<T> findAll(Pageable)

  • Iterable<T> findAll(Sort)

现在是时候向前跳转并讨论 Spring Data 的技术和存储特定模块。我们将涵盖 Spring Data JPA 和 Spring Data MongoDB,以说明数据库宇宙中的两个完全不同的世界:关系和 NoSQL。当我们使用特定实现时,我们使用特定于实现的存储库,但您的方法接口保持不变;因此,理论上,从特定的 Spring Data 实现切换到另一个不会影响您的客户端程序(服务、控制器或测试用例)。

Spring Data JPA

Spring Data JPA 是基于JPAJava 持久性架构)的 Spring Data 实现,处理对象关系数据访问。对于开发人员,大部分编程都是基于 Spring Data Commons 中描述的内容,而 Spring Data JPA 允许进行一些特定于关系 SQL 和 JPA 的额外定制。主要区别在于存储库设置和使用@Query注解进行查询优化。

启用 Spring Data JPA

在您的项目中启用 Spring Data JPA 是一个简单的两步过程:

  1. spring-data-jpa依赖项添加到您的maven/gradle构建文件中。

  2. 在 bean 配置中声明启用 JPA 存储库。

在 Maven 中,您可以像下面的代码一样添加spring-data-jpa依赖项:

<dependency>
  <groupId>org.springframework.data</groupId>
  <artifactId>spring-data-jpa</artifactId>
  <version>${spring-data-jpa.version}</version>
</dependency>

如果您正在使用 XML,可以启用 JPA 存储库,如下所示:

  <jpa:repositories base-package="com.taskify.dao" />

在 Java 配置的情况下,您只需注释以启用 JPA 存储库。

@Configuration
@ComponentScan(basePackages = {"com.taskify"})
@EnableJpaRepositories(basePackages = "com.taskify.dao")
public class JpaConfiguration {
  ...
}

JpaRepository

启用 JPA 存储库后,Spring 会扫描给定包中使用@Repository注释的 Java 类,并创建完全功能的代理对象,准备供使用。这些是您的 DAO,您只需定义方法,Spring 会在运行时为您提供基于代理的实现。看一个简单的例子:

public interface TaskDAO extends JpaRepository<Task, Long>{

  List<Task> findByAssigneeId(Long assigneeId);

  List<Task> findByAssigneeUserName(String userName);
}

Spring 生成智能实现,实际上在代理实现内部执行所需的数据库操作,查看方法名称和参数。

Spring Data MongoDB

MongoDB 是最受欢迎的面向文档的 NoSQL 数据库之一。它以BSON二进制 JSON)格式存储数据,允许您将整个复杂对象存储在嵌套结构中,避免将数据分解成大量关系表的需求。其嵌套对象结构直接映射到面向对象的数据结构,并消除了任何对象关系映射的需求,就像 JPA/Hibernate 一样。

Spring Data MongoDB 是 MongoDB 的 Spring Data 模块。它允许将 Java 对象直接映射到 MongoDB 文档。它还为连接到 MongoDB 并操作其文档集合提供了全面的 API 和基础支持。

启用 Spring Data MongoDB

Spring Data MongoDB 可以通过以下步骤启用:

  1. 在构建文件(maven/gradle)中添加spring-data-mongodb

  2. 在 Spring 元数据配置中注册一个 Mongo 实例。

  3. 向 Spring 元数据添加mongoTemplate Spring Bean。

使用 Maven 添加spring-data-mongodb依赖项应该如下所示:

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-aop</artifactId>
  <version>${spring.framework.version}</version>
</dependency>

您可以在 XML 元数据中注册一个 Mongo 实例,如下行所示:

<mongo:mongo host="192.168.36.10" port="27017" />

这个 Mongo 实例是运行在服务器上的实际 MongoDB 实例的代理。

简单的mongoTemplate看起来像以下代码中给出的清单:

<bean id="mongoTemplate" class="org.springframework.data.mongodb.core.MongoTemplate">
  <constructor-arg ref="mongo" />
  <constructor-arg name="databaseName" value="Taskify" />
</bean>

MongoRepository

MongoRepository 是 Spring Data MongoDB 的特定于 MongoDB 的存储库。它看起来非常类似于JpaRepository。看一个样本MongoRepository类:

public interface TaskDAO extends MongoRepository<Task, String>{

  List<Task> findByAssigneeId(String assigneeId);

  @Query("{ 'status' : 'Complete' }")
  List<Task> findCompletedTasks();

  @Query(value = "{ 'status' : 'Open', assignee.id: ?0 }")
  List<Task> findOpenTasksByAssigneeId(String assigneeId);
  ...
}

领域对象和实体

数据驱动的应用程序通常将领域对象设计为实体,然后在运行时将它们持久化到数据库中,可以是关系表,也可以是键值对文档结构。Spring Data 处理领域实体就像处理任何其他持久化框架一样。为了说明存储库的用法,我们将引用以下三个相关实体,它们在程序中设计为普通的旧 Java 对象POJOs)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

以下是 Java 表示。第一个是用于 JPA 的注释,另外两个是用于 MongoDB 的。JPA 实体使用@Entity进行注释。列映射到每个字段。请记住,除了注释之外,您还可以使用基于 XML 的映射来为 JPA 实体进行映射。XML 映射提供了包括集中控制和可维护性在内的多个好处。本例中为简单起见使用注释,假设读者已经熟悉 JPA 或 Hibernate 映射。

@Entity
@Table(name = "TBL_USER", uniqueConstraints = @UniqueConstraint(name = "UK_USER_USERNAME", columnNames = {"USER_NAME" }) )
public class User {

  @Id
  @SequenceGenerator(name = "SEQ_USER", sequenceName = "SEQ_USER", allocationSize = 1, initialValue=1001)
  @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "SEQ_USER")
  private Long id;

  @Column(name = "NAME", length = 200)
  private String name;

  @Column(name = "USER_NAME", length = 25)
  private String userName;

  @Column(name = "PASSWORD", length = 20)
  private String password;

  @Column(name = "DOB")
  @Temporal(TemporalType.TIMESTAMP)
  private Date dateOfBirth;

  @ManyToOne(optional = true)
  @JoinColumn(name = "FILE_ID", referencedColumnName = "ID")
  private File profileImage;

  public User() {}

  public User(Long id, String name, String userName, String password, Date dateOfBirth) {
    super();
    this.id = id;
    this.name = name;
    this.userName = userName;
    this.password = password;
    this.dateOfBirth = dateOfBirth;
  }

  public Long getId() {
    return id;
  }
  ...
}

以下是任务实体,注释为 MongoDB 文档。Mongo 实体使用@Document进行注释。它需要一个 ID 字段,可以用@Id进行注释,也可以用名称id进行注释。

@Document(collection = "tasks")
public class Task {

  @Idprivate String id;
  private String name;
  private int priority;
  private String status;
  private User createdBy;
  private Date createdDate;
  private User assignee;
  private Date completedDate;
  private String comments;

  public Task() {}
  ...
}

文件实体被注释为 JPA 实体。

@Entity
@Table(name = "TBL_FILE")
public class File {

  @Id
  @SequenceGenerator(name = "SEQ_FILE", sequenceName = "SEQ_FILE", allocationSize = 1)
  @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "SEQ_FILE")
  private Long id;

  @Column(name = "FILE_NAME", length = 200)
  private String fileName;
  ...
}

查询解析方法

除了在接口级别声明的查询(findcountdeleteremoveexists)方法外,CrudRepository还支持使用@Query注解方法声明查询,该注解方法可以使用任何名称,这有助于从作为参数给定的SpELSpring Expression Language)表达式中派生实际的 SQL 查询。在这两种查询派生选项中,Spring Data 采用以下查询查找策略之一:

查询查找策略描述
CREATE从方法名称生成特定于模块的查询。
USE_DECLARED_QUERY使用由注解或其他方式声明的查询。
CREATE_IF_NOT_FOUND此策略结合了前两种策略。这是默认策略。

查询查找策略通常在启用 JPA 存储库时设置。

<jpa:repositories base-package="com.taskify.dao" query-lookup-strategy="create-if-not-found"/>

查询生成策略(CREATE)围绕实体的属性及其依赖关系以嵌套方式工作。作为开发人员,您可以根据特定格式定义方法名称,这些方法名称可以被 Spring Data 解释和实现。查询方法的一般结构如下所示:

[return Type] [queryType][limitKeyword]By[criteria][OrderBy][sortDirection]

  • 返回类型可以是实体<T>本身(在唯一结果的情况下),列表<T>,流<T>,页面<T>,原始数字,Java 包装类型,void,future<T>CompletableFuture<T>ListenableFuture<T>等。最后三个用于 Spring 的异步方法执行,并应该用@Async注解。

  • queryType可以是findreadquerycountexistsdelete等。

  • limitKeyword支持distinctFirst[resultSize]Top[resultSize]。例如First5

  • criteria是通过将一个或多个属性表达式(使用驼峰命名法)与OrAndBetweenGreaterThanLessThanIsNullStartsWithExists等标准运算符组合而成的。条件可以后缀为IgnoreCaseAllIgnoreCase,以应用大小写不敏感性。

  • OrderBy按原样使用,后面跟着属性表达式。

  • sortDirection可以是AscDesc中的任意一个。这仅与OrderBy一起使用。

让我们看一些示例以更清晰地说明。以下示例代码说明了如何构造查询(或删除)方法,以便 Spring Data 可以在运行时生成实际的 SQL 查询。

public interface UserDAO extends JpaRepository<User, Long> {

  // Returns unique user with given user-name
  User findByUserName(String userName);

  // Returns a paginated list of users whose name starts with // given value
  Page<User> findByNameStartsWith(String name, Pageable pageable);

  // Returns first 5 users whose name starts with given value, 
  // order by name descending
  List<User> findTop5ByNameStartsWithOrderByNameDesc(String name);

  // Returns number of users whose birth date is before the given // value
  Long countUsersDateOfBirthLessThan(Date dob);

  // Deletes the User of given id
  void deleteById(Long userId);

  // Asynchronously returns a list of users whose name contains // the given value
  @Async
  Future<List<User>> findByNameContains(String name);
}

前面的示例显示JpaRepositoryMongoRepository的工作方式相同;您只需要扩展它,而不需要更改方法签名。您已经看到了遍历实体的根级属性,适当组合运算符的约束查询和过滤方法。除了根级属性,您还可以遍历和过滤嵌套属性,以定义查询约束,换句话说,限制结果。看一下以下示例:

public interface TaskDAO extends MongoRepository<Task, String>{

  List<Task> findByAssigneeId(Long assigneeId);

  List<Task> findByAssigneeUserName(String userName);
}

在前面的示例中列出的方法中,遍历了任务实体的嵌套属性:

  • findByAssigneeId = task.assignee.id

  • findByAssigneeUserName = task.assignee.userName

您可以根据实体的复杂程度和要求,遍历实体的任何嵌套元素级别。

使用@Query 注释

除了根据方法名称自动生成查询的自动生成查询之外,Spring Data 还允许您在存储库本身中直接声明实体的查询,而不是方法名称。您可以使用 SpEL 声明查询,Spring Data 会在运行时解释它,并(代理存储库)为您生成查询。这是查询解析策略的一种实现:USE_DECLARED_QUERY

让我们看一些不言自明的例子:

public interface TaskDAO extends JpaRepository<Task, Long>{	

  @Query("select t from Task t where status = 'Open'")
  List<Task> findOpenTasks();

  @Query("select t from Task t where status = 'Complete'")
  List<Task> findCompletedTasks();

  @Query("select count(t) from Task t where status = 'Open'")
  int findAllOpenTasksCount();

  @Query("select count(t) from Task t where status = 'Complete'")
  int findAllCompletedTasksCount();

  @Query("select t from Task t where status = 'Open' and assignee.id = ?1")
  List<Task> findOpenTasksByAssigneeId(Long assigneeId);

  @Query("select t from Task t where status = 'Open' and assignee.userName = ?1")
  List<Task> findOpenTasksByAssigneeUserName(String userName);

  @Query("select t from Task t where status = 'Complete' and assignee.id = ?1")
  List<Task> findCompletedTasksByAssigneeId(Long assigneeId);

  @Query("select t from Task t where status = 'Complete' and assignee.userName = ?1")
  List<Task> findCompletedTasksByAssigneeUserName(String userName);
}

从前面的示例中可以看出,我们可以遍历嵌套属性以限制查询,在其中的条件部分。您还可以在同一个存储库中同时拥有查询生成策略(CREATEUSE_DECLARED_QUERY)。

前面的示例是基于 Spring Data JPA 的;Spring Data MongoDB 的等效部分在以下代码中给出。您可以看到@Query注释值与 MongoDB 结构相比有何不同。

public interface TaskDAO extends MongoRepository<Task, String>{

  @Query("{ 'status' : 'Open' }")
  List<Task> findOpenTasks();

  @Query("{ 'status' : 'Complete' }")
  List<Task> findCompletedTasks();

  @Query(value = "{ 'status' : 'Open' }", count = true)
  int findAllOpenTasksCount();

  @Query(value = "{ 'status' : 'Complete' }", count = true)
  int findAllCompletedTasksCount();

  @Query(value = "{ 'status' : 'Open', assignee.id: ?0 }")
  List<Task> findOpenTasksByAssigneeId(String assigneeId);

  @Query(value = "{ 'status' : 'Open', assignee.userName: ?0 }")
  List<Task> findOpenTasksByAssigneeUserName(String userName);

  @Query(value = "{ 'status' : 'Complete', assignee.id: ?0 }")
  List<Task> findCompletedTasksByAssigneeId(String assigneeId);

  @Query(value = "{ 'status' : 'Open', assignee.userName: ?0 }")
  List<Task> findCompletedTasksByAssigneeUserName(String userName);
}

Spring Data web 支持扩展

Spring Data 为 Spring MVC 应用程序提供了一个智能扩展,称为SpringDataWebSupport,如果您启用它,它会自动集成一些生产力组件。如果您正在使用 Spring Data 存储库编程模型进行数据访问,它主要会直接从请求参数中解析域实体作为PageableSort实例,并与请求映射控制器方法集成。

在使用这些功能之前,您需要为项目启用SpringDataWebSupport。如果您使用 Java 配置,可以像以下代码中所示注释@EnableSpringDataWebSupport

@Configuration
@EnableWebMvc
@ComponentScan(basePackages = {"com.taskify"})
@EnableSpringDataWebSupport
@EnableJpaRepositories(basePackages = "com.taskify.dao")
public class ApplicationConfiguration {
 ...
}

在 XML 元数据的情况下,您可以像以下代码中所示将SpringDataWebConfiguration注册为 Spring bean:

<bean class="org.springframework.data.web.config.SpringDataWebConfiguration" />

一旦您设置了SpringDataWebSupport,您就可以开始在请求映射方法中使用 Spring Data 实体作为请求参数,如下面的代码所示:

@RestController
@RequestMapping("/api/v1/user")
@CrossOrigin
public class UserController {

  @RequestMapping(path = "/{id}", method = RequestMethod.GET)
  public User getUser(@PathVariable("id") User user) {
    return user;
  }
  ...
}

在前面的方法中,您可以看到 Spring Data 会透明地为您使用UserRepository加载User实体数据。同样,您可以接受针对 JSON 或 XML 的 POST 请求的PageableSort实例。明智地使用SpringDataWebSupport扩展可以使您的代码更清洁和更易于维护。

使用 Spring Data 进行审计

跟踪数据修改是严肃业务应用程序的关键特性。管理员和经理急于知道数据库中保存的某些业务信息是何时以及谁进行了更改。Spring Data 提供了智能且简单的方法,可以透明地审计数据实体。Spring Data 提供了以下有意义的注释,用于在系统中捕获修改的用户和时间数据实体:

注释预期类型
@CreatedBy创建实体的主体用户。通常,它是表示域用户的另一个实体。
@CreatedDate记录实体创建的时间。支持的类型:java.util.Date、日历、JDK 8 日期/时间类型、Joda DateTime
@LastModifiedBy最后更新实体的用户主体。与 @CreatedBy 相同类型。
@LastModifiedDate记录实体上次更新的时间。支持的类型与 @CreatedDate 相同。

典型的 JPA 实体应该如下所示的代码:

@Entity
@Table(name = "tbl_task")
public class Task {

  @Id
  private Long id;
  ...
  @ManyToOne(optional = true)
  @JoinColumn(name = "CREATED_USER_ID", referencedColumnName = "ID")
  @CreatedBy
  private User createdBy;

  @Column(name = "CREATED_DATE")
  @Temporal(TemporalType.TIMESTAMP)
  @CreatedDate
  private Date createdDate;

  @ManyToOne(optional = true)
  @JoinColumn(name = "MODIFIED_USER_ID", referencedColumnName = "ID")
  @LastModifiedBy
  private User modifiedBy;

  @Column(name = "MODIFIED_DATE")
  @Temporal(TemporalType.TIMESTAMP)
  @LastModifiedDate
  private Date modifiedDate;
  ...
}

如果您使用 XML 而不是注释来映射实体,则可以实现一个可审计接口,该接口强制您实现审计元数据字段,或者扩展 AbstractAuditable,这是 Spring Data 提供的一个方便的基类。

由于您记录了创建和修改实体的用户信息,您需要帮助 Spring Data 从上下文中捕获该用户信息。您需要注册一个实现了 AuditAware<T> 的 bean,其中 T 是您用 @CreatedBy@LastModifiedBy 注释的字段的相同类型。看一下以下示例:

@Component
public class SpringDataAuditHelper implements AuditorAware<User> {

  ...
  @Override
  public User getCurrentAuditor() {
    // Return the current user from the context somehow.
  }

}

如果您使用 Spring Security 进行身份验证,那么 getCurrentAuditor 方法应该从 SecurityContextHolder 类中获取并返回用户,如下所示:

@Component
public class SpringDataAuditHelper implements AuditorAware<User> {

  ...
  @Override
  public User getCurrentAuditor() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    if (authentication == null || !authentication.isAuthenticated()) {
      return null;
    }
    return ((User) authentication.getPrincipal()).getUser();
  }
}

现在您的审计基础设施已经准备就绪,您对可审计实体所做的任何修改都将被 Spring Data 透明地跟踪。

到目前为止,您已经掌握了强大的 Spring Data,并且知道如何使用 Spring Data 存储库创建优雅、干净但非常强大的数据访问层,现在是时候考虑如何确保应用程序的数据完整性和可靠性了。Spring 事务就是答案;让我们在下一节中探讨它。

Spring 事务支持

数据驱动的企业系统将数据完整性视为至关重要,因此事务管理是主要数据库和应用服务器支持的关键功能。Spring 框架提供了全面的事务支持,抽象了任何底层基础设施。Spring 事务支持包括跨不同事务选择的一致方法,如 JTA、JPA 和 JDO。它与所有 Spring 数据访问机制很好地集成。Spring 事务支持声明式和编程式事务管理。

注意

事务 可以被定义为数据交换的原子单位,通常是关系数据库中的 SQL 语句,应该作为一个块(全部或无)提交或回滚。事务系统或事务管理框架在参与系统或资源(如数据库和消息队列)之间强制执行ACID原子性一致性隔离性持久性)属性。

Spring 事务的相关性

企业 Java 应用服务器本地提供JTAJava 事务 API)支持,它使分布式事务(也称为全局事务)跨多个资源、应用程序和服务器。传统上,企业 Java BeanEJB)和消息驱动 BeanMDB)用于容器管理的事务CMT),它基于 JTA 和 JNDI。JTA 事务管理资源密集型;其异常处理基于已检查的异常,因此不利于开发人员。此外,使用 EJB CMT 进行单元测试很困难。

对于那些不想使用资源密集型的 JTA 事务的人来说,本地事务是另一个可用的选项,它允许您使用诸如 JDBC 之类的 API 来以编程方式强制执行特定于资源的事务。虽然相对容易使用,但它仅限于单个资源,因为多个资源不能参与单个事务。此外,本地事务通常是侵入性的,因此会污染您的代码。

Spring 事务抽象通过提供一个一致的事务模型来解决全局和本地事务的问题,可以在任何环境中运行。虽然它支持声明式和编程式事务管理,但对于大多数情况来说,声明式模型已经足够了。Spring 事务消除了仅仅为了事务而需要像 JBoss 或 WebLogic 这样的应用服务器的需求。您可以从在简单的 Servlet 引擎(如 Tomcat)上使用 Spring 的本地事务开始,然后在不触及业务代码的情况下,通过更改 Spring 元数据中的事务管理器,将其扩展到应用服务器上的分布式事务。

大多数应用程序只需要本地事务,因为它们不涉及多个服务器或诸如数据库、JMS 和 JCA 之类的事务资源;因此,它们不需要一个完整的应用服务器。对于跨多个服务器的分布式事务,需要 JTA,这需要一个应用服务器,因为 JTA 需要使用 JNDI 来查找数据源。JNDI 通常只在应用服务器中可用。在应用服务器内部使用JTATransactionManager来实现 JTA 功能。

注意

当您在应用服务器内部部署 Spring 应用程序时,您可以使用特定于服务器的事务管理器来利用它们的全部功能。只需将事务管理器切换到使用特定于服务器的JtaTransactionManager实现,如WebLogicJTATransactionManagerWebSphereUowTransactionManager,并将其放入 Spring 元数据中。现在您的所有代码都是完全可移植的。

Spring 事务基础

Spring 事务管理抽象是围绕一个名为PlatformTransactionManager的接口设计的,您需要在 Spring 元数据中将其配置为 Spring bean。PlatformTransactionManager管理执行事务操作的实际事务实例,如提交和回滚,基于定义事务策略的TransactionDefinition实例。TransactionDefinition定义了关键的事务属性,如隔离、传播、事务超时和给定事务实例的只读状态。

注意

事务属性决定了事务实例的行为。它们可以以编程方式设置,也可以以声明方式设置。事务属性包括:

隔离级别:定义了一个事务与并行运行的其他事务之间的隔离程度。有效值包括:NoneRead committedRead uncommittedRepeatable readsSerializableRead committed不能看到其他事务的脏读。

传播:确定数据库操作的事务范围,与其自身之前、之后和嵌套在其内部的其他操作相关。有效值包括:REQUIREDREQUIRES_NEWNESTEDMANDATORYSUPPORTSNOT_SUPPORTEDNEVER

超时:事务在完成之前可以持续运行或等待的最长时间段。一旦超时,它将自动回滚。

只读状态:在此模式下,您无法保存读取的数据。

这些事务属性并不特定于 Spring,而是反映了标准的事务概念。TransactionDefinition接口在 Spring 事务管理上下文中指定了这些属性。

根据您的环境(独立、web/app 服务器)和您使用的持久化机制(如纯 JDBC、JPA 和 Hibernate),您可以选择适当的PlatformTransactionManager实现,并根据需要在 Spring 元数据中进行配置。在幕后,使用 Spring AOP,Spring 将TransactionManager注入到您的代理 DAO(或 JPA 的EntityManager)中,并执行您的事务方法,应用在 Spring 配置中声明的事务语义,无论是使用@Transactional注解还是等效的 XML 标记。我们将在本章后面讨论@Transactional注解及其 XML 等效。

对于操作单个DataSource对象的应用程序,Spring 提供了DataSourceTransactionManager。以下显示了如何在 XML 中配置它:

<bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
  <property name="dataSource" ref="taskifyDS" />
</bean>

对于多个DataSource对象或事务资源,您需要具有 JTA 功能的JtaTransactionManager,通常委托给容器 JTA 提供程序。您需要在 Java EE 应用服务器中使用DataSource对象,在服务器上定义,并通过 JNDI 查找,以及JtaTransactionManager。典型的组合应该如下代码片段所示:

<bean id="txManager" class="org.springframework.transaction.jta.JtaTransactionManager" />
</bean>
<jee:jndi-lookup id="taskifyDS" jndi-name="java:jboss/datasources/taskify" expected-type="javax.sql.DataSource/>

如果您正在使用 Hibernate 和只有一个DataSource(没有其他事务资源),那么最好的选择是使用HibernateTransactionManager,它要求您将会话工厂作为依赖项传递。对于 JPA,Spring 提供了JpaTransactionManager,它绑定了一个单独的 JPA EntityManager实例。但是,在应用容器环境中建议使用JtaTransactionManager

Spring 为 WebLogic 和 WebSphere 的应用服务器提供了专门的事务管理器,以便充分利用容器特定的事务协调器的全部功能。在相应的环境中使用WebLogicTransactionManagerWebsphereUowTransactionManager

声明式事务管理

将事务语义从业务代码中分离出来,放入 XML 文件或方法上方的注解通常称为声明式事务管理。Spring 框架允许您通过其声明式事务管理功能透明且非侵入地将事务行为应用到您的 bean 中。

您可以在任何 Spring bean 上声明性地应用 Spring 事务,与 EJB CMT 不同。使用 Spring 事务,您可以在元数据中以 AOP 样式指定 bean 方法周围的事务建议;然后 Spring 将在运行时使用 AOP 应用您的这些建议。您可以设置回滚规则,以指定哪些异常在哪些 bean 或方法周围导致自动回滚或非回滚。

事务模式 - 代理和 AspectJ

Spring 事务支持两种事务模式:代理模式和 AspectJ 模式。代理是默认和最流行的模式。在代理模式中,Spring 创建一个 AOP 代理对象,包装事务性 bean,并在使用基于元数据的事务方面的方法周围透明地应用事务行为。由 Spring 根据事务元数据创建的 AOP 代理,借助配置的PlatformTransactionManager,在事务性方法周围执行事务。

如果选择使用 AspectJ 模式进行事务处理,则事务方面将被编织到指定方法周围的 bean 中,在编译时修改目标类的字节码。在这种情况下不会进行代理。在特殊情况下,例如调用具有不同传播级别的同一类的事务方法时,代理无法帮助,您将需要使用 AspectJ 模式。

定义事务行为

Spring 提供了两种方便的方法来声明性地定义 bean 的事务行为:

  • XML 元数据文件中的事务 AOP 配置

  • 使用@Transactional注解

让我们从 XML 文件中的 AOP 配置开始。有关配置 AOP、使用切面、切入点、建议等的详细讨论,请参阅第一章中的面向切面编程部分,开始使用 Spring Core

通常,您可以在 XML 元数据文件中使用切入点表达式声明事务建议和切入点。最佳方法是将事务配置保存在单独的 bean 定义文件中(例如transation-settings.xml),并将其导入到主应用程序上下文文件中。

通常,您可以声明事务建议和其他语义,如下面的代码所示:

<!-- transactional advices --> 
<tx:advice id="txAdvice" transaction-manager="transactionManager">
  <!-- the transactional semantics... -->
  <tx:attributes>
    <!-- all methods starting with 'get' are read-only -->
    <tx:method name="find*" read-only="true" />
    <!-- other methods use the default transaction settings (see below) -->
    <tx:method name="*" isolation="DEFAULT" propagation="REQUIRED" />
  </tx:attributes>
</tx:advice>

<!-- Applying the above advices to the service layer methods -->
<aop:config>
  <aop:pointcut id="allServiceMethods"
  expression="execution(* com.taskify.service.*.*(..))" />
  <aop:advisor advice-ref="txAdvice" pointcut- ref="allServiceMethods" />
</aop:config>

您可以看到,这个 AOP 配置指示 Spring 如何在方法周围使用切入点编织事务建议。它指示TransactionManager使整个服务层的所有查找方法都为只读,并强制其他方法具有事务传播:REQUIRED,这意味着如果方法的调用者已经处于事务上下文中,则该方法加入相同的事务而不创建新的事务;否则,将创建一个新的事务。如果要为此方法创建一个不同的事务,应该使用REQUIRES_NEW传播。

另外,请注意,事务隔离级别被指定为DEFAULT,这意味着将使用数据库的默认隔离级别。大多数数据库默认为READ_COMMITTED,这意味着事务线程无法看到其他事务中正在进行的数据(脏读)。

设置回滚规则

使用 Spring 事务,您可以在相同的<tx:advice>块中声明性地设置回滚规则,如下面的代码所示:

<tx:advice id="txAdvice" transaction-manager="transactionManager">
  <tx:attributes>
    ...
    <tx:method name="completeTask" propagation="REQUIRED" rollback-for="NoTaskFoundException"/>
    <tx:method name="findOpenTasksByAssignee" read-only="true" no-rollback-for="InvalidUserException"/>
    <tx:method name="*" isolation="DEFAULT" propagation="REQUIRED" />
  </tx:attributes>
</tx:advice>

您可以使用<tx:method>元素的rollback-forno-rollback-for属性指定哪些异常应该回滚事务,哪些异常不应该回滚事务,用于您的业务操作。

注意

PlatformTransactionManager接口的方法抛出的TransactionException是未经检查的异常RuntimeException。在 Spring 中,未经检查的异常会自动回滚事务。除非在元数据中指定,使用rollback-for属性,否则不会回滚已检查的或应用程序异常。

Spring 事务允许您使用 Spring AOP 和 SpEL 以分钟级别的粒度自定义 bean 的事务行为。此外,您可以在<tx:method>元素上指定事务的行为属性,如传播、隔离和超时。

使用@Transactional注解

@Transactional注解描述了方法或类的事务属性。类级别的注解适用于所有方法,除非在方法级别明确注释。它支持您在 XML 配置中设置的所有属性。请参阅以下示例:

@Service
@Transactional
public class TaskServiceImpl implements TaskService {
  ...
  public Task createTask(Task task) {
    if (StringUtils.isEmpty(task.getStatus()))
      task.setStatus("Open");
    taskDAO.save(task);
    return task;
  }

  @Transactional(propagation = Propagation.REQUIRED, rollbackFor = NoUserFoundException)
  public Task createTask(String name, int priority, Long createdByuserId, Long assigneeUserId, String comments) {
    Task task = new Task(name, priority, "Open", userService.findById(createdByuserId), null, userService.findById(assigneeUserId), comments);
    taskDAO.save(task);
    logger.info("Task created: " + task);
    return task;
  }

  @Transactional(readOnly = true)
  public Task findTaskById(Long taskId) {
    return taskDAO.findOne(taskId);
  }
  ...
}

在上面的示例中,具有传播REQUIRED的事务方法createTaskNoUserFoundException发生时回滚。同样,您也可以在相同级别设置不回滚规则。

注意

@Transactional只能应用于公共方法。如果要对受保护的、私有的或包可见的方法进行注释,请考虑使用 AspectJ,它使用编译时切面编织。Spring 建议仅在具体类上注释@Transactional,而不是在接口上注释,因为在大多数情况下,例如当您使用proxy-target-class="true"mode="aspectj"时,它将无法工作。

@Transactional启用事务管理

在 Spring 可以检测到您的 bean 方法的@Transactional注解之前,您需要在应用程序中首先启用事务管理。您可以在 XML 元数据中使用以下表示法启用事务:

<tx:annotation-driven transaction-manager="transactionManager" />

以下是前面清单的 Java 配置替代方案:

@Configuration
@EnableTransactionManagement
public class JpaConfiguration {
}

当 Spring 看到上述任何设置时,它会扫描应用程序上下文中使用@Transactional注解的 bean 方法。

你可以在这个级别上将事务模式从默认的proxy更改为aspectj

<tx:annotation-driven transaction-manager="transactionManager" mode="aspectj"/>

在这个级别上,你可以设置的另一个属性是proxy-target-class,这只适用于proxy模式的情况。

编程式事务管理

Spring 提供了全面的支持,使用两个组件进行编程式事务管理:TransactionTemplatePlatformTransactionManager。以下代码片段说明了TransactionTemplate的用法:

@Service
public class TaskServiceImpl implements TaskService {
  @Autowired
  private TransactionTemplate trxTemplate;
  ...
  public Task createTask(String name, int priority, Long createdByuserId, Long assigneeUserId, String comments) {

    return trxTemplate.execute(new TransactionCallback<Task>() {
      @Override
      public Task doInTransaction(TransactionStatus status) {
        User createdUser = userService.findById(createdByuserId);
        User assignee = userService.findById(assigneeUserId);
        Task task = new Task(name, priority, "Open", createdUser, null, assignee, comments);
        taskDAO.save(task);
        logger.info("Task created: " + task);
        return task;
      }
    });
  }
}

TransactionTemplate支持设置所有事务属性,就像 XML 配置一样,这样可以更精细地控制,但会将业务代码与事务关注点混合在一起。只有在需要对无法通过声明式事务管理实现的特定功能进行绝对控制时才使用它。如果可能的话,尽量使用声明式事务管理,以获得更好的可维护性和应用管理。

总结

到目前为止,我们已经探讨了 Spring 框架对数据访问和事务的全面覆盖。Spring 提供了多种方便的数据访问方法,这些方法消除了开发人员在构建数据层和标准化业务组件方面的大部分工作。正确使用 Spring 数据访问组件可以使 Spring 应用的数据层清晰且易于维护。利用 Spring 事务支持可以确保应用程序的数据完整性,而不会污染业务代码,并使应用程序可以在不同的服务器环境中移植。由于 Spring 抽象了大部分技术上的繁重工作,构建应用程序的数据层变得成为一项令人愉快的软件工程工作。

第四章:WebSocket 理解

Web 应用的理念建立在一个简单的范式之上。在单向交互中,Web 客户端发送请求到服务器,服务器回复请求,客户端渲染服务器的响应。通信始于客户端的请求,以服务器的响应结束。

我们建立了基于这一范式的 Web 应用;然而,技术上存在一些缺点:客户端必须等待服务器的响应并刷新浏览器才能渲染它。这种单向通信的性质要求客户端发起请求。后来的技术,如 AJAX 和长轮询,为我们的 Web 应用带来了重大优势。在 AJAX 中,客户端发起请求,但不等待服务器的响应。以异步的方式,AJAX 客户端回调方法从服务器获取数据,浏览器的新 DHTML 功能渲染数据而不刷新浏览器。

除了单向行为之外,这些技术的 HTTP 依赖性需要以 HTTPS 头和 cookie 的形式交换额外的数据。这些额外的数据导致了延迟,并成为高度响应的 Web 应用的瓶颈。

WebSocket 将传输的数据量从几千字节减少到几个字节,并将延迟从 150 毫秒减少到 50 毫秒(用于消息数据包加上建立连接的 TCP 往返时间),这两个因素引起了 Google 的注意(Ian Hickson)。

WebSocket(RFC 6455)是一个全双工和双向的协议,以帧的形式在客户端和服务器之间传输数据。WebSocket 通信如下图所示,从客户端和服务器之间的握手过程开始,需要通过 HTTP 连接。由于防火墙只允许某些端口与外部通信,我们无法直接使用 WebSocket 协议:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

WebSocket 通信

在握手过程中,各方(客户端和服务器)决定选择哪种基于套接字的协议来传输数据。在这个阶段,服务器可以使用 HTTP cookies 验证用户,并在认证或授权失败时拒绝连接。

然后,双方从 HTTP 升级到基于套接字的协议。从这一点开始,服务器和客户端在 TCP 连接上进行全双工和双向通道通信。

客户端或服务器可以通过将它们流式传输到帧格式中来发送消息。WebSocket 使用心跳机制,使用 ping/pong 消息帧来保持连接活动。这看起来像是一方发送一个 ping 消息,期望另一方回复一个 pong。任何一方也可以关闭通道并终止通信,就像前面的图表所示。

就像 Web URI 依赖于 HTTP 或 HTTPS 一样,WebSocket URI 使用wswss方案(例如,ws://www.sample.org/wss://www.sample.org/)进行通信。WebSocket 的ws通过 TCP/IP 传输非加密数据,类似于 HTTP 的工作方式。相比之下,wss`依赖于 TCP 上的传输层安全TLS),这种组合带来了数据安全和完整性。

一个很好的问题是在哪里使用 WebSocket。最好的答案是在低延迟和高频率通信至关重要的地方——例如,如果您的端点数据在 100 毫秒内发生变化,并且您希望对数据变化采取非常快速的措施。

Spring Framework 4 包括一个新的 Spring WebSocket 模块,具有 Java WebSocket API 标准(JSR-356)兼容性以及一些附加的增值功能。

虽然使用 WebSocket 为 Web 应用程序带来了优势,但某些浏览器版本的兼容性不足会阻止 WebSocket 通信。为了解决这个问题,Spring 4 包括一个回退选项,以模拟 WebSocket API 以解决浏览器不兼容性。

WebSocket 以帧格式传输数据,除了用一个单独的位来区分文本和二进制数据之外,它对消息内容是中立的。为了处理消息的格式,消息需要一些额外的元数据,并且客户端和服务器应该在应用层协议上达成一致,即所谓的子协议。各方在初始握手期间选择子协议。

WebSocket 不强制使用子协议,但在它们缺失的情况下,客户端和服务器都需要以预定义的样式标准、框架特定或定制格式传输数据。

Spring 支持简单文本定向消息协议STOMP)作为子协议——称为 STOMP over WebSocket——在 WebSocket 通信中。Spring 的消息传递建立在集成概念上,如消息和通道和处理程序,以及消息映射的注释。使用 STOMP over WebSocket 为 Spring WebSocket 应用程序提供了基于消息的功能。

使用所有这些新的 Spring 4 功能,您可以创建一个 WebSocket 应用程序,并向所有订阅的客户端广播消息,以及向特定用户发送消息。在本章中,我们首先创建一个简单的 Spring Web 应用程序,演示如何设置 WebSocket 应用程序以及客户端如何与端点发送和接收消息。在第二个应用程序中,我们将看到 Spring WebSocket 的回退选项如何解决浏览器不兼容性,以及基于代理的消息系统如何使用 STOMP over WebSocket 工作,以及订阅的客户端如何发送和接收消息。然而,在最后一个 Web 应用程序中,我们将展示如何向特定用户发送基于代理的消息。

创建一个简单的 WebSocket 应用程序

在本节中,我们将在开发一个简单的 WebSocket 应用程序时了解 WebSocket 的客户端和服务器组件。如前所述,在 WebSocket 通信中使用子协议是可选的。在本应用程序中,我们没有使用子协议。

首先,你需要设置一个 Spring web 应用程序。为了将请求分派到你的服务(在 Spring WebSocket 中称为处理程序),你需要设置一个框架 Servlet(分派器 Servlet)。这意味着你应该在 web.xml 中注册 DispatcherServlet 并在应用程序上下文中定义你的 bean 和服务。

设置一个 Spring 应用程序需要你以 XML 格式进行配置。Spring 引入了 Spring Boot 模块来摆脱 Spring 应用程序中的 XML 配置文件。Spring Boot 的目标是通过向类添加几行注释并将它们标记为 Spring 构件(bean、服务、配置等)来配置 Spring 应用程序。默认情况下,它还根据类路径中的内容添加依赖项。例如,如果你有一个 web 依赖项,那么 Spring Boot 可以默认配置 Spring MVC。它还允许你覆盖这种默认行为。详细介绍 Spring Boot 需要一本完整的书;我们只是在这里使用它来简化 Spring 应用程序的配置。

这些是该项目的 Maven 依赖项:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.2.5.RELEASE</version>
</parent>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-messaging</artifactId>
    </dependency>
            <dependency>
        <groupId>org.json</groupId>
        <artifactId>json</artifactId>
        <version>20140107</version>
    </dependency>
</dependencies>
<properties>
    <java.version>1.8</java.version>
</properties>
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

正如本节开头所提到的,没有子协议(因此也没有应用层框架)来解释 WebSocket 消息。这意味着客户端和服务器需要处理这项工作并了解消息的格式。

在服务器端,处理程序(端点)接收并提取消息,并根据业务逻辑回复给客户端。在 Spring 中,你可以通过扩展 TextWebSocketHandlerBinaryWebSocketHandler 来创建一个自定义处理程序。TextWebSocketHandler 处理字符串或文本消息(比如 JSON 数据),BinaryWebSocketHandler 处理二进制消息(比如图像或媒体数据)。下面是一个使用 TextWebSocketHandler 的代码清单:

public class SampleTextWebSocketHandler extends TextWebSocketHandler {
   @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        JSONObject jsonObject = new JSONObject(payload);
        StringBuilder builder=new StringBuilder();
        builder.append("From Myserver-").append("Your Message:").append(jsonObject.get("clientMessage"));
        session.sendMessage(new TextMessage(builder.toString()));
   }
}

由于我们这里只处理 JSON 数据,所以 SampleTextWebSocketHandler 类扩展了 TextWebSocketHandlerhandleTextMessage 方法通过接收其有效负载并将其转换为 JSON 数据来获取客户端的消息,然后向客户端发送一条消息。

为了告诉 Spring 将客户端请求转发到端点(或者这里的处理程序),我们需要注册处理程序:

@Configuration
@EnableWebSocket
public class SampleEhoWebSocketConfigurer {
    @Bean
    WebSocketConfigurer webSocketConfigurer(final WebSocketHandler webSocketHandler) {
        return new WebSocketConfigurer() {
            @Override
            public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
                registry.addHandler(new SampleTextWebSocketHandler(), "/path/wsAddress");
            }
        };
    }
    @Bean
    WebSocketHandler myWebsocketHandler() {
        return new SampleTextWebSocketHandler();
    }

@Configuration@EnableWebsocket 告诉 Spring 这是项目的 WebSocket 配置器。它注册了我们的处理程序 (SampleTextWebSocketHandler) 并设置了请求路径(在 WebSocket URL 中,比如 ws://server-ip:port/path/wsAddress)将被转发到这个处理程序。

现在的问题是如何设置一个 Spring 应用程序并将所有这些东西粘合在一起。Spring Boot 提供了一种简单的方式来设置一个基于 Spring 的应用程序,它带有一个可配置的嵌入式 Web 服务器,你可以“只需运行”它:

package com.springessentialsbook.chapter4;
...
@SpringBootApplication
public class EchoWebSocketBootApplication {
    public static void main(String[] args) {
        SpringApplication.run(EchoWebSocketBootApplication
        .class, args);
    }
}

@SpringBootApplicationEchoWebSocketBootApplication 类标记为你的应用程序的特殊配置类,@SpringBootApplication 的行为类似于以下注释:

  • @Configuration 声明该类作为应用程序上下文的 bean 定义

  • @EnableAutoConfiguration 允许 Spring Boot 根据类路径添加一个依赖的 bean 定义(例如,在项目类路径中的 spring-webmvc 告诉 Spring Boot 在 web.xml 中设置一个 web 应用程序的 DispatcherServlet 注册)

  • @ComponentScan 用于扫描同一包内的所有注释(服务、控制器、配置等)并相应地配置它们

最后,main 方法调用 SpringApplication.run 来在一个 web 应用程序中设置一个 Spring 应用程序,而不需要编写一行 XML 配置(applicationContext.xmlweb.xml)。

当客户端想要发送 WebSocket 请求时,它应该创建一个 JavaScript 客户端对象(ws = new WebSocket('ws://localhost:8090/path/wsAddress'))并传递 WebSocket 服务地址。为了接收数据,我们需要附加一个回调监听器(ws.onmessage)和一个错误处理程序(ws.onerror),如下所示:

    function openWebSocket(){
        ws = new WebSocket( 'ws://localhost:8090/path/wsAddress');
        ws.onmessage = function(event){
            renderServerReturnedData(event.data);
        };

        ws.onerror = function(event){
            $('#errDiv').html(event);
        };
    }

    function sendMyClientMessage() {
        var myText = document.getElementById('myText').value;
        var message=JSON.stringify({ 'clientName': 'Client-'+randomnumber, 'clientMessage':myText});
        ws.send(message);
        document.getElementById('myText').value='';
    }

您可以通过运行以下命令来运行应用程序:


mvn spring-boot:run -Dserver.port=8090

这将在端口8090上运行和部署 Web 应用程序(这里不使用8080,因为它可能与您正在运行的 Apache 服务冲突)。因此,应用程序的索引页面将可以在http://localhost:8090/访问(按照read-me.txt中的说明运行应用程序)。它应该是这样的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在 Chrome 浏览器中应用程序的开放页面

当用户在 Chrome 中发送文本时,它将由SampleTextWebSocketHandler处理,处理程序将回复,并且响应将在浏览器中呈现。

如果您尝试在低于 10 的 Internet Explorer 版本中测试此应用程序,您将收到 JavaScript 错误。

正如我们之前讨论的,某些版本的浏览器不支持 WebSocket。Spring 4 提供了一个回退选项来管理这些类型的浏览器。在下一节中,将解释 Spring 的这个特性。

Spring 4 中的 STOMP over WebSocket 和回退选项

在上一节中,我们看到在一个不使用子协议的 WebSocket 应用程序中,客户端和服务器应该意识到消息格式(在这种情况下是 JSON)以便处理它。在本节中,我们在 WebSocket 应用程序中使用 STOMP 作为子协议(这被称为STOMP over WebSocket),并展示这个应用层协议如何帮助我们处理消息。

在上一个应用程序中的消息架构是基于异步客户端/服务器通信的。

spring-messaging模块将异步消息系统的特性引入了 Spring Framework。它基于从 Spring Integration 继承的一些概念,如消息、消息处理程序(处理消息的类)和消息通道(发送者和接收者之间的数据通道,在通信期间提供松散耦合)。

在本节结束时,我们将解释我们的 Spring WebSocket 应用程序如何与 Spring 消息系统集成,并以类似于传统消息系统(如 JMS)的方式工作。

在第一个应用程序中,我们看到在某些类型的浏览器中,WebSocket 通信失败是因为浏览器不兼容。在本节中,我们将解释 Spring 的回退选项如何解决这个问题。

假设您被要求开发一个基于浏览器的聊天室应用程序,匿名用户可以加入聊天室,并且任何用户发送的文本都应该发送给所有活跃用户。这意味着我们需要一个所有用户都应该订阅的主题,并且任何用户发送的消息都应该广播给所有用户。Spring WebSocket 功能满足这些要求。在 Spring 中,使用 STOMP over WebSocket,用户可以以类似于 JMS 的方式交换消息。在本节中,我们将开发一个聊天室应用程序,并解释一些 Spring WebSocket 的特性。

第一个任务是配置 Spring 以处理 WebSocket 上的 STOMP 消息。使用 Spring 4,您可以立即配置一个非常简单、轻量级(基于内存的)消息代理,设置订阅,并让控制器方法处理客户端消息。ChatroomWebSocketMessageBrokerConfigurer类的代码如下:

package com.springessentialsbook.chapter4;..
@Configuration
@EnableWebSocketMessageBroker
public class ChatroomWebSocketMessageBrokerConfigurer extends AbstractWebSocketMessageBrokerConfigurer {
   @Override
   public void configureMessageBroker(MessageBrokerRegistry config) {
      config.enableSimpleBroker("/chatroomTopic");
      config.setApplicationDestinationPrefixes("/myApp");
   }
   @Override
   public void registerStompEndpoints(StompEndpointRegistry registry) {
      registry.addEndpoint("/broadcastMyMessage").withSockJS();
   }
}

@Configuration标记ChatroomWebSocketMessageBrokerConfigurer类为 Spring 配置类。@EnableWebSocketMessageBroker提供由消息代理支持的 WebSocket 消息功能。

重写的configureMessageBroker方法,如其名称所示,重写了消息代理配置的父方法,并设置:

  • setApplicationDestinationPrefixes:指定/myApp作为前缀,任何目的地以/myApp开头的客户端消息将被路由到控制器的消息处理方法。

  • enableSimpleBroker:将经纪人主题设置为/chatroomTopic。任何目的地以/chatroomTopic开头的消息将被路由到消息代理(即广播到其他连接的客户端)。由于我们使用的是内存代理,我们可以指定任何主题。如果我们使用专用代理,目的地的名称将是/topic/queue,基于订阅模型(发布/订阅或点对点)。

重写的方法registerStompEndpoints用于设置端点和回退选项。让我们仔细看一下:

  • 客户端 WebSocket 可以连接到服务器的端点/broadcastMyMessage。由于选择了 STOMP 作为子协议,我们不需要了解底层消息格式,让 STOMP 处理它。

  • .withSockJS()方法启用了 Spring 的回退选项。这保证了在任何类型或版本的浏览器中成功的 WebSocket 通信。

由于 Spring MVC 将 HTTP 请求转发到控制器中的方法,MVC 扩展可以通过 WebSocket 接收 STOMP 消息并将其转发到控制器方法。Spring Controller类可以接收目的地以/myApp开头的客户端 STOMP 消息。处理程序方法可以通过将返回的消息发送到经纪人通道来回复订阅的客户端,经纪人通过将消息发送到响应通道来回复客户端。在本节的最后,我们将看一些关于消息架构的更多信息。例如,让我们看一下ChatroomController类:

    package com.springessentialsbook.chapter4;
      ...
@Controller
public class ChatroomController {

    @MessageMapping("/broadcastMyMessage")
    @SendTo("/chatroomTopic/broadcastClientsMessages")
    public ReturnedDataModelBean broadCastClientMessage(ClientInfoBean message) throws Exception {
        String returnedMessage=message.getClientName() + ":"+message.getClientMessage();
        return new ReturnedDataModelBean(returnedMessage );
    }
}

这里,@Controller标记ChatroomController作为 MVC 工作流控制器。@MessageMapping用于告诉控制器将客户端消息映射到处理程序方法(broadCastClientMessage)。这将通过将消息端点与目的地(/broadcastMyMessage)进行匹配来完成。方法的返回对象(ReturnedDataModelBean)将通过@SendTo注解发送回经纪人到订阅者的主题(/chatroomTopic/broadcastClientsMessages)。主题中的任何消息都将广播给所有订阅者(客户端)。请注意,客户端不会等待响应,因为它们发送和监听来自主题而不是直接来自服务的消息。

我们的领域 POJOs(ClientInfoBeanReturnedDataModelBean),如下所述,将提供客户端和服务器之间的通信消息负载(实际消息内容):

package com.springessentialsbook.chapter4;
public class ClientInfoBean {
    private String clientName;
    private String clientMessage;
    public String getClientMessage() {
    return clientMessage;
  }
    public String getClientName() {
        return clientName;
    }
}

package com.springessentialsbook.chapter4;
public class ReturnedDataModelBean {

    private String returnedMessage;
    public ReturnedDataModelBean(String returnedMessage) {
        this.returnedMessage = returnedMessage; }
    public String getReturnedMessage() {
        return returnedMessage;
    }
}

为了增加一些安全性,我们可以添加基本的 HTTP 身份验证,如下所示(我们不打算在本章中解释 Spring 安全性,但将在下一章中详细介绍):

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic();
        http.authorizeRequests().anyRequest().authenticated();
    }
    @Autowired
    void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
        .withUser("user").password("password").roles("USER");
    }
}

@Configuration标记此类为配置类,@EnableGlobalMethodSecurity@EnableWebSecurity在类中设置安全方法和 Web 安全性。在configure方法中,我们设置基本身份验证,在configureGlobal中,我们设置了识别的用户名和密码以及用户所属的角色。

要添加 Spring Security 功能,我们应该添加以下 Maven 依赖项:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-messaging</artifactId>
    <version>4.0.1.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
</dependency>

正如我们在上一节中所解释的,@SpringBootApplication标签在不必编写一行 XML 配置(applicationContext.xmlweb.xml)的情况下在 Web 应用程序中设置了一个 Spring 应用程序:

package com.springessentialsbook.chapter4;
...
@SpringBootApplication
public class ChatroomBootApplication {
    public static void main(String[] args) {
        SpringApplication.run(ChatroomBootApplication.class, args);
    }
}

最后,您可以通过运行以下命令来运行应用程序:


mvn spring-boot:run -Dserver.port=8090

这在端口8090上的嵌入式 Web 服务器上运行和部署 Web 应用程序(8080未使用,因为它可能与正在运行的 Apache 服务冲突)。因此,应用程序的索引页面将可以在http://localhost:8090/上访问(按照read-me.txt运行应用程序):

    <script src="img/sockjs-0.3.4.js"></script>
    <script src="img/stomp.js"></script>
    <script type="text/javascript">
...
function joinChatroom() {
    var topic='/chatroomTopic/broadcastClientsMessages';
    var servicePath='/broadcastMyMessage';
    var socket = new SockJS(servicePath);
    stompClient = Stomp.over(socket);
    stompClient.connect('user','password', function(frame) {
        setIsJoined(true);
        console.log('Joined Chatroom: ' + frame);
        stompClient.subscribe(topic, function(serverReturnedData){
            renderServerReturnedData(JSON.parse(serverReturnedData.body).returnedMessage);
        });
    });
}
...
function sendMyClientMessage() {
    var serviceFullPath='/myApp/broadcastMyMessage';
    var myText = document.getElementById('myText').value;
    stompClient.send(serviceFullPath, {}, JSON.stringify({ 'clientName': 'Client-'+randomnumber, 'clientMessage':myText}));
    document.getElementById('myText').value='';
}

在客户端,注意浏览器如何连接(使用joinChatRoom)并发送数据(在sendMyClientMessage方法中)。这些方法使用 JavaScript 库 SockJS 和 Stomp.js。

如您所见,当客户端订阅主题时,它会注册一个监听方法(stompClient.subscribe(topic, function(serverReturnedData){.…})。当任何消息(来自任何客户端)到达主题时,将调用监听方法。

如前所述,某些浏览器版本不支持 WebSocket。SockJS 被引入以处理所有版本的浏览器。在客户端,当您尝试连接到服务器时,SockJS 客户端发送GET/info消息以从服务器获取一些信息。然后它选择传输协议,可以是 WebSocket、HTTP 流或 HTTP 长轮询之一。WebSocket 是首选的传输协议;但是,如果浏览器不兼容,它选择 HTTP 流,并且在更糟糕的情况下选择 HTTP 长轮询。

在本节的开头,我们描述了我们的 WebSocket 应用程序如何与 Spring 消息系统集成,并以类似于传统消息系统的方式工作。

@EnableWebSocketMessageBrokerChatroomWebSocketMessageBrokerConfigurer的重写方法设置创建了一个具体的消息流(参见下图)。在我们的消息架构中,通道解耦了接收者和发送者。消息架构包含三个通道:

  • 客户端入站通道(请求通道)用于从客户端发送的请求消息

  • 客户端出站通道(响应通道)用于发送到客户端的消息

  • 代理通道用于向代理发送内部服务器消息

我们的系统使用 STOMP 目的地进行简单的路由前缀。任何目的地以/myApp开头的客户端消息将被路由到控制器消息处理方法。任何以/chatroomTopic开头的消息将被路由到消息代理。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

简单代理(内存中)消息架构

以下是我们应用程序的消息流:

  1. 客户端连接到 WebSocket 端点(/broadcastMyMessage)。

  2. 客户端消息到/myApp/broadcastMyMessage将通过请求通道转发到ChatroomController类。映射控制器的方法将返回的值传递给主题/chatroomTopic/broadcastClientsMessages的代理通道。

  3. 代理将消息传递到响应通道,即主题/chatroomTopic/broadcastClientsMessages,订阅了该主题的客户端将收到消息。

在 WebSocket 应用程序中向单个用户广播消息

在前一节中,我们看到了多个订阅者模型的 WebSocket 应用程序,其中代理向主题发送消息。由于所有客户端都订阅了相同的主题,因此它们都收到了消息。现在,您被要求开发一个针对 WebSocket 聊天应用程序中特定用户的应用程序。

假设您想开发一个自动回答应用程序,其中用户向系统发送问题并自动获得答案。该应用程序与上一个应用程序几乎相同(Spring 4 中的 STOMP over WebSocket 和回退选项),只是我们应该更改服务器端的 WebSocket 配置器和端点以及客户端端的订阅。AutoAnsweringWebSocketMessageBrokerConfigurer类的代码如下:

@Configuration
@EnableWebSocketMessageBroker
public class AutoAnsweringWebSocketMessageBrokerConfigurer extends AbstractWebSocketMessageBrokerConfigurer {
   @Override
   public void configureMessageBroker(MessageBrokerRegistry config) {
      config.setApplicationDestinationPrefixes("/app");
      config.enableSimpleBroker("/queue");
      config.setUserDestinationPrefix("/user");
   }
   @Override
   public void registerStompEndpoints(StompEndpointRegistry registry) {
      registry.addEndpoint("/message").withSockJS();
   }
}

config.setUserDestinationPrefix("/user")方法设置了一个前缀,指示用户已订阅并期望在主题上收到自己的消息。AutoAnsweringController类的代码如下:

@Controller
public class AutoAnsweringController {
    @Autowired
    AutoAnsweringService autoAnsweringService;
    @MessageMapping("/message")
    @SendToUser
    public String sendMessage(ClientInfoBean message) {
        return autoAnsweringService.answer(message);
    }
    @MessageExceptionHandler
    @SendToUser(value = "/queue/errors", broadcast = false)
    String handleException(Exception e) {
        return "caught ${e.message}";
    }
}

@Service
public class AutoAnsweringServiceImpl implements AutoAnsweringService {
    @Override
    public String answer(ClientInfoBean bean) {
        StringBuilder mockBuffer=new StringBuilder();
        mockBuffer.append(bean.getClientName())
                .append(", we have received the message:")
                .append(bean.getClientMessage());
        return mockBuffer.toString();
    }
}

在端点中,我们使用@SendToUser而不是@SendTo("...")。这只将响应转发给消息发送者。@MessageExceptionHandler还将错误发送给消息发送者(broadcast = false)。

AutoAnsweringService只是一个模拟服务,用于返回给客户端的答案。在客户端,当用户订阅主题(/user/queue/message)时,我们只添加了/user前缀:

function connectService() {
    var servicePath='/message';
    var socket = new SockJS(servicePath);
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function(frame) {

        setIsJoined(true);
        stompClient.subscribe('/user/queue/message', function(message) {
            renderServerReturnedData(message.body);
        });
        stompClient.subscribe('/user/queue/error', function(message) {
            renderReturnedError(message.body);
        });
    });
}
function sendMyClientMessage() {
    var serviceFullPath='/app/message';
    var myText = document.getElementById('myText').value;
    stompClient.send(serviceFullPath, {}, JSON.stringify({ 'clientName': 'Client-'+randomnumber, 'clientMessage':myText}));
    document.getElementById('myText').value='';
}

主题user/queue/error用于接收从服务器端分发的错误。

注意

有关 Spring 的 WebSocket 支持的更多信息,请访问docs.spring.io/spring-framework/docs/current/spring-framework-reference/html/websocket.html

有关 WebSocket 通信的更多信息,请参阅第八章用 WebSocket 替代 HTTP,来自书籍企业 Web 开发Yakov Fain,Victor Rasputnis,Anatole Tartakovsky,Viktor GamovO’Reilly

摘要

在本章中,我们解释了基于 WebSocket 的通信,Spring 4 如何升级以支持 WebSocket,以及克服浏览器 WebSocket 不兼容性的备用选项。我们还介绍了添加基本 HTTP 身份验证的小示例,这是 Spring Security 的一部分。我们将在第五章保护您的应用程序中更多地讨论安全性。

第五章:保护您的应用程序

Spring Security 为保护基于 Java/Spring 的企业应用程序提供了广泛的功能。乍一看,Servlets 或 EJB 的安全功能看起来是 Spring Security 的替代品;然而,这些解决方案缺乏开发企业应用程序的某些要求。服务器环境的依赖性可能是这些解决方案的另一个缺点。

身份验证和授权是应用程序安全的主要领域。身份验证是对用户身份的验证,而授权是对用户权限的验证。

Spring Security 与各种身份验证模型集成,其中大多数由第三方提供者提供。此外,Spring Security 还开发了基于主要安全协议的自己的身份验证模型。以下是其中一些协议:

  • 基于表单的身份验证

  • HTTP 基本身份验证

  • LDAP

  • JAAS

  • Java 开放式单点登录

  • Open ID 认证

由于 Spring Security 模型的列表很长,我们只能在本章中详细介绍其中最受欢迎的模型。

Spring Security 在授权功能上非常强大。我们可以将这些功能分为三组:web、方法和域对象授权。稍后在授权部分,我们将解释这些类别。

为了在 Web 应用程序中使用 Spring Security 功能,您需要在项目中包含以下依赖项:

<dependency>
   <groupId>org.springframework.security</groupId>
   <artifactId>spring-security-web</artifactId>
   <version>4.0.2.RELEASE</version>
</dependency>
<dependency>
   <groupId>org.springframework.security</groupId>
   <artifactId>spring-security-config</artifactId>
   <version>4.0.2.RELEASE</version>
</dependency>

授权的开放标准OAuth)概念于 2006 年末引入,旨在允许第三方在 Microsoft、Google、Facebook、Twitter 或类似账户上获得用户资源的有限访问权限,而无需共享他们的用户名和密码。

在 2010 年,OAuth 作为 OAuth 1.0a 协议在 RFC 5849 中标准化。后来在 2012 年,它演变为 RFC 6749 中的 OAuth 2.0 框架。在本章中,我们解释了 Spring 的 OAuth 2.0 框架实现。

OAuth 2.0 授权框架使第三方应用程序能够在资源所有者与 HTTP 服务之间进行批准交互,或者允许第三方应用程序代表自己获得访问权限(tools.ietf.org/html/rfc6749)。

Spring 为其 OAuth 2.0 实现提供了一个单独的模块(spring-security-oauth2),依赖于 Spring Security 的功能。在本章中,我们解释了身份验证以及 Spring 如何通过提供自己易于使用的功能以及提供插入自定义实现的选项来简化该过程。授权是本章的第二个主题,我们将解释如何在同一应用程序中配置单独的安全模型。在最后一节中,我们解释了 Spring 的 OAuth 2.0 功能。

身份验证

在应用程序的安全领域中,首先想到的是身份验证。在身份验证过程中,应用程序将用户的凭据(例如用户名和密码或令牌)与其可用的信息进行比较。如果这两者匹配,它允许进入下一步。我们将在授权部分中跟进下一步。

Spring Security 提供了支持各种安全认证协议的功能。在本节中,我们将重点放在基础和基于表单的身份验证上。

Spring 提供了一个内置的表单,用于基于表单的身份验证。此外,它还允许您定义自定义的登录表单。

Spring 为您提供了使用内存中的身份验证的选项,其中用户名和密码将在应用程序中硬编码。

另一种选择是使用自定义的身份验证提供程序,让您决定如何通过程序对用户进行身份验证,例如,调用数据层服务来验证用户。它还允许您将 Spring Security 与现有的安全框架集成。

要配置 Spring Security 对用户进行身份验证,首先需要定义一个名为springSecurityFilterChain的 Servlet 过滤器。此过滤器负责在 Web 应用程序中应用安全措施(例如,验证用户、根据用户角色在登录后导航到不同页面以及保护应用程序 URL)。

WebSecurityConfigurerAdapter 是一个方便的 Spring 模板,用于配置 springSecurityFilterChain

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.springessentialsbook.chapter5")
public class WebSecurityConfigurator extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;

    @Autowired
    public void configureGlobalSecurity(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication().withUser("operator").password("password").roles("USER");
        auth.inMemoryAuthentication().withUser("admin").password("password").roles("ADMIN");
        auth.inMemoryAuthentication().withUser("accountant").password("password").roles("ACCOUNTANT");

    }

@Configuration将此类注册为配置类。方法的名称configureGlobalSecurity并不重要,因为它只通过自动装配配置了AuthenticationManagerBuilder实例。唯一重要的是用@EnableWebSecurity注释类,这将在应用程序中注册 Spring Web 安全性。正如您所看到的,为了简单起见,我们使用了内存中的身份验证,这将用户的用户名、密码和用于用户身份验证的角色硬编码。在真实的企业应用程序中,LDAP、数据库或云提供服务来验证用户凭据。

我们在配置类中并没有编写太多代码,但它确实在幕后做了很多工作。以下是该类实现的一些功能。除了用户身份验证和角色分配之外,我们将在本章的下一部分中解释其他功能。

  • 首先要求对所有应用程序 URL 进行身份验证

  • 创建一个 Spring 默认登录表单来验证用户

  • 使用基于表单的身份验证对用户进行身份验证(operator/password,admin/password,accountant/password)并为每个用户分配单独的角色(用户、管理员和会计)

  • 允许用户注销

  • CSRF 攻击预防

正如我们所解释的,在现实世界的企业应用程序中,从不在应用程序代码中硬编码用户凭据。您可能有一个现有的安全框架,调用服务以验证用户。在这种情况下,您可以配置 Spring Security 在自定义服务中对用户进行身份验证。

身份验证接口实现是在 Spring Security 上下文中携带用户凭据的内容。您可以在应用程序的任何地方使用SecurityContextHolder.getContext().getAuthentication()获取身份验证对象。

当用户经过身份验证时,Authentication将被填充。如果您没有指定AuthenticationProvider(例如,如果您使用内存中的身份验证),Authentication将自动填充。在这里,我们将看看如何自定义AuthenticationProvider并填充Authentication对象。

以下代码显示了 Spring 的AuthenticationProvider实现类如何与自定义用户详细信息服务集成(该服务从数据源返回用户凭据):

@Component
public class MyCustomizedAuthenticationProvider implements AuthenticationProvider {
  @Autowired
  UserDetailsService userService;
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    User user=null;
    Authentication auth=null;
    String username=authentication.getName();
    String password=authentication.getCredentials().toString();
    user= (User) userService.loadUserByUsername(username);
    if(password ==null ||   ! password.equals(user.getPassword())) throw new UsernameNotFoundException("wrong user/password");
    if(user !=null){
      auth = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), user.getAuthorities());
    } else throw new UsernameNotFoundException("wrong user/password");
    return auth;

  }
  public boolean supports(Class<?> aClass) {
    return true;
  }
}

您的自定义身份验证提供程序应该实现AuthenticationProvider及其authenticate方法。

请注意,这里的userService实例应该实现 Spring 的UserDetailsService接口及其loadUserByUserName方法。该方法返回用户的数据模型。请注意,您可以扩展 Spring 的User对象并创建自定义的用户。我们使用数据服务模拟了UserService的集成部分。在实际应用中,可能会有一个服务调用来获取并返回用户数据,而您的UserServiceImpl类只会将用户包装在UserDetails数据模型中,如下所示:

@Service
public class UserServiceImpl implements UserDetailsService {
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        // suppose we fetch user data from DB and populate it into // User object
        // here we just mock the service
        String role=null;
        if(userName.equalsIgnoreCase("admin")){
            role ="ROLE_ADMIN";
        }else if(userName.equalsIgnoreCase("accountant") ){
            role="ROLE_ACCOUNTANT";
        }else if(userName.equalsIgnoreCase("operator")){
            role="ROLE_USER";
        }else{
            throw new UsernameNotFoundException("user not found in DB");
        }
        List<GrantedAuthority> authorities=new ArrayList<GrantedAuthority>();
        authorities.add(new GrantedAuthorityImpl(role));
        return new User(userName, "password", true, true, true, true, authorities);
    }
}

之后,您可以在配置类中设置您的定制提供程序,如下面的代码所示。当用户经过身份验证时,认证对象应该以编程方式填充。在本章的授权部分中,我们将解释这个对象。

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
@ComponentScan(basePackages = "com.springessentialsbook.chapter5")
public class MultiWebSecurityConfigurator   {

    @Autowired
    private AuthenticationProvider authenticationProvider;

    @Autowired
    public void configureGlobalSecurity(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider);
    }

我们在第一步中定义了springSecurityFilterChain过滤器。为了使其工作,我们需要在 Web 应用程序中注册它,如下所示:

import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer;
public class SecurityWebApplicationInitializer extends AbstractSecurityWebApplicationInitializer { }

该类不需要任何代码,因为超类(AbstractSecurityWebApplicationInitializer)注册了 Spring Security 过滤器。这发生在 Spring 上下文启动时。

如果我们不使用 Spring MVC,我们应该将以下内容传递给构造函数:

super(WebSecurityConfigurator);

AnnotatedConfigDispatcherServletInitializer扩展了 Spring 的 Servlet 初始化程序AbstractAnnotationConfigDispatcherServletInitializer。这个类允许 Servlet 3 容器(例如 Tomcat)自动检测 Web 应用程序,而无需web.xml。这是简化 Web 应用程序设置的另一步,它以编程方式注册DispatcherServlet和 Servlet 映射。通过在getRootConfigClasses中设置WebSecurityConfigurator类,您告诉父类方法创建应用程序上下文使用您的注释和定制的 Spring Security 配置类。以下是AnnotatedConfigDispatcherServletInitializer类的代码:

public class AnnotatedConfigDispatcherServletInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
   @Override
   protected Class<?>[] getRootConfigClasses() {
      return new Class[] { MultiWebSecurityConfigurator.class };
   }

   @Override
   protected Class<?>[] getServletConfigClasses() {
      return null;
   }

   @Override
   protected String[] getServletMappings() {
      return new String[] { "/" };
   }
}

到目前为止在 Spring Security 中我们配置的是检查用户名和密码是否正确。如果我们想要配置其他安全功能,比如定义登录页面和需要进行身份验证的 Web 应用程序 URL 请求,我们需要覆盖WebSecurityConfigurerAdapterconfigure(HttpSecurity http)方法。

在我们定制的安全配置器中,我们定义了一个登录页面(login.jsp)和一个授权失败页面(nonAuthorized.jsp),如下所示:

@Configuration
@EnableWebSecurity
public class WebSecurityConfigurator extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;

   ...
   @Override
   public void configure(HttpSecurity http) throws Exception {
   ...

   .and().formLogin()
   .loginPage("/login").successHandler(authenticationSuccessHandler)
   .failureUrl("/nonAuthorized")
   .usernameParameter("username").passwordParameter("password").loginProcessingUrl("/login")

...

这段代码告诉 Spring 处理提交的 HTTP 请求表单(使用 POST 方法)并使用预期的用户名和密码作为参数以及"/login"作为操作。以下是登录表单:

<form role="form" action="/login" method="post">
  <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
  <div>
    <label for="username">Username</label>
    <input type="text" name="username" id="username" required autofocus>
  </div>
  <div>
    <label for="password">Password</label>
    <input type="password" name="password" id="password" required>
  </div>
  <button type="submit">Sign in</button>
</form>

提示

如果您在配置文件中没有指定用户名、密码和loginProcessingUrl参数,Spring Security 会期望客户端浏览器提供j_usernamej_passwordj_spring_security_check。通过覆盖 Spring 的默认值,您可以隐藏 Spring Security 的实现。

跨站请求伪造CSRF)攻击发生在例如,当经过身份验证的 Web 客户端点击了一个恶意链接并执行了不需要的操作,比如转账、获取联系人电子邮件或更改密码时。Spring Security 提供了随机生成的 CSRF 来保护客户端免受 CSRF 攻击。

如果在configure方法中省略了.loginPage,Spring 将使用其默认登录页面,这是一个非常简单的 HTML 登录页面。在这种情况下,Spring Security 使用预期的j_usernamej_passwordj_spring_security_check参数作为用户名、密码和操作,您不应该在方法中对其进行配置。

例如,在这里我们要求 Spring 提供其自己的默认登录表单:

@Override
public void configure(HttpSecurity http) throws Exception {
   ...
         .and().formLogin()
         .successHandler(authenticationSuccessHandler)
         .failureUrl("/nonAuthorized")
         ...

}

Spring Security 支持 HTTP 基本身份验证,其中客户端浏览器在您想要访问与模式匹配的资源时(在本例中为"/adResources*/**")会打开一个弹出窗口(初始时):

protected void configure(HttpSecurity http) throws Exception {
    http.antMatcher("/adResources*/**").authorizeRequests().anyRequest().hasRole("ADMIN")
        .and()
        .httpBasic();
}

身份验证后,服务器端导航可能是下一步。尽管现代客户端框架(如 AngularJS)从客户端提供路由信息,但您可能仍希望将路由逻辑保留在服务器端。成功处理程序是 Spring Security 的一个功能,它允许您在 Web 应用程序中定义身份验证后的导航逻辑。

Spring Security 允许您在身份验证后配置自定义的服务器端导航。您可以在configure方法中配置它(使用successHandler):

@Override
public void configure(HttpSecurity http) throws Exception {
    ...
    .loginPage("/login").successHandler(authenticationSuccessHandler)
      ....
}

您的自定义导航处理程序应实现AuthenticationSuccessHandler接口。OnAuthenticationSuccess是用户经过身份验证时将调用的方法。在此方法中,我们应该定义目标 URL。在此处显示的示例实现类中,用户的角色仅用于定义目标 URL:

@Component
public class MyCustomizedAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse 
    response, final Authentication authentication) throws IOException {
        handle(request, response, authentication);
        final HttpSession session = request.getSession(false);
        if (session != null) {
            session.setMaxInactiveInterval(3600);//1 hour
        }
        clearAttributes(request);
    }

    protected void handle(final HttpServletRequest request, final HttpServletResponse response, final 
    Authentication authentication) throws IOException {
        final String url = getUrl(authentication);
        if (response.isCommitted()) {
           return;
        }
        redirectStrategy.sendRedirect(request, response, url);
    }

    private String getUrl(final Authentication authentication) {
        String url=null;
        final Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (final GrantedAuthority grantedAuthority : authorities) {
            if (grantedAuthority.getAuthority().equals("ROLE_USER")) {
                url= "/user" ;
                break;
            } else if (grantedAuthority.getAuthority().equals("ROLE_ADMIN")) {
                url= "/admin" ;
                break;
             } else if (grantedAuthority.getAuthority().equals("ROLE_ACCOUNTANT")) {
                url= "/accountant" ;
                break;
            }else {
                throw new IllegalStateException();
            }
        }
        return url;
    }

    protected void clearAttributes(final HttpServletRequest request) {
        final HttpSession session = request.getSession(false);
        if (session == null) {
            return;
        }
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }

    public void setRedirectStrategy(final RedirectStrategy redirectStrategy) {
        this.redirectStrategy = redirectStrategy;
    }

    protected RedirectStrategy getRedirectStrategy() {
        return redirectStrategy;
    }
}

Spring Security 允许您在多种方法中配置安全配置,并且在每种方法中,您可以定义不同类别的资源。在这里,我们将基于表单和基本身份验证的安全配置分开到这两个类中:

@EnableWebSecurity
@ComponentScan(basePackages = "com.springessentialsbook.chapter5")
public class MultiWebSecurityConfigurator {
   @Autowired
   private AuthenticationProvider authenticationProvider;
   @Autowired
   public void configureGlobalSecurity(AuthenticationManagerBuilder auth) throws Exception {

      auth.authenticationProvider(authenticationProvider);
   }
   @Configuration
   protected static class LoginFormBasedWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
      @Autowired
      private AuthenticationSuccessHandler authenticationSuccessHandler;
      @Override
      public void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests()
               ...
               .permitAll();
      }
   }
   @Configuration
   @Order(1)
   public static class HttpBasicWebSecurityConfigurationAdapter extends WebSecurityConfigurerAdapter {
      @Override
      protected void configure(HttpSecurity http) throws Exception {
         http.antMatcher("/adResources*/**").authorizeRequests().anyRequest().hasRole("ADMIN")
         .and()
         .httpBasic();
      }

}}

例如,在一种方法中,我们配置adResources路径中的资源,以便管理员角色在基于 HTTP 的身份验证中查看(浏览器打开弹出窗口并要求输入用户名和密码)。在第二种方法中,我们应用表单登录授权,并根据用户角色限制对资源的访问。

授权

身份验证部分,我们展示了用户提供的凭据(用户名/密码)如何与应用程序存储的凭据进行比较,如果匹配,则用户被验证。

为了增强安全性,我们可以限制用户对应用程序资源的访问。这就是授权的问题——谁应该访问哪个应用程序的资源。

Spring Security 提供了非常全面的授权功能。我们可以将这些功能分类为以下三个授权组:

  • Web 请求(谁可以访问哪个应用程序 URL?)

  • 方法调用(谁可以调用方法?)

  • 域对象访问(谁可以看到哪些数据?)

例如,客户应该能够查看自己的订单和个人资料,而管理员应该能够查看所有客户的订单以及任何客户无法看到的数据。

自 Spring Security 3.0 版本以来,Spring 已将 Spring EL 表达式添加到其授权功能中。Spring EL 允许您将复杂的授权逻辑转换为简单的表达式。在本节中,我们使用 Spring EL 进行授权。

Spring Security 中的GrandAuthority是一个包含字符串值的对象,该字符串值可以互换地称为权限、权利或许可(请参阅身份验证部分,了解如何创建GrandAuthority,其中解释了AuthenticationProvider接口,以查看GrandAuthority是如何创建的)。默认情况下,如果此字符串值以前缀ROLE_开头(例如,ROLE_ADMIN),它将被视为用户的角色。因此,如果不以前缀开头,它也足够灵活,可以用作权限。Spring Security 使用此对象进行 Web、方法和域对象授权。

对于 Web 请求授权,我们可以根据 Spring Security 中用户的角色限制用户访问,如下所示(我们将在本节后面看到如何在控制器中执行此操作):

public void configure(HttpSecurity http) throws Exception {
   http.authorizeRequests()
      .antMatchers("*.jsp").denyAll()
      .antMatchers("/", "/login").permitAll()
      .antMatchers("/user*//**").access("hasRole('USER') or hasRole('ADMIN')")
      .antMatchers("/admin*//**").access("hasRole('ADMIN')")
      .antMatchers("/accountant*//**").access("hasRole('ADMIN') or hasRole('ACCOUNTANT')")
      .failureUrl("/nonAuthorized")
      ...
      .permitAll();
}

由于我们使用 spring MVC,我们拒绝所有以.jsp结尾的 URL(*.jsp),并让 MVC 将 URL 映射到 JSP 页面。我们允许任何人访问登录页面(.antMatchers("/", /login").permitAll())。

我们将用户对会计资源的访问限制为管理员和会计角色(例如,antMatchers("/accountant*//**").access("hasRole('ADMIN') or hasRole('ACCOUNTANT')"))。如果用户在身份验证失败或尝试访问未经授权的资源时,我们设置了一个错误 URL 并将用户转发到该 URL,使用failureUrl("/nonAuthorized")

您需要添加@EnableGlobalMethodSecurity(prePostEnabled=true)来应用方法/域级别的授权:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
@ComponentScan(basePackages = "com.springessentialsbook.chapter5")
public class MultiWebSecurityConfigurator {

我们已经描述了如何使用配置文件限制对 URL 的访问。您也可以在控制器的方法中做同样的事情:

@PreAuthorize("hasRole('ADMIN') or hasRole('ACCOUNTANT')"
@RequestMapping(value = "/accountant", method = RequestMethod.GET)
public String dbaPage(ModelMap model) {
...
}

对于方法调用授权,您可以在方法级别配置 Spring Security,并定义谁可以在应用程序的服务层运行特定方法:

@PreAuthorize("hasRole('ADMIN') or hasRole('ACCOUNTANT')"
)
public void migrateUsers(id){...};

对于域对象访问,您可以应用方法调用授权,并有一个服务方法来微调谁可以在应用程序中看到哪些数据。例如,在服务层,如果用户名参数等于已登录的用户名或用户具有管理员角色,您可以限制访问(参考代码中的bussinessServiceImpl):

@PreAuthorize("@businessServiceImpl.isEligibleToSeeUserData(principal, #username)")
@RequestMapping("/userdata/{username}")
public String getUserPage(@PathVariable String username,ModelMap model) {
  {...}

OAuth2 授权框架

OAuth2 授权框架只是让第三方应用程序访问您的受保护资源而不共享用户凭据(用户名/密码)的一种方式。当网站(例如 LinkedIn)要求您分享电子邮件联系人时,您会面临这种情况,当您同意时,您将被转到您的邮件提供商的登录页面(例如,雅虎)。

当您登录时,邮件提供商会请求您的许可,以便与 LinkedIn 分享您的联系人。然后,LinkedIn 可以获取您的联系人列表以便发送邀请。

OAuth2 依赖于以下实体:

  • 资源所有者:这是拥有受保护资源的用户,例如,雅虎电子邮件用户

  • 客户端或第三方应用程序:这是需要访问所有者受保护资源的外部应用程序,例如,LinkedIn

  • 授权服务器:这个服务器在验证资源所有者并获得授权后,授予客户端/第三方访问权限

  • 资源服务器:这个服务器托管所有者的受保护资源,例如,雅虎服务器

许多领先的提供商(例如,谷歌和脸书)都有授权和资源服务器。

这个图表说明了 OAuth2 框架是如何工作的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Spring 通过重用 Spring Security 的身份验证和授权概念,并包含新功能来实现授权和资源服务器,从而促进了 OAuth2 框架。要在项目中使用 Spring OAuth2,您需要以下依赖:

<dependency>
   <groupId>org.springframework.security.oauth</groupId>
   <artifactId>spring-security-oauth2</artifactId>
   <version>2.0.8.RELEASE</version>
</dependency>

身份验证部分我们解释了关于验证用户和保护资源的内容,这里仍然保持不变。新的内容是授权和资源服务器设置。

OAuth 2.0 服务包括授权和资源服务器。Spring Security 允许您将授权服务器和资源服务器作为独立的应用程序,其中一个授权服务器可以被一个或多个资源服务器共享,或者在单个应用程序中同时拥有这两种类型的服务器。为了简化起见,我们在同一个应用程序中实现了授权和资源服务器。

MultiOAuth2ResourceAndAuthorizationConfigurator类中,我们定义了资源和授权服务器。@EnableResourceServer标记ResourceServerConfiguration类为资源服务器,它定义了 URL /public的资源为非受保护资源,/protected/**的资源为需要有效令牌才能访问的安全资源。

@EnableAuthorizationServer标记AuthorizationServerConfiguration为授权服务器,向第三方客户端授予令牌。TokenStore是一个 Spring 接口;它的实现类(InMemoryTokenStoreJdbcTokenStoreJwtTokenStore)跟踪令牌。

JdbcTokenStore使用数据库存储令牌,并且有一个 Spring-JDBC 依赖。当您想要有令牌的历史记录、服务器故障后的恢复或者在多个服务器之间共享令牌时,JdbcTokenStore是合适的。

JwtTokenStore将与令牌相关的数据编码到令牌本身中。JwtTokenStore不会使令牌持久化,并且需要JwtAccessTokenConverter作为 JWT 编码令牌和 OAuth 认证信息之间的翻译器。

为简单起见,我们使用InMemoryTokenStore实现类,但在实际应用中,使用JdbcTokenStore/JwtTokenStore是更好的做法。

我们重用了在身份验证部分详细介绍的AuthenticationManager类。

configure(ClientDetailsServiceConfigurer clients)方法是我们配置令牌生成设置的位置,如下所示:

  • withClient告诉我们哪个客户端可以访问资源(这与用户身份验证分开)

  • secret是客户端的密码

  • authorities告诉我们哪些用户角色有资格访问资源

  • authorizedGrantType指定客户端具有哪种授权类型(例如,刷新和访问令牌)

  • accessTokenValiditySeconds设置令牌的生存时间

设置在以下代码中提到:

@Configuration
public class MultiOAuth2ResourceAndAuthorizationConfigurator {
    @Configuration
    @EnableResourceServer
    protected static class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
        @Override
        public void configure(HttpSecurity http) throws Exception {
            http
                .headers()
                .frameOptions().disable()
                .authorizeRequests()
                .antMatchers("/public/").permitAll()
                .antMatchers("/protected/**").authenticated();
        }
    }
    @Configuration
    @EnableAuthorizationServer
    protected static class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter implements EnvironmentAware {
        private static final String  CLIENT_ID = "myClientId";
        private static final String  CLIENT_PASSWORD = "myClientPassword";
        private static final int  TOKEN_TIME_TO_LIVE = 1800;
        private static final String  ROLE_USER = "ROLE_USER";
        private static final String  ROLE_ACCOUNTANT = "ROLE_ACCOUNTANT";
        private static final String  READ_ACCESS = "read";
        private static final String  WRITE_ACCESS = "write";
        private static final String  GRANT_TYPE_PASSWORD = "password";
        private static final String  GRANT_TYPE_REFRESH_TOKEN = "refresh_token";
        @Bean
        public TokenStore tokenStore() {
            return new InMemoryTokenStore();
        }
        @Autowired
        private AuthenticationManager authenticationManager;
        @Override
        public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
            endpoints
                .tokenStore(tokenStore())
                .authenticationManager(authenticationManager);
        }
        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            clients
                .inMemory()
                .withClient(CLIENT_ID)
                .secret(CLIENT_PASSWORD)
                .scopes(READ_ACCESS, WRITE_ACCESS)
                .authorities(ROLE_USER, ROLE_ACCOUNTANT)
                .authorizedGrantTypes(GRANT_TYPE_PASSWORD, GRANT_TYPE_REFRESH_TOKEN)
                .accessTokenValiditySeconds( TOKEN_TIME_TO_LIVE);
        }
        @Override
        public void setEnvironment(Environment environment) {
        }
    }
}

我们授予访问令牌的资源包含在一个控制器中。在这里,我们定义了一个非常简单的资源:

@RequestMapping(value = "/protected", method = RequestMethod.GET)
@ResponseBody
public String getProtectedResources(ModelMap model) {
   return "this is from getProtectedResources";
}
@RequestMapping(value = "/public", method = RequestMethod.GET)
@ResponseBody
public String getPublicResources(ModelMap model) {
   return  "this is from getPublicResources";
}

您可以使用以下命令运行项目,该命令构建并运行资源和授权服务器:


mvn clean package spring-boot:run -Dserver.contextPath=/myapp -Dserver.port=9090

如果您尝试以下操作,您可以看到资源,因为此 URL 未受保护:


curl -i http://localhost:9090/myapp/public

然而,如果您尝试下一个命令,您会收到一个“未经授权”的错误,并且您需要一个有效的令牌才能访问此资源:


curl -i http://localhost:9090/myapp/protected

您需要首先获取令牌才能访问受保护的资源。Spring MVC 默认情况下在/oauth/token URL 中公开一个名为TokenEndpoint的端点,以获取令牌。以下命令会给您一个授权令牌:


curl -X POST -vu myClientId:myClientPassword  'http://localhost:9090/myapp/oauth/token?username=operator&password=password&grant_type=password'

现在,您可以提供令牌并访问安全资源:


curl -i -H "Authorization: Bearer [access_token]" http://localhost:9090/myapp/protected

请注意,我们为令牌设置了生存时间,并且如果令牌过期,我们需要刷新令牌。以下命令通过调用/oauth/token端点并将refresh_token作为grant_type参数传递来更新令牌:


curl  -X POST  -vu  myClientId:myClientPassword  'http://localhost:9090/myapp/oauth/token?grant_type=refresh_token&refresh_token=[refresh_token]'

摘要

在本章中,我们详细介绍了 Spring Security 的一些特性。由于 Spring Security 是一个独立的模块,并且具有各种功能,为了获取有关整个规范的更多信息,您需要阅读docs.spring.io/spring-security/site/docs/current/reference/html/index.htmlprojects.spring.io/spring-security-oauth/

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值