Spring-In-Action-5 第一部分-Spring单体应用

62 阅读7分钟

Spring风景线

  • Spring核心框架:Spring MVC,数据持久化支持,反应式编程等
  • Spring Boot:starter,Actuator,环境属性规范等
  • Spring Data:数据映射、多数据源支持
  • Spring Security:身份验证、授权和API安全性等
  • Spring Integration 和 Spring Batch:实时集成和批处理
  • Spring Cloud:微服务

chap2 开发Web应用

Spring 项目结构

  • /src/main/java 项目源码
  • /src/main/resources 非Java的资源
    • static 静态内容
    • templates 渲染内容的模版,如 Thymeleaf
  • /src/test/java 测试代码

测试Web应用

  • @RunWith(SpringRunner.class) 是JUnit提供的注解。提供测试运行器知道JUnit运行测试。SpringRunner是SpringJUnit4ClassRunner的别名
  • SpringMVC 是 Spring 自带的Web框架。使用 @WebMvcTest(HomeController.class) 可以测试Web应用
@RunWith(SpringRunner.class)
@WebMvcTest(HomeController.class)   // 让测试在Spring MVC的上下文中执行
public class HomeControllerTest {

  @Autowired
  private MockMvc mockMvc;

  @Test
  public void testHomePage() throws Exception {
    mockMvc.perform(get("/"))    // 请求路径
    
      .andExpect(status().isOk())  // 状态码断言
      
      .andExpect(view().name("home"))  // 视图断言
      
      .andExpect(content().string(           // 视图内容断言
          containsString("Welcome to...")));  
  }

}

校验表单输入

Spring Boot 中包含了 Hibernate 作为 Validation API 的实现,可以支持Bean校验

/**
使用方式:
1. 被校验的类中声明规则
2. 在controller中声明需要进行校验 @Valid
3. 展示校验错误
*/

@Data
public class Taco {
  @NotNull
  @Size(min=5, message="Name must be at least 5 characters long")
  private String name;

  @Size(min=1, message="You must choose at least 1 ingredient")
  private List<String> ingredients;
}

@Slf4j
@Controller
@RequestMapping("/design")
public class DesignTacoController {
  @PostMapping
  public String processDesign(@Valid Taco design, Errors errors, Model model) {
    if (errors.hasErrors()) {
      return "design";
    }
    log.info("Processing design: " + design);
    return "redirect:/orders/current";
  }
}

chap3 数据持久化

JdbcTemplate

  • 传统的JDBC使用方式
public class RawJdbcIngredientRepository implements IngredientRepository {

  private DataSource dataSource;

  public RawJdbcIngredientRepository(DataSource dataSource) {
    this.dataSource = dataSource;
  }
  
  @Override
  public Iterable<Ingredient> findAll() {
    List<Ingredient> ingredients = new ArrayList<>();
    Connection connection = null;
    PreparedStatement statement = null;
    ResultSet resultSet = null;
    try {
      connection = dataSource.getConnection(); // 1-获取connection
      statement = connection.prepareStatement( // 2-构建SQL语句
          "select id, name, type from Ingredient");
      resultSet = statement.executeQuery(); // 3-执行查询
      while(resultSet.next()) { // 4-解析结果
        Ingredient ingredient = new Ingredient(
            resultSet.getString("id"),
            resultSet.getString("name"),
            Ingredient.Type.valueOf(resultSet.getString("type")));
        ingredients.add(ingredient);
      }      
    } catch (SQLException e) {
      // SQLException是一个检查型异常,必须catch处理
      // 可能出现两类错误:数据库连接失败或SQL语句异常
    } finally {
      if (resultSet != null) {
        try {
          resultSet.close();
        } catch (SQLException e) {}
      }
      if (statement != null) {
        try {
          statement.close();
        } catch (SQLException e) {}
      }
      if (connection != null) {
        try {
          connection.close();
        } catch (SQLException e) {}
      }
    }
    return ingredients;
  }
}
  • 使用JdbcTemplate
@Repository
public class JdbcIngredientRepository implements IngredientRepository {

  private JdbcTemplate jdbc;

  @Autowired
  public JdbcIngredientRepository(JdbcTemplate jdbc) {
    this.jdbc = jdbc;
  }

	/**
		query() 方法入参:1-sql,2-行数据映射类Spring RowMapper的实现
	*/
  @Override
  public Iterable<Ingredient> findAll() {
    return jdbc.query("select id, name, type from Ingredient",
        this::mapRowToIngredient);
  }
  
  private Ingredient mapRowToIngredient(ResultSet rs, int rowNum)
      throws SQLException {
    return new Ingredient(
        rs.getString("id"), 
        rs.getString("name"),
        Ingredient.Type.valueOf(rs.getString("type")));
  }
}
  • 应用启动时会自动读取并执行 /src/main/resources 下的sql文件
    • schema.sql 一般定影数据表结构
    • data.sql 用来插入数据
./src/main/resources
├── application.yml
├── data.sql
├── schema.sql
├── static
│   ├── images
│   │   └── TacoCloud.png
│   └── styles.css
└── templates
    ├── design.html
    ├── home.html
    └── orderForm.html
  • 插入数据:update() 方法 或 SimpleJdbcInsert
// 普通 update 方法
private void saveIngredientToTaco(
          Ingredient ingredient, long tacoId) {
    jdbc.update(
        "insert into Taco_Ingredients (taco, ingredient) " +
        "values (?, ?)",
        tacoId, ingredient.getId());
  }

// 返回主键的 update 方法
private long saveTacoInfo(Taco taco) {
    taco.setCreatedAt(new Date());
    PreparedStatementCreator psc =
        new PreparedStatementCreatorFactory(
            "insert into Taco (name, createdAt) values (?, ?)",
            Types.VARCHAR, Types.TIMESTAMP
        ).newPreparedStatementCreator(
            Arrays.asList(
                taco.getName(),
                new Timestamp(taco.getCreatedAt().getTime())));

    KeyHolder keyHolder = new GeneratedKeyHolder();

  	// 需要参数:PreparedStatementCreator 和 keyHolder
  	jdbc.update(psc, keyHolder);

    return keyHolder.getKey().longValue();
  }
  • 插入数据:SimpleJdbcInsert
@Repository
public class JdbcOrderRepository implements OrderRepository {

  private SimpleJdbcInsert orderInserter;
  private SimpleJdbcInsert orderTacoInserter;
  private ObjectMapper objectMapper;

  @Autowired
  public JdbcOrderRepository(JdbcTemplate jdbc) {
		// 创建SimpleJdbcInsert
    this.orderInserter = new SimpleJdbcInsert(jdbc)
        .withTableName("Taco_Order")
        .usingGeneratedKeyColumns("id");

		// 创建SimpleJdbcInsert
    this.orderTacoInserter = new SimpleJdbcInsert(jdbc)
        .withTableName("Taco_Order_Tacos");

		// 将对象映射为K-V形式
    this.objectMapper = new ObjectMapper();
  }

  @Override
  public Order save(Order order) {
    order.setPlacedAt(new Date());
    long orderId = saveOrderDetails(order);
    order.setId(orderId);
    List<Taco> tacos = order.getTacos();
    for (Taco taco : tacos) {
      saveTacoToOrder(taco, orderId);
    }

    return order;
  }

  private long saveOrderDetails(Order order) {
		// Bean转Map
    @SuppressWarnings("unchecked")
    Map<String, Object> values =
        objectMapper.convertValue(order, Map.class);
    values.put("placedAt", order.getPlacedAt());

    long orderId =
        orderInserter
            .executeAndReturnKey(values) // 插入并获取主键
            .longValue();
    return orderId;
  }

  private void saveTacoToOrder(Taco taco, long orderId) {
    Map<String, Object> values = new HashMap<>();
    values.put("tacoOrder", orderId);
    values.put("taco", taco.getId());
    orderTacoInserter.execute(values);  // 简单插入
  }

Spring Data JPA

使用步骤:

1-添加Spring Data JPA依赖

2-将领域对象标注为实体 (@Entity, @Id)

3-声明和自定义 JPA Repo

将领域对象标注为实体 (@Entity, @Id)

// Taco 和 Ingredient 是多对多关系

@Data
@RequiredArgsConstructor
@NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
@Entity // JPA
public class Ingredient {
  
  @Id // JPA
  private final String id;
  private final String name;
  private final Type type;
  
  public static enum Type {
    WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
  }

}

@Data
@Entity
@Table(name="Taco") // Taco实体对应的数据库实体为Taco表
public class Taco {

  @Id
  @GeneratedValue(strategy=GenerationType.AUTO) // 自增主键
  private Long id;

  @NotNull
  @Size(min=5, message="Name must be at least 5 characters long")
  private String name;

  private Date createdAt;

  @ManyToMany(targetEntity=Ingredient.class) // JPA多对多关系
  @Size(min=1, message="You must choose at least 1 ingredient")
  private List<Ingredient> ingredients = new ArrayList<>();
  
  @PrePersist // 持久化之前会将createdAt设置为当前时间
  void createdAt() {
    this.createdAt = new Date();
  }
}

声明 JPA Repo

// 继承CrudRepository即可。在运行时会自动生成实现类并提供通用方法
// CrudRepository<实体化类型, 主键ID类型>
public interface TacoRepository 
         extends CrudRepository<Taco, Long> {

}

自定义 JPA Repo

  • SpringData自定义了DSL用于解析repository方法的签名
  • @Query 注解可以声明SQL
// 动词+by+属性+条件
List<Order> readOrdersByDeliveryZipAndPlacedAtBetween(String deliveryZip, Date startDate, Date endDate);

// @Query注解自定义SQL
@Query("Order o where o.deliveryCity='Seattle'")
List<Order> readOrdersDeliveredInSeattle();

chap4 Spring Security

引入 Spring Security 的starter依赖后,默认用户名user,在日志文件中会写入随机生成的密码,如

Using default security password: xxx-xxxx-xxxx

Spring Security 提供基于内存、JDBC、LDAP和自定义的用户存储

配置 Spring Security

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Override
  protected void configure(AuthenticationManagerBuilder auth)
      throws Exception {
    // ...
  }
}

基于JDBC的用户存储

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Override
  protected void configure(AuthenticationManagerBuilder auth)
      throws Exception {
    auth
      .jdbcAuthentication()
        .dataSource(dataSource);
  }
}
  • Spring Security 内置一组查询用户信息所执行的默认SQL。对应着Spring Security预设的一套数据库表结构,包括 usersauthoritiesgroupsgroup_membersgroup_authorities

  • org.springframework.security.core.userdetails.jdbc.JdbcDaoImpl 中可以获取到鉴权相关的默认SQL

  • 也可以自定义查询内容

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Override
  protected void configure(AuthenticationManagerBuilder auth)
      throws Exception {
    auth
      .jdbcAuthentication()
        .dataSource(dataSource)
        .usersByUsernameQuery(
            "select username, password, enabled from Users " +
            "where username=?")
        .authoritiesByUsernameQuery(
            "select username, authority from UserAuthorities " +
            "where username=?");
    		// 指定passwordEncoder避免密码明文传输
		    .passwordEncoder(new StandardPasswordEncoder("53cr3t");
  }
}

自定义用户认证

  • 实现UserDetails接口,定义用户领域持久化对象
  • 实现UserDetailService,实现 loadUserByUsername 方法,提供service

鉴权和登出

  • 重写WebSecurityConfigurerAdapter中的configure(HttpSecurity http) 方法
@SuppressWarnings("deprecation")
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  @Autowired
  private UserDetailsService userDetailsService;
  
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .authorizeRequests() // 会按顺序进行校验
        .antMatchers("/design", "/orders")
          .hasRole("ROLE_USER")
        .antMatchers("/", "/**").access("permitAll")
        
      .and()
        .formLogin()
          .loginPage("/login") // 自定义登录页
          
      .and()
        .logout()
          .logoutSuccessUrl("/") // 登出后重定向

      .and()
        .csrf()
          .ignoringAntMatchers("/h2-console/**")

      // Allow pages to be loaded in frames from the same origin; needed for H2-Console
      // tag::frameOptionsSameOrigin[]
      .and()  
        .headers()
          .frameOptions()
            .sameOrigin()
      ;
  }
}
  • 除了使用默认鉴权方法外,还可以使用 access() + SpEL 声明安全规则
http
  .authorizeRequests() 
  	.antMatchers("/design", "/orders")
	  	.access("hasRole('ROLE_USER')")

获取用户信息

多种方式:

  • 注入 Principal 对象:controller可用
  • 注入 Authentication 对象:controller可用
  • 使用 SecurityContextHolder:随处可用但是麻烦
  • 使用 @AuthenticationPrincipal 注解:controller方法入参

配置属性

Spring提供两种配置:

  • Bean装配:声明在Spring容器中创建的组件和组件之间的注入关系
  • 属性注入:配置Bean的属性

Spring中配置的Bean可用通过Spring环境提取属性值

自定义配置属性

在类上添加 @ConfigurationProperties 注解后可用为当前Bean提供配置的属性值

@ConfigurationProperties 注解通常会放到专门用来配置属性的Bean中

@Component // 配置类
@ConfigurationProperties(prefix="taco.order") // 读取配置属性
@Data
@Validated // 开启校验
public class OrderCodeProps {
  @Min()
  @Max()
	private int pageSize = 20; // 配置属性
}

// 使用配置类
@Controller
@ReqeustMapping
public class OrderController {
	// 注入配置类
  private OrderProps props;
  
  // 读取配置
  // props.getPageSize
}

配置文件 application.yaml

taco:
	orders:
		pageSize: 10
  • 声明自定义配置属性的元数据

避免出现如 Unknown property xxx 的告警提示

在 META-INF 下创建 additional-spring-configuration-metadata.json 的文件并添加配置:

{"properties": [
  {
    "name": "taco.orders.page-size",
    "type": "java.lang.String",
    "description": "Sets the maximum number of orders to display in a list."
  }
]}

使用 profile

创建属于不同环境的profile的方式:

  • 创建不同环境下的profile文件application-{profile}.yml,如 application-prod.yml
  • 或在同一个yaml文件中使用 spring.profiles 属性隔离不同环境间的配置,如:
taco:
  orders:
    pageSize: 10
    
---
spring:
  profiles: prod
  
  datasource:
    url: jdbc:mysql://localhost/tacocloud
    username: tacouser
    password: tacopassword
    
logging:
  level:
    tacos: WARN

激活profile:

推荐使用环境变量设置处于激活状态的profile,如

export SPRING_PROFILES_ACTIVE=prod

使用@Profile()注解根据不同profile创建Bean

@Profile("!prod") // 非生产环境才需要装配该bean
@Configuration
public class DevelopmentConfig {

  @Bean
  public CommandLineRunner dataLoader(IngredientRepository repo,
        UserRepository userRepo, PasswordEncoder encoder) { // user repo for ease of testing with a built-in user
    return new CommandLineRunner() {
      @Override
      public void run(String... args) throws Exception {
        repo.save(new Ingredient("FLTO", "Flour Tortilla", Type.WRAP));
        // ...
    };
  }
  
}