Меняем Java на Scala. Базовое приложение

в 9:34, , рубрики: java, scala, Веб-разработка, наслаждайтесь процессом, пишите проще

Здравствуй.

Лето на дворе, скоро отпуск и появилось немного свободного времени поделиться наработками, каким-то опытом по написанию Web приложений на Java платформе. Как основной язык я буду использовать Scala. Это будет похоже на небольшой гайд, как человеку с опытом Java постепенно начать использовать Scala и не отказываться от уже имеющихся у него наработок.

Это первая часть из серии статей, в которой мы уделим внимание базовой структуре приложения. Ориентирована на людей знающих Java, работавших со Spring, Hibernate, JPA, JSP и другими 3-4ех буквенными сокращениями. Я попытаюсь рассказать как максимально быстро и безболезненно начать использовать Scala в ваших проектах и по-другому проектировать ваше новое приложение. Все это будет вокруг проекта, который должен выполнять ряд требований:
1. Приложение полностью закрыто, работаем только после авторизации
2. Наличие удобного API (REST мы забудем (он уже история) и напишем что-то вроде Google AdWords API, со своим SQL like запросником)
3. Возможность запуска на сервере приложений так и без него
4. i18n
5. Миграция БД
6. Среда для разработки должна разворачиваться через Vagrant
7. И, по мелочи, логирование, развертывание…

Все это нужно сделать так, чтобы сопровождать и развивать наше приложение было очень легко, чтобы не возникло такой ситуации, когда при добавление нового справочника программист оценивает это сроком в 2 дня. Если я вас заинтересовал, прошу под кат.

Для начала

Стоит ознакомиться с синтаксисом Scala, например пролистав книгу Хорстмана «Scala для нетерпеливых». Чтобы примерно представлять, как устроен язык и знать что в нем есть. Я советую не лезть сразу в дебри, начинать с простого и запоминать где вы видели какие-нибудь сложные и интересные конструкции. Через время возвращаться к ним и смотреть как они реализованы, пробовать сделать подобные вещи. Язык большой и сразу использовать все возможности может быть проблематично.

Что будем использовать

Для Scala есть много заточенных вещей, например стоит посмотреть на Play Framework, SBT, Slick, Lift. Но мы начнем с тех вещей с которыми уже работали. Сборку будем делать через Maven. Возьмем Spring, Spring MVC, Spring Security за основу. Для БД возьмем Squeryl (мне не нравится Hibernate из-за своих тяжеловесности, специфических особенностей, и вечно проблемным Lazy). Фронт у нас будет полностью на Angular, для стилей будет SASS, вместо JS возьмем CoffeeScript (я покажу как его использовать, но вы с той же легкостью можете и отказаться от Coffee). Ну и, само собой, мы будем писать тесты, как интеграционные, так и модульные, на ScalaTest. Тестирование фронта мы опустим, так как это отдельный со своими особенностями объемный разговор. API у нас будет интересное. В нем будет понятие сервиса, у сервиса будут методы, также мы будем поддерживать SQL like запросы на выборку. Например:

select id, name, bank from Organization where bank.id = :id // => [{id: 1, name: 'name', bank: {id: 1, name: 'bankname', node: 'Note'}}]
select name, bank.id, bank.name from Organization order by bank.name // => [{name: 'name', bank: {id: 1, name: 'bankname'}}]

К делу

Структура и зависимости

Первым делом создаем Maven проект и сразу подключим плагин для компиляции Scala.

pom.xml
   <properties>
        <scala-version>2.10.4</scala-version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.scala-lang</groupId>
            <artifactId>scala-library</artifactId>
            <version>${scala-version}</version>
        </dependency>
    </dependencies>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>net.alchim31.maven</groupId>
                    <artifactId>scala-maven-plugin</artifactId>
                    <version>3.1.6</version>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>2.0.2</version>
                </plugin>
            </plugins>
        </pluginManagement>
        <plugins>
            <plugin>
                <groupId>net.alchim31.maven</groupId>
                <artifactId>scala-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <id>scala-compile-first</id>
                        <phase>process-resources</phase>
                        <goals>
                            <goal>add-source</goal>
                            <goal>compile</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>scala-test-compile</id>
                        <phase>process-test-resources</phase>
                        <goals>
                            <goal>testCompile</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <executions>
                    <execution>
                        <phase>compile</phase>
                        <goals>
                            <goal>compile</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

Все наши исходники будут лежать в директории src/main/scala, также можно и писать какие-то вещи на Java, укладывая их в src/main/java. Собственно Scala классы можно использовать в Java классах и наоборот, если такая возможность нужна. Также нам потребуется Spring, Spring MVC, Spring Security, Spring OAuth, я считаю что подключить все это не составит особого труда, поэтому описывать не буду. Из нюансов нам понадобится еще Jetty (при разработке мы будем запускать наше приложение через него). Еще Scala Config, ScalaTest. Для того чтобы тесты запускались через Maven, нужно выключить Maven Surefire Plugin и использовать Scalatest Maven Plugin

pom.xml

             <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.7</version>
                <configuration>
                    <skipTests>true</skipTests>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.scalatest</groupId>
                <artifactId>scalatest-maven-plugin</artifactId>
                <version>1.0</version>
                <configuration>
                    <reportsDirectory>${project.build.directory}/surefire-reports</reportsDirectory>
                    <junitxml>.</junitxml>
                    <filereports>WDF TestSuite.txt</filereports>
                </configuration>
                <executions>
                    <execution>
                        <id>test</id>
                        <goals>
                            <goal>test</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

Для того, чтобы не писать в каждом классе инициализацию логгера, подключим библиотеку, которая предоставит нам trait LazyLogging.

<dependency>
  <groupId>com.typesafe.scala-logging</groupId>
  <artifactId>scala-logging-slf4j_2.10</artifactId>
  <version>2.1.2</version>
</dependency>
Миграция БД

Теперь пришло время подумать о нашей БД. Для миграции мы будем использовать Liquibase. Для начала создадим файл, в котором будут описаны ссылки на все changeset.

resources/changelog/db.changelog-master.xml

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="
        http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">

    <include file="classpath:changelog/db.changelog-0.1.xml"/>
</databaseChangeLog>

И опишем наш первый changeset, в котором будут все таблицы для авторизации и OAuth

db.changelog-0.1.xml

<?xml version="1.0" encoding="UTF-8"?>

<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
         http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">

    <changeSet id="0.1-auth" author="andy.sumskoy@gmail.com">
        <createTable tableName="users">
            <column name="user_id" type="int" autoIncrement="true">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="username" type="varchar(255)">
                <constraints unique="true" nullable="false"/>
            </column>
            <column name="password" type="varchar(255)">
                <constraints nullable="false"/>
            </column>
            <column name="enabled" type="boolean" defaultValueBoolean="true">
                <constraints nullable="false"/>
            </column>
        </createTable>

        <createTable tableName="authority">
            <column name="authority_id" type="int" autoIncrement="true">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="name" type="varchar(255)">
                <constraints unique="true" nullable="false"/>
            </column>
        </createTable>

        <createTable tableName="user_authorities">
            <column name="user_id" type="int">
                <constraints foreignKeyName="fk_user_authorities_users" referencedTableName="users"
                             referencedColumnNames="user_id"/>
            </column>
            <column name="authority_id" type="int">
                <constraints foreignKeyName="fk_user_authorities_authority" referencedTableName="authority"
                             referencedColumnNames="authority_id"/>
            </column>
        </createTable>
        <addPrimaryKey columnNames="user_id, authority_id" constraintName="pk_user_authorities"
                       tableName="user_authorities"/>

        <insert tableName="authority">
            <column name="authority_id">1</column>
            <column name="name">ROLE_ADMIN</column>
        </insert>
        <insert tableName="authority">
            <column name="authority_id">2</column>
            <column name="name">ROLE_USER</column>
        </insert>
        <insert tableName="authority">
            <column name="authority_id">3</column>
            <column name="name">ROLE_POWER_USER</column>
        </insert>
        
        <createTable tableName="persistent_logins">
            <column name="username" type="varchar(64)">
                <constraints nullable="false"/>
            </column>
            <column name="series" type="varchar(64)">
                <constraints nullable="false" primaryKey="true"/>
            </column>
            <column name="token" type="varchar(64)">
                <constraints nullable="false"/>
            </column>
            <column name="last_used" type="timestamp">
                <constraints nullable="false"/>
            </column>
        </createTable>

        <createTable tableName="oauth_client_details">
            <column name="client_id" type="varchar(256)">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="resource_ids" type="varchar(256)"/>
            <column name="client_secret" type="varchar(256)"/>
            <column name="scope" type="varchar(256)"/>
            <column name="authorized_grant_types" type="varchar(256)"/>
            <column name="web_server_redirect_uri" type="varchar(256)"/>
            <column name="authorities" type="varchar(256)"/>
            <column name="access_token_validity" type="int"/>
            <column name="refresh_token_validity" type="int"/>
            <column name="additional_information" type="text"/>
            <column name="autoapprove" type="varchar(256)"/>
        </createTable>

        <createTable tableName="oauth_access_token">
            <column name="token_id" type="varchar(256)"/>
            <column name="token" type="blob"/>
            <column name="authentication_id" type="varchar(256)"/>
            <column name="user_name" type="varchar(256)"/>
            <column name="client_id" type="varchar(256)"/>
            <column name="authentication" type="blob"/>
            <column name="refresh_token" type="varchar(256)"/>
        </createTable>

        <createTable tableName="oauth_refresh_token">
            <column name="token_id" type="varchar(256)"/>
            <column name="token" type="blob"/>
            <column name="authentication" type="blob"/>
        </createTable>

        

    </changeSet>
    
    <changeSet id="0.1-auth-data" author="andy.sumskoy@gmail.com" context="test">
        <insert tableName="users">
            <column name="user_id">1</column>
            <column name="username">admin</column>
            <column name="password">dd28a28446b96db4c2207c3488a8f93fbb843af1eeb7db5d2044e64581145341c4f1f25de48be21b
            </column>
            <column name="enabled">true</column>
        </insert>
        <insert tableName="user_authorities">
            <column name="user_id">1</column>
            <column name="authority_id">1</column>
        </insert>
        <insert tableName="user_authorities">
            <column name="user_id">1</column>
            <column name="authority_id">2</column>
        </insert>
        <insert tableName="user_authorities">
            <column name="user_id">1</column>
            <column name="authority_id">3</column>
        </insert>

        <insert tableName="oauth_client_details">
            <column name="client_id">simple-client</column>
            <column name="client_secret">simple-client-secret-key</column>
            <column name="authorized_grant_types">password</column>
        </insert>
    </changeSet>

</databaseChangeLog>

Тут стоит обратить внимание на то, что если наше приложение будет запущено в тестовой среде, то в системе будет зарегистрирован пользователь admin с паролем admin, имеющий все возможные права и будет создан клиент для OAuth. Также стоит отметить, что если вы собираетесь использовать только одну СУБД, то я бы рекомендовал писать changeset на SQL (об этом можно почитать в документации к liquibase).

Теперь нужно добиться того, чтобы при старте приложения liquibase доводил нашу БД «до кондиции», но об этом чуточку позже.

Настройки приложения

Для начала нам нужно создать resources/application.conf

habr.template = {
  default = {
    db.url = "jdbc:postgresql://localhost/habr"
    db.user = "habr"
    db.password = "habr"
  }

  test = {
    db.url = "jdbc:postgresql://localhost/test-habr"
  }

  dev = {

  }
}

Тут мы создаем несколько секций, в default выставлены все настройки по умолчанию, в dev, test специфичные в зависимости от окружения. Создадим также класс AppConfig, который будет отвечать за настройку нашего приложения

AppConfig


class AppConfig {

  val env = scala.util.Properties.propOrElse("spring.profiles.active", scala.util.Properties.envOrElse("ENV", "test"))
  val conf = ConfigFactory.load()
  val default = conf.getConfig("habr.template.default")
  val config = conf.getConfig("habr.template." + env).withFallback(default)

  def dataSource = {
    val ds = new BasicDataSource
    ds.setDriverClassName("org.postgresql.Driver")
    ds.setUsername(config.getString("db.user"))
    ds.setPassword(config.getString("db.password"))
    ds.setMaxActive(20)
    ds.setMaxIdle(10)
    ds.setInitialSize(10)
    ds.setUrl(config.getString("db.url"))
    ds
  }

  def liquibase(dataSource: DataSource) = {
    val liquibase = new LiquibaseDropAllSupport()
    liquibase.setDataSource(dataSource)
    liquibase.setChangeLog("classpath:changelog/db.changelog-master.xml")
    liquibase.setContexts(env)
    liquibase.setShouldRun(true)
    liquibase.dropAllContexts += "test"
    liquibase
  }

}

Мы определяем среду, в которой запущено приложение, это может быть — -Dspring.profiles.active, либо export ENV. Загружаем нужную ветку конфига и мерджим с настройками по умолчанию. Создаем пулл соединений с БД. Тут еще можно вынести в настройки размер пула, к примеру, все по желанию. Ну и создаем liquibase, который поддерживает полное удаление всей структуры в БД для определенных сред выполнения, к примеру, удаление всего может пригодиться если вы используете CI для вашего приложения. Теперь можно зарегистрировать DataSource и Liquibase как Bean в Spring

root.xml

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">

    <bean id="config" class="com.sumskoy.habr.template.AppConfig"/>
    <bean id="dataSource" factory-bean="config" factory-method="dataSource"/>
    <bean id="liquibase" factory-bean="config" factory-method="liquibase">
        <constructor-arg ref="dataSource"/>
    </bean>
</beans>

Запуск из под Jetty

Я всегда для разработки использую Jetty, это избавляет от долгого ожидания перед каждым запуском на сервере приложений, а если у вас большое количество ресурсов, то этот процесс может занимать до 30 секунд, что крайне раздражает. Создадим точку входа в наше приложение:

Main

object Main extends App {
  val server = new Server(8080)

  val webAppContext = new WebAppContext()
  webAppContext.setResourceBase("src/main/webapp")
  webAppContext.setContextPath("/")
  webAppContext.setParentLoaderPriority(true)
  webAppContext.setConfigurations(Array(
    new WebXmlConfiguration()
  ))

  server.setHandler(webAppContext)
  server.start()
  server.join()
}

Безопасность

Я не буду расписывать как настроить Spring Security, единственное оговорю, что для авторизации мы будем использовать /login.html, как освной url — /index.html, все API у нас будет в ветке /api.
Давайте сделаем простую модель User, сделаем к ней Repository, в которой пока будет один метод, он должен будет возвращать пользователя по имени. Сделаем контроллер, который возвращает имя текущего пользователя:

User Entity

case class User(username: String, password: String, enabled: Boolean, @Column("user_id") override val id: Int) extends BaseEntity {
  def this() = this("", "", false, 0)
}

Добавим модель в схему

Core Schema

object CoreSchema extends Schema {
  val users = table[User]("users")

  on(users)(user => declare(
    user.id is autoIncremented,
    user.username is unique
  ))
}

И напишем простой Repository. Я не буду делать интерфейс с реализацией, сразу напишу реализацию, так как в большинстве случаев в этом нет нужды, только лишний раз захламляет код. Если вдруг понадобиться менять реализацию или использовать AOP, то выделить интерфейс из класса не составит труда, но сейчас нам это не нужно и такая необходимость не предвидится в ближайшем будущем. Не будем усложнять себе жизнь.

User Repository

@Repository
class UserRepository {

  def findOne(username: String) = inTransaction {
    CoreSchema.users.where(_.username === username).singleOption
  }

}

Ну и простой контроллер

AuthController

@Controller
@RequestMapping(Array("api/auth"))
class AuthController @Autowired()(private val userRepository: UserRepository) {

  @RequestMapping(Array("check"))
  @ResponseBody
  def checkTokenValid(principal: Principal): Map[String, Any] = {

    userRepository.findOne(principal.getName) match {
      case Some(user) => Map[String, Any]("username" -> user.username, "enabled" -> user.enabled)
      case _ => throw new ObjectNotFound()
    }

  }

}

Тут стоит упомянуть что для сериализации в JSON мы используем Jackson. К нему есть библиотека, которая позволяет работать в классами и коллекциями Scala, для этого определим правильный маппер для Spring

def converter() = {
    val messageConverter = new MappingJackson2HttpMessageConverter()

    val objectMapper = new ObjectMapper() with ScalaObjectMapper
    objectMapper.registerModule(DefaultScalaModule)
    messageConverter.setObjectMapper(objectMapper)

    messageConverter
  }

<beans:bean id="converter" factory-bean="config" factory-method="converter"/>
    <mvc:annotation-driven>
        <message-converters register-defaults="true">
            <beans:ref bean="converter"/>
        </message-converters>
    </mvc:annotation-driven>
Тесты

Теперь нужно зафиксировать поведение авторизации через тесты. Мы гарантируем, что клиент может авторизоваться через форму входа и через OAuth. Напишем на это пару тестов.
Для начала сделаем базовый класс для всех тестов с использованием Spring MVC

IntegrationTestSpec

@ContextConfiguration(value = Array("classpath:context/root.xml", "classpath:context/mvc.xml"))
@WebAppConfiguration
abstract class IntegrationTestSpec extends FlatSpec with ShouldMatchers with ScalaFutures {
  @Resource private val springSecurityFilterChain: java.util.List[FilterChainProxy] = new util.ArrayList[FilterChainProxy]()
  @Autowired private val wac: WebApplicationContext = null

  new TestContextManager(this.getClass).prepareTestInstance(this)

  var builder = MockMvcBuilders.webAppContextSetup(this.wac)
  for(filter <- springSecurityFilterChain.asScala) builder = builder.addFilters(filter)

  val mockMvc = builder.build()
  val md = MediaType.parseMediaType("application/json;charset=UTF-8")

  val objectMapper = new ObjectMapper() with ScalaObjectMapper
  objectMapper.registerModule(DefaultScalaModule)
}

И напишем наш первый тест для авторизации

it should "Login as admin through oauth with default password" in {
    val resultActions =
      mockMvc.perform(
        get("/oauth/token").
          accept(md).
          param("grant_type", "password").
          param("client_id", "simple-client").
          param("client_secret", "simple-client-secret-key").
          param("username", "admin").
          param("password", "admin")).
        andExpect(status.isOk).
        andExpect(content.contentType(md)).
        andExpect(jsonPath("$.access_token").exists).
        andExpect(jsonPath("$.token_type").exists).
        andExpect(jsonPath("$.expires_in").exists)

    val contentAsString = resultActions.andReturn.getResponse.getContentAsString

    val map: Map[String, String] = objectMapper.readValue(contentAsString, new TypeReference[Map[String, String]] {})
    val access_token = map.get("access_token").get
    val token_type = map.get("token_type").get

    mockMvc.perform(
      get("/api/auth/check").
        accept(md).
        header("Authorization", token_type + " " + access_token)).
      andExpect(status.isOk).
      andExpect(content.contentType(md)).
      andExpect(jsonPath("$.username").value("admin")).
      andExpect(jsonPath("$.enabled").value(true))
  }

И тест для авторизации через форму

it should "Login as admin through user form with default password" in {
    mockMvc.perform(
      post("/auth/j_spring_security_check").
        contentType(MediaType.APPLICATION_FORM_URLENCODED).
        param("j_username", "admin").
        param("j_password", "admin")).
      andExpect(status.is3xxRedirection()).
      andExpect(header().string("location", "/index.html"))
  }

На этом мы пока остановимся. В следующей статье мы сделаем фронт, со сборкой SASS, CoffeeScript, минимизацией и прочими удобными штуками. Подружимся с Yeoman, Bower, Grunt, а также сделаем развертывание среды для программиста через Vagrant.

Все это можно посмотреть на Bitbucket https://bitbucket.org/andy-inc/scala-habr-template.

Если нашли опечатку или ошибку, пишите в ЛС. Заранее благодарю за понимание.

Спасибо за ваше внимание, делитесь мнениями и не минусуйте молча.

Автор: a696385

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js