잊지 않겠습니다.

Gradle 2.0으로 업그레이드 된 버젼에 대해서 업데이트 된 글이 있습니다.


기존 gradle을 이용한 개발 환경 설정 업데이트입니다.


변경사항은 다음과 같습니다. 


1. jetty plugin의 제거 : 개발환경에서는 jetty / tomcat의 차이가 없을 것 같아, jetty를 없앴습니다.

2. spork를 이용한 테스트 환경 추가. : groovy base인 spork를 이용한 test 환경을 추가하였습니다.

3. tomcat plugin 사용시, tomcatRun 에러 수정 : tomcat plugin 사용시 tomcatRun 을 하면 servlet jar class not found 에러가 나오는 것을 수정했습니다. 

4. gradle 1.9 에서 동작하지 않던 버그를 수정했습니다.

5. build.gradle을 4개로 분리했습니다.

1) base.gradle : 기본 설정에 관련된 내용으로 구성됩니다.

2) domain.gradle : domain module에 관련된 내용으로 구성됩니다. (queryDsl QFile generate)

3) web.gradle : war에 관련된 내용으로 구성됩니다. (tomcatRun, tomcatRunWar)


build.gradle

apply plugin: 'base'
apply plugin: 'sonar-runner'
apply plugin: 'maven'
version = '1.0.0'

ext {
    javaVersion = 1.7
    springVersion = '4.0.0.RELEASE'
}


buildscript {
    repositories {
        mavenCentral()
        jcenter()
        maven { url 'http://repo.springsource.org/plugins-release' }
    }
    dependencies {
        classpath 'org.gradle.api.plugins:gradle-tomcat-plugin:1.0'
        classpath (group: 'org.gradle.api.plugins', name: 'gradle-cargo-plugin', version: '0.6.1')
        classpath 'org.springframework.build.gradle:propdeps-plugin:0.0.1'
    }
}

allprojects {
    apply plugin: 'propdeps'
    apply plugin: 'propdeps-maven'
    apply plugin: 'propdeps-idea'
    apply plugin: 'propdeps-eclipse'

    repositories {
        mavenLocal()
        mavenCentral()
        jcenter()
        maven { url 'http://download.java.net/maven/2' }
        maven { url "http://oss.sonatype.org/content/repositories/snapshots/" }
        maven { url 'https://maven.java.net/content/repositories/releases'}
        maven { url 'http://repo.springsource.org/plugins-release' }
    }

    dependencies {
        provided 'org.projectlombok:lombok:0.12.0'
    }
}


apply from: 'base.gradle'
apply from: 'domain.gradle'
apply from: 'web.gradle'


base.gradle

subprojects {
    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'
    apply plugin: 'jacoco'
    apply plugin: 'groovy'

    sonarRunner {
        sonarProperties {
            property "sonar.sourceEncoding", "UTF-8"
        }
    }

    jacoco {
        toolVersion = '0.6.3.201306030806'
    }

    test {
        jacoco {
            append = false
            destinationFile = file("target/jacoco.exec")
            classDumpFile = file("target/classpathdumps")
        }
    }

    if(project.hasProperty('target')) {
        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-${project.target}"]
        }
    } else {
        String hostname = InetAddress.getLocalHost().getHostName();
        if(hostname.endsWith('.local')) {   //맥의 경우, .local 이 모든 hostname에 추가됩니다.
            hostname.replace(".local", '')
        }

        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-" + hostname]
        }
    }

    dependencies {
        def slf4jVersion = "1.7.2"
        compile "org.slf4j:jcl-over-slf4j:$slf4jVersion"
        compile "org.slf4j:jul-to-slf4j:$slf4jVersion"
        compile "org.slf4j:slf4j-api:$slf4jVersion"
        compile 'ch.qos.logback:logback-classic:1.0.13'

        testCompile "junit:junit:4.11"

        groovy "org.codehaus.groovy:groovy-all:2.1.5"
        testCompile "org.spockframework:spock-core:1.0-groovy-2.0-SNAPSHOT"
        testCompile "org.spockframework:spock-spring:1.0-groovy-2.0-SNAPSHOT"
    }

    sourceCompatibility = rootProject.ext.javaVersion
    targetCompatibility = rootProject.ext.javaVersion

    tasks.withType(Compile) {
        options.encoding = 'UTF-8'
    }
}

sonarRunner {
    sonarProperties {
        property "sonar.host.url", "http://192.168.13.209:9000"
        property "sonar.jdbc.url", "jdbc:mysql://192.168.13.209:3306/sonar"
        property "sonar.jdbc.driverClassName", "com.mysql.jdbc.Driver"
        property "sonar.jdbc.username", "root"
        property "sonar.jdbc.password", 'qwer12#$'
    }
}


domain.gradle

configure(subprojects.findAll { it.name.endsWith('domain') }) {
    dependencies {
        compile group: 'mysql', name: 'mysql-connector-java', version: '5.1.22'
        compile("com.jolbox:bonecp:0.8.0.RELEASE") {
            exclude group: 'com.google.guava'
        }
        compile group: 'com.google.guava', name: 'guava', version: '15.0'
        compile group: 'org.springframework', name: 'spring-core', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-orm', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-tx', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-context', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-context-support', version: "${rootProject.ext.springVersion}"

        compile group: 'org.hibernate', name: 'hibernate-core', version: '4.1.10.Final'
        compile group: 'org.hibernate.javax.persistence', name: 'hibernate-jpa-2.0-api', version: '1.0.1. Final'
        compile group: 'org.hibernate', name: 'hibernate-entitymanager', version: '4.1.10.Final'
        compile group: 'org.hibernate', name: 'hibernate-validator', version: '4.3.1.Final'

        compile group: 'org.springframework.data', name: 'spring-data-jpa', version: '1.4.2.RELEASE'

        def queryDSL = '3.2.4'
        compile("com.mysema.querydsl:querydsl-core:$queryDSL")
        compile("com.mysema.querydsl:querydsl-jpa:$queryDSL")
        compile("com.mysema.querydsl:querydsl-sql:$queryDSL")
        provided("com.mysema.querydsl:querydsl-apt:$queryDSL") {
            exclude group: 'com.google.guava'
        }

    }

    sourceSets {
        generated {
            java {
                srcDirs = ['src/main/generated']
            }
        }
    }

    task generateQueryDSL(type: JavaCompile, group: 'build', description: 'Generates the QueryDSL query types') {
        source = sourceSets.main.java
        classpath = configurations.compile + configurations.provided
        options.compilerArgs = [
                "-proc:only",
                "-processor", "com.mysema.query.apt.jpa.JPAAnnotationProcessor"
        ]
        destinationDir = sourceSets.generated.java.srcDirs.iterator().next()
    }

    compileJava {
        dependsOn generateQueryDSL
        source generateQueryDSL.destinationDir
    }

    compileGeneratedJava {
        dependsOn generateQueryDSL
        options.warnings = false
        classpath += sourceSets.main.runtimeClasspath
    }

    clean {
        delete sourceSets.generated.java.srcDirs
    }

    idea {
        module {
            sourceDirs += file('src/main/generated')
        }
    }
}



web.gradle

configure(subprojects.findAll { it.name.endsWith('Web') }) {
    apply plugin: 'war'
    apply plugin: 'tomcat'
    apply plugin: 'cargo'

    dependencies {
        String tomcatVersion = '7.0.47'
        tomcat "org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}"
        tomcat "org.apache.tomcat.embed:tomcat-embed-logging-juli:${tomcatVersion}"
        tomcat("org.apache.tomcat.embed:tomcat-embed-jasper:${tomcatVersion}") {
            exclude group: 'org.eclipse.jdt.core.compiler', module: 'ecj'
        }

        def cargoVersion = '1.3.3'
        cargo "org.codehaus.cargo:cargo-core-uberjar:$cargoVersion",
                "org.codehaus.cargo:cargo-ant:$cargoVersion"

        providedCompile 'javax.servlet:javax.servlet-api:3.1.0'
        providedCompile 'javax.websocket:javax.websocket-api:1.0'
        providedCompile 'javax.servlet:jsp-api:2.0'
        providedCompile "org.apache.tomcat:tomcat-servlet-api:${tomcatVersion}"
    }

    tomcatRun {
        dependsOn war
    }

    tomcatRunWar {
        dependsOn war
    }

    cargoRedeployRemote {
        dependsOn war
    }

    cargoDeployRemote {
        dependsOn war
    }

    cargo {
        containerId = 'tomcat7x'
        port = 8080

        deployable {
            context = "${project.name}"
        }

        remote {
            hostname = '192.168.13.209'
            username = 'ykyoon'
            password = 'qwer12#$'
        }
    }
}





Posted by Y2K
,

spock를 이용한 spring test

Java 2013. 12. 11. 03:17

드디어 JUnit의 시대가 끝이 나는것인가.... 라는 생각이 드는 멋진 테스트 Framework인 Spock를 소개합니다.


groovy 언어로 구성을 해야지 되는 언어적 제약사항이 있지만, 기존의 JUnit Test에 비해서 매우 간결한 문법과 TDD를 위한 구성을 제공하고 있습니다. 


1. 설정


gradle 기준으로 spock는 다음과 같이 설정됩니다. 


    repositories {
        mavenCentral()
        maven { url "http://oss.sonatype.org/content/repositories/snapshots/" }
     }
    dependencies {
        groovy "org.codehaus.groovy:groovy-all:2.1.5"
        testCompile "org.spockframework:spock-core:1.0-groovy-2.0-SNAPSHOT"
    }
   


기본적으로 JUnit을 사용하고 있는 test project입니다. 


2. 구성


기본적으로 JUnit과 거의 유사한 기본 설정을 갖습니다.

SpockJUnit
SpecificationTest class
setup()@Before
cleanup()@After
setupSpec()@BeforeClass
cleanupSpec()@AfterClass
FeatureTest
Parameterized featureTheory
ConditionAssertion
Exception condition@Test(expected=...)
@FailsWith@Test(expected=...)
InteractionMock expectation (EasyMock, JMock, ...)



3. 테스트 코드의 종류에 따른 구현


우리가 일반적으로 테스트 코드를 짤때, 다음과 테스트 코드가 주로 작성되게 됩니다. 


1) 특정한 method를 실행후, 객체의 상태가 예측대로인지 확인

2) 특정한 method의 input을 선정하고, output값이 예측대로 나왔는지 확인


이 두가지 case는 약간은 미묘하게 다릅니다. 1)의 경우는 response를 확인을 하는 것이고, 2)의 결과는 예측하는 것으로 약간의 차이를 가지고 오게 됩니다. 

이를 spock에서는 여러 block으로 지정하여, case에 대한 내용을 명확하게 하고 있습니다. 


spock에서 지원하는 block은 다음과 같습니다.  또한 이 block뒤에는 자유롭게 문자열을 넣어서, block안의 내용을 서술할 수 있습니다.


setup : 객체에 대한 설정

when / then : when block이 있으면 반드시 then block이 존재합니다. when에서 객체의 response를 얻어내고, then을 통해 response를 확인합니다. condition을 설정한다고 생각하시면 편합니다.

expect / where : 예측되는 값과 예측값 list를 정의합니다. spock에서 가장 놀라운 기능중 하나입니다. 


위 block을 적용한 code example은 다음과 같습니다. 


    def "건물이름 길이가 작아 exception이 발생"() {
        def buildingName = "가"
        when:
        service.searchByBuilding(buildingName)
        then:
        def e = thrown(IllegalArgumentException)
        e.message == AddressSearchServiceImpl.BUILDING_NAME_IS_TOO_SHORT
    }

    def "서울내의 레미안만 얻어오기"() {
        def buildingName = "레미안"
        when:
        def searchResults = service.searchByBuilding(SidoEnum.SEOUL.getStringValue(), buildingName, 0, 10)
        then:
        searchResults.every { r ->
            r.buildingName.contains(buildingName)
            r.siGunGu.sido.sidoNumber.equals(SidoEnum.SEOUL.getStringValue()) == true
        }
    }

    def "검색문자열 분리 테스트"() {
        expect:
        def matcher = SearchTextRegex.extractSearchText(input)
        matcher.find() == true
        matcher.group("mainText").equals(mainText) == true
        matcher.group("mainNumber").equals(mainNumber) == true

        where:
        input | mainText | mainNumber
        "구로동 245 번지" | "구로동" | "245"
        "서초1동" | "서초1동" | ""
        "잠실동 624" | "잠실동" | "624"
        "지봉로 1" | "지봉로" | "1"
    }

너무나 멋지지 않나요? 지금까지 구성하던 test code의 길이가 절반 이하로 줄어들뿐 아니라, 매우 직관적이고, TDD에 적합한 테스트 코드 형태로 구성이 되고 있는 것을 알 수 있습니다. 


개인적으로 하고 있는 project들을 모두 spock로 변경하는 작업을 한번 해봐야지 될 것 같습니다. 이런 것을 보면 왠지 희열이 느껴지지 않나요? ^^;;

모두들 Happy Coding~ 



Posted by Y2K
,

Spring 3.0에서 처음 도입된 Code Base Configuration은 이제 Spring 설정의 대세가 되어가는 기분이 든다. 

그런데, 이상하게도 Spring Security의 경우에는 Code Base Configuration이 적용되는 것이 매우 늦어지고 있다는 생각이 든다. 최신 3.2.0RC2에서의 Code Base Security를 구현하는 방법에 대해서 간단히 알아보기로 한다. 


1. @EnableWebSecurity Annotation

Spring Security를 사용하는 @Configuration은 @EnableWebSecurity annotation을 반드시 가져야지 된다.


2. WebSecurityConfigurerAdapter의 상속

@Configuration을 구성한 객체는 기본적으로 WebSecurityConfigurerAdapter를 상속받아야지 된다. 이 상속은 기본적인 method의 stub을 제공한다. 


@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
    public static final String ADMIN_ROLE = "ADMIN";

3. configurer 구현

configurer method를 override시켜서, configuration을 구성한다. 기본적으로 configuration xml에서 구성된 내용들은 configurer method에서 구현된다. 


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/login")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/main")
                    .failureUrl("/login?error=loginFailed")
                .and()
                .authorizeRequests()
                    .antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
                    .antMatchers("/api/apiKey/**").authenticated()
                    .antMatchers("/api/message/**").authenticated()
                    .antMatchers("/api/auth/**").authenticated();
    }

기본적으로 구성할 수 있는 login form이다. /login에서 username, password를 이용해서 로그인 하게 되며, login success와 failure를 모두 처리하도록 되어있다. 

4. AuthenticationProvider의 구성

AuthenticationProvider는 UserDetailsService와 PasswordEncoder를 반드시 구성해준다. AuthenticationProvider는 인증을 위한 사용자 Pool과 username, password간의 matching을 하는 방법을 제공한다.  주의점은 UserDetailsService이다. 반드시 BeanIds.USER_DETAILS_SERVICE bean name으로 등록이 되어야지 된다. Spring 내부에서 이 Bean Name을 이용해서 UserDetailsService를 구성하기 때문이다.


    /**
     * UserDetailService와 PasswordEncoder를 이용해서, AuthenticationProvider를 구성한다.
     * @return
     * @throws Exception
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    /**
     * UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Override
    @Bean(name = BeanIds.USER_DETAILS_SERVICE)
    public UserDetailsService userDetailsServiceBean() throws Exception {
        CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
        userDetailsService.setUserService(context.getBean(UserService.class));
        return userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                    return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.equals(encodedPassword);
            }
        };
    }


5. AuthenticationManager 구성

AuthenticationProvider를 이용해서 AuthenticationProvider를 구성해준다. 코드는 매우 단순하다. 다만 개인적으로는 Form Login 부분을 customizing을 시키는 경우가 많기 때문에 AuthenticationManager를 Bean으로 등록시켜두면 두고두고 편한 일들이 많다. 


    /**
     * AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }


전체 코드는 다음과 같다. 


/**
 * Created with IntelliJ IDEA.
 * User: ykyoon
 * Date: 11/18/13
 * Time: 1:12 AM
 * To change this template use File | Settings | File Templates.
 */

@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
    public static final String ADMIN_ROLE = "ADMIN";

    @Autowired
    private ApplicationContext context;
    @Autowired
    private CorsSupportLoginUrlAuthenticationEntryPoint corsEntryPoint;

    @Bean
    public DigestAuthenticationFilter digestAuthenticationFilter() throws Exception {
        DigestAuthenticationFilter digestAuthenticationFilter = new DigestAuthenticationFilter();
        digestAuthenticationFilter.setAuthenticationEntryPoint(corsEntryPoint);
        digestAuthenticationFilter.setUserDetailsService(userDetailsServiceBean());
        return digestAuthenticationFilter;
    }

    @Bean
    public LoginProcessHandler loginProcessHandler() {
        LoginProcessHandler loginProcessHandler = new LoginProcessHandler();
        loginProcessHandler.setObjectMapper(objectMapper());
        return loginProcessHandler;
    }

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/login")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/main")
                    .failureUrl("/login?error=loginFailed")
                .and()
                .authorizeRequests()
                    .antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
                    .antMatchers("/api/apiKey/**").authenticated()
                    .antMatchers("/api/message/**").authenticated()
                    .antMatchers("/api/auth/**").authenticated();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuthenticationProvider());
    }

    /**
     * AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * UserDetailService와 PasswordEncoder를 이용해서, AuthenticationProvider를 구성한다.
     * @return
     * @throws Exception
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    /**
     * UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Override
    @Bean(name = BeanIds.USER_DETAILS_SERVICE)
    public UserDetailsService userDetailsServiceBean() throws Exception {
        CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
        userDetailsService.setUserService(context.getBean(UserService.class));
        return userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                    return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.equals(encodedPassword);
            }
        };
    }

    @Bean(name = CORS_SUPPORT_FILTER)
    public Filter corsSupportFilter() {
        CorsSupportFilter corsSupportFilter = new CorsSupportFilter();
        return corsSupportFilter;
    }
}

6. no-web.xml인 경우 등록하는 방법


Servlet 3.0에서 지원되는 web.xml이 없는 개발에서 매우 유용한 방법이다. Spring Security는 Web.xml이 없을때, 아주 쉬운 코드로 WebXmlInitializer를 구성해뒀는데, AbstractSecurityWebApplicationInitializer를 상속받은 Class가 class path에 있는 경우, 자동으로 springSecurityFilterChain 이름의 Bean을 이용하는 DelegateProxyFilter를 등록한다. web.xml인 경우에는 기존과 동일하게 사용하면 된다. 다만 root context에 @Configuration이 로드가 먼저 선행되어야지 된다. 


public class SecurityWebXmlInitializer extends AbstractSecurityWebApplicationInitializer {
}


Posted by Y2K
,