잊지 않겠습니다.

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
,

Spring MVC를 이용한 개발에서 가장 멋진 일은 MockMvc를 이용한 테스트다. 특히 Controller의 사용자 Scenario를 짜고, 그 Sceneario의 결과를 테스트 해보는것은 너무 재미있는 일이다. 그런데, Spring MVC에 Spring Security를 적용한 후에 인증에 대한 테스트를 하기 위해서는 다음의 간단한 절차를 거쳐야지 된다. 


1. MockMvc에 Spring Security Filter를 적용해야지 된다.

2. Login 절차를 통과한 사용자를 만들어내야지 된다. - MockHttpSession을 이용한다. 


이와 같은 과정을 조금 단순화하기 위해서 간단한 TestSupport 객체를 만들어봤다. Utility 객체이기 때문에, 하는 일들은 매우 단순하다. 

1. MockMvc에 Spring Security Filter를 적용한 후, Return시킨다.

2. Digest 인증키값을 만들어낼 수 있다.

3. Basic 인증키값을 만들어낼 수 있다.

4. Form 인증이 반영된 MockHttpSession 값을 만들어낼 수 있다.


Helper class 코드는 다음과 같다.


/**
 * User: ykyoon
 * Date: 11/18/13
 * Time: 7:13 PM
 * Spring Security Filter 적용 및 인증 지원을 위한 Test Helper Class
 */
public class AuthorizedControllerHelper {

    /**
     * MockMvc 생성 코드
     * @param context WebApplicationContext
     * @return Spring Security Filter가 적용된 MockMvc 객체
     * @throws Exception
     */
    public static MockMvc getSecurityAppliedMockMvc(WebApplicationContext context) throws Exception {
        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();
        MockFilterConfig secFilterConfig = new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN);
        delegateProxyFilter.init(secFilterConfig);

        return MockMvcBuilders.webAppContextSetup(context).addFilter(delegateProxyFilter).build();
    }

    public static final String AUTH_HEADER = "Authorization";

    /**
     * Basic 인증 문자열 생성
     * @param username 사용자 이름
     * @param password 비밀번호
     * @return Basic XXXX 형태의 Authorization 문자열
     * @throws Exception
     */
    public static final String buildBasicAuthHeaderValue(String username, String password) throws Exception {
        String authHeaderFormat = "Basic ";
        String encodingRawData = String.format("%s:%s", username, password);
        String encodingData = authHeaderFormat + new String(Base64.encode(encodingRawData.getBytes("utf-8")));
        return encodingData;
    }

    /**
     * Digest 인증 문자열 생성
     * @param mvc MockMvc. Digest의 경우, 한번의 Request를 통해 서버의 nonce값을 얻어내야지 된다.
     *            Spring Security의 EntryEndPoint의 설정이 DigestAuthenticationFilter로 되어있어야지 된다.
     * @param username 사용자 이름
     * @param password 비밀번호
     * @param uri 호출할 URI
     * @param method HttpRequestMethod : GET, POST, PUT, DELETE
     * @return Digest 인증 문자열
     * @throws Exception
     */
    public static String buildDigestAuthenticateion(MockMvc mvc, String username,
                                                    String password,
                                                    String uri, String method) throws Exception {
        MvcResult mvcResult = null;
        if(method.equals("GET")) {
            mvcResult = mvc.perform(get(uri)).andDo(print()).andReturn();
        } else if(method.equals("POST")) {
            mvcResult = mvc.perform(post(uri)).andDo(print()).andReturn();
        } else if(method.equals("PUT")) {
            mvcResult = mvc.perform(put(uri)).andDo(print()).andReturn();
        } else if(method.equals("DELETE")) {
            mvcResult = mvc.perform(delete(uri)).andDo(print()).andReturn();
        }
        String authHeader = mvcResult.getResponse().getHeader("WWW-Authenticate");
        String[] authHeaderItemStrings = authHeader.split(",\\s");
        Map<String, String> authItems = new HashMap<>();
        Pattern keyAndItemPattern = Pattern.compile("(Digest\\s)?(?<key>[^=]+)=\"(?<value>[^\"]+)\"");
        for(int i = 0 ; i < authHeaderItemStrings.length; i++) {
            Matcher matcher = keyAndItemPattern.matcher(authHeaderItemStrings[i]);
            assertThat(matcher.find(), is(true));
            String key = matcher.group("key");
            String value = matcher.group("value");
            authItems.put(key, value);
        }
        Assert.assertNotNull(authItems.get("realm"));
        Assert.assertNotNull(authItems.get("nonce"));
        Assert.assertNotNull(authItems.get("qop"));

        String ha1 = DigestUtils.md5DigestAsHex(String.format("%s:%s:%s", username, authItems.get("realm"), password).getBytes("UTF-8"));
        String ha2 = DigestUtils.md5DigestAsHex(String.format("%s:%s", method, uri).getBytes("UTF-8"));
        String cnonce = calculateNonce();
        String totalString = String.format("%s:%s:00000001:%s:%s:%s",
                ha1, authItems.get("nonce"), cnonce, authItems.get("qop"), ha2);
        String response = DigestUtils.md5DigestAsHex(totalString.getBytes("UTF-8"));

        String clientRequest = String.format("Digest username=\"%s\",", username) +
                String.format("realm=\"%s\",", authItems.get("realm")) +
                String.format("nonce=\"%s\",", authItems.get("nonce")) +
                String.format("uri=\"%s\",", uri) +
                String.format("qop=%s,", authItems.get("qop")) +
                "nc=00000001," +
                String.format("cnonce=\"%s\",", cnonce) +
                String.format("response=\"%s\"", response);

        return clientRequest;
    }

    /**
     * Form인증을 위한 MockHttpSession 반환 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return Spring Security Attribute가 적용된 MockHttpSession 값
     * @throws Exception
     */
    public static MockHttpSession buildSecuritySession(WebApplicationContext context, String username) throws Exception {
        MockHttpSession session = new MockHttpSession();
        SecurityContext securityContext = buildFormAuthentication(context, username);
        session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext);
        return session;
    }

    /**
     * Spring Security Context 얻어내는 내부 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return SecurityContext
     * @throws Exception
     */
    private static SecurityContext buildFormAuthentication(WebApplicationContext context, String username) throws Exception {
        UserDetailsService userDetailsService = (UserDetailsService) context
                .getBean(BeanIds.USER_DETAILS_SERVICE);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                userDetails,
                userDetails.getPassword(),
                userDetails.getAuthorities());
        SecurityContext securityContext = new SecurityContext() {
            private static final long serialVersionUID = 8611087650974958658L;
            private Authentication authentication;

            @Override
            public void setAuthentication(Authentication authentication) {
                this.authentication = authentication;
            }

            @Override
            public Authentication getAuthentication() {
                return this.authentication;
            }
        };
        securityContext.setAuthentication(authToken);
        return securityContext;
    }

    /**
     * Digest인증시에 사용되는 cnonce 값을 생성하는 내부 함수
     * @return
     * @throws UnsupportedEncodingException
     */
    private static String calculateNonce() throws UnsupportedEncodingException {
        Date d = new Date();
        SimpleDateFormat f = new SimpleDateFormat("yyyy:MM:dd:hh:mm:ss");
        String fmtDate = f.format(d);
        Random rand = new Random(100000);
        Integer randomInt = rand.nextInt();
        return DigestUtils.md5DigestAsHex((fmtDate + randomInt.toString()).getBytes("UTF-8"));
    }
}

Spring Security가 반드시 적용된 Test에서만 사용가능하다. Helper를 이용한 Test code는 다음과 같다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { DomainConfiguration.class, SecurityConfiguration.class, ControllerConfiguration.class })
@WebAppConfiguration
public class AdminNoticeControllerTest {
    @Autowired
    private WebApplicationContext context;
    private MockMvc mvc;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private NoticeService noticeService;

    @Before
    public void setUp() throws Exception {
        assertThat(context, is(not(nullValue())));
        mvc = AuthorizedControllerHelper.getSecurityAppliedMockMvc(context);
    }

    @Test
    public void getAllNotices() throws Exception {
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(get(AdminNoticeController.API_ADMIN_NOTICES)
                .param("pageIndex", "0")
                .param("pageSize", "10").session(session))
                .andExpect(status().isOk())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void hideNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, false);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_HIDE)
                    .param("noticeId", notice.getId().toString())
                    .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void editNotice() throws Exception {

    }

    @Test
    public void showNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, true);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_SHOW)
                .param("noticeId", notice.getId().toString())
                .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    private void checkResultData(MvcResult result) throws Exception {
        ResultData r = objectMapper.readValue(result.getResponse().getContentAsString(), ResultData.class);
        assertThat(r.isOk(), is(true));
    }
}


Posted by Y2K
,

Spring Security를 이용한 REST API를 개발할 때, 외부 사이트와의 데이터 연동을 위해 CORS를 적용하는 것도 매우 좋은 선택이다. 

일반적으로 Cross domain 문제를 해결하기 위해서, JSONP를 이용하는 경우가 많지만 이 방법은 두가지 이유때문에 개인적으로는 추천하지 않는다. 


1. GET method만을 이용 가능 - 데이터를 많이 보내야지 되는 경우가 발생할 수 있고, 무엇보다 REST한 API를 만드는데 제약사항이 발생하게 된다.

2. 인증 문제 - 인증을 받아서 처리를 해줘야지 되는 API의 경우에는 JSONP를 이용할 수 없다. 모든 정보를 API서버에만 위치하고, HTML + javascript로만 동작하는 web application을 작성하는 것이 목적이라면 인증을 처리하기 위해서라도 JSONP가 아닌 CORS를 적용해야지 된다. 


그러나 CORS 역시 많은 제약사항을 가지고 있다. 기본적인 제약사항들은 다음과 같다.  


1. GET, HEAD, POST 만 사용 가능하다.

2. POST의 경우에는 다음과 같은 조건이 경우에만 사용가능하다.

  1) content-type이 application/x-www.form-urlencoded, multipart/form-data, text/plain의 경우에만 사용 가능하다.

  2) customer Header가 설정이 된 경우에는 사용 불가하다. (X-Modified etc...)

3. Server에서 Access-Control-Allow-Origin 안에 허용여부를 결정해줘야지 된다. 


큰 제약사항은 위 3가지지만, 세부적으로는 preflight 문제가 발생하게 된다. preflight란, POST로 외부 site를 call 할때, OPTIONS method를 이용해서 URL에 접근이 가능한지를 다시 한번 확인하는 절차를 거치게 된다. 이때, 주의할 점이 WWW에서 제약한 사항은 분명히 content-type이 application/xml, text/xml의 경우에만 preflight가 발생한다고 되어있으나, firefox나 chrome의 경우에는 text/plain, application/x-www-form-urlencoded, multipart/form-data 모두에서 prefligh가 발생하게 된다. 


위 이론을 먼저 알고, Spring Security를 적용한 REST API Server를 구축하기 위해서는 다음과 같은 절차가 우선 필요하다.


1. Spring Security Form authentication endPoint의 변경

: Spring Security Form authentication은 인증되지 않은 Request가 접근한 경우, login page로 302 redirect를 발생시킨다. 이렇게 되면 API를 이용해서 로그인의 실패등을 확인하기 힘들기 때문에, 인증되지 않은 Request를 처리하는 방법을 달리 해줘야지 된다. 기본적으로 Digest Authentication 역시 www는 지원하기 때문에 Digest 인증 방식의 end point를 이용해서 인증되지 않은 request가 접근한 경우 302가 아니라 401(NotAuthenticated)를 반환할 수 있도록 Spring Configuration을 변경하도록 한다. 


2. CSRF disable

: 다른 domain에서의 API call이 발생하기 때문에 CSRF salt cookie값을 얻어내는 것은 불가능하다. 따라서, CSRF를 disable시켜야지 된다. (이 부분은 해결방법이 다른 것이 있는지 확인이 필요)


3. Login Processing을 재구현

: Spring Security를 이용한 Form Authentication의 경우, login success의 경우에도 마찬가지로 redirect가 발생하게 된다. AuthenticationSuccessHandler interface를 이용해서 변경시켜주거나, Login / Logout을 아애 새로 만들어주는 것이 필요하다. 개인적으로는 Login / Logout을 새로 만들어주는 것을 선호하는 편인데, Controller에 대한 Test code 역시 만들어주는 것이 가능하고, 좀더 깔끔해보이는 느낌이 든다.


4. CORS Filter의 적용

: 모든 response 에 Allow-Origin header를 삽입해주는 Filter 객체가 반드시 필요하다. 


Spring Security의 Form 인증을 새로 만들어주기 위해서는 다음과 같이 Controller를 만들 필요가 있다.


@Controller

public class LoginController {

    public static final String API_LOGIN = "/api/login";

    public static final String API_LOGOUT = "/api/logout";

    @Autowired

    private AuthenticationManager authenticationManager;


    @Autowired

    private UserService userService;


    @RequestMapping(value= API_LOGIN, method = {RequestMethod.GET, RequestMethod.OPTIONS})

    @ResponseBody

    @ResultDataFormat

    public Object getAuthenticationStatus() {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        if (auth != null && !auth.getName().equals("anonymousUser") && auth.isAuthenticated()) {

            User user = userService.findByUsername(auth.getName());

            return new LoginStatus(true, auth.getName(), user.getName());

        } else {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value= API_LOGIN, method = RequestMethod.POST)

    @ResponseBody

    @ResultDataFormat

    public Object login(@RequestParam("username") String username, @RequestParam("password") String password) {

        try {

            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);

            User user = userService.findByUsername(username);

            token.setDetails(user);

            Authentication auth = authenticationManager.authenticate(token);

            SecurityContextHolder.getContext().setAuthentication(auth);

            return new LoginStatus(auth.isAuthenticated(), user.getUsername(), user.getName());

        } catch (Exception e) {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value = API_LOGOUT)

    @ResponseBody

    @ResultDataFormat

    public Object logout(HttpServletRequest request) {

        HttpSession session = request.getSession(false);

        if(session != null) {

            session.invalidate();

        }

        SecurityContextHolder.clearContext();

        return new LoginStatus(false, null, null);

    }


    @Getter

    public class LoginStatus {

        private final boolean isAuthenticated;

        private final String username;

        private final String name;

        public LoginStatus(boolean loggedIn, String username, String name) {

            this.isAuthenticated = loggedIn;

            this.username = username;

            this.name = name;

        }

    }

}




다음에는 CORS Filter 적용 및 Spring Security Configuration을 이용해서 REST Server 구성을 더 해보도록 하겠다.




Posted by Y2K
,

개인 project를 진행하다가 이상한 에러가 계속해서 발생되었다. 


Transaction update가 발생될때와 Test Rollback 이 발생될 때마다 PlatformTransaction null point exception이 발생되었다.

특이하게 이 exception은 다음과 같은 상황에서만 발생된다. 


1. JUnit을 이용한 test인 경우

2. update, delete query인 경우


발생되는 곳에 @Transactional을 붙이건 뭘하건 계속해서 발생이 되는데.... 이 에러의 원인은 엉뚱하게도 다른곳에 있었다.


hibernate-core와 hibernate-entitymanager 간의 버젼이 다를 경우에 발생하는 에러라는것. -_-;;


gradle로 build script를 짜두고 hibernate version만으로 따로 project property로 관리하고 있었는데, 최신의 hibernate와 예전의 hibernate-entitymanager간의 버젼이 다르기 때문에 발생되는 에러였다. 


그래서 gradle script를 다음과 같이 변경한 후에는 잘 돌아간다.; hibernate에 관련된 library들은 같은 version으로 관리될 수 있도록 해야지 되겠다.;



ext {

    springVersion = '4.0.0.BUILD-SNAPSHOT'

    springDataVersion = '1.4.2.RELEASE'

    javaVersion = 1.7

    slf4jVersion = '1.7.2'

    querydslVersion = '3.2.4'

    hibernateVersion = "4.2.7.Final"

}


        compile "org.hibernate:hibernate-core:${rootProject.ext.hibernateVersion}"

        compile "org.hibernate:hibernate-entitymanager:${rootProject.ext.hibernateVersion}"

        compile "org.hibernate:${rootProject.ext.hibernateVersion}"



Posted by Y2K
,

gradle은 maven과 다르게 provided가 지원되지 않는다. 

지원되지 않기 때문에 다양한 방법으로 provided를 사용하고 있는데, spring.org에서 해결책을 내놓은 plugin으로 해결 가능하다. 


github 주소는 이곳에, 

https://github.com/spring-projects/gradle-plugins/tree/master/propdeps-plugin


사용법은 아래와 같다. 

buildscript {
    repositories {
        maven { url 'http://repo.springsource.org/plugins-release' }
    }
    dependencies {
        classpath 'org.springframework.build.gradle:propdeps-plugin:0.0.1'
    }
}

// ...

configure(allprojects) {
    apply plugin: 'propdeps'
    apply plugin: 'propdeps-maven'
    apply plugin: 'propdeps-idea'
    apply plugin: 'propdeps-eclipse'
}


dependencies {
    compile("commons-logging:commons-logging:1.1.1")
    optional("log4j:log4j:1.2.17")
    provided("javax.servlet:javax.servlet-api:3.0.1")
    testCompile("junit:junit:4.11")
}
기존의 idea, eclipse task역시 반영을 받기 때문에 매우 유용하게 사용 가능하다.


Posted by Y2K
,
기존 maven을 사용할 때, 개인 개발 환경에서 개인적으로 가장 문제를 느낀 것은 개인 property파일의 문제였습니다. 
개인 property 파일을 local로 만들어주고, 그 local을 이용할 때 그 파일들은 따로 svn으로 관리하기가 매우 힘들었던 단점을 가지고 있습니다. 

예를 들어 logback의 설정파일안에 ykyoon은 c:\Logs안에 log 파일을 만들때, 다른 사람들은 d:\Logs에 만드는 등, property 파일들간의 충돌이 가장 큰 문제중 하나였습니다. 

따라서, 이러한 개인 property 파일들의 관리 및 svn이나 git를 이용한 버젼관리까지 같이 해주기 위해서는 사용자의 build system마다 다른 build 경로를 가질 수 있어야지 됩니다. 제가 gradle을 보게 된 가장 큰 이유중 하나입니다. 

그리고, Web Project의 경우에는 jetty 또는 tomcat을 이용해서 local run이 가능한 것이 개발의 속도를 높여줍니다. 
한가지 더 추가를 한다면, ~Web으로 시작되는 project들은 tomcat/jetty를 이용해서 running이 가능하다면 좋습니다. 

이와 같은 조건을 모두 만족하는 build.gradle을 만들어본다면 다음과 같습니다. 

1. 각 개발자 PC 상황에 맞는 resources path 설정

: 개발자들은 자신의 개인 PC를 이용해서 개발을 합니다. 네트워크의 충돌을 막기 위해서 일반적으로 hostname은 모두 다르게 구성됩니다. 따라서, hostname을 기반으로 하는 것이 좋은 것으로 판단됩니다.

hostname을 얻어내기 위해서는 다음과 같은 script를 구성하면 됩니다. 

        String hostname = InetAddress.getLocalHost().getHostName();
        if(hostname.endsWith('.local')) {   //맥의 경우, .local 이 모든 hostname에 추가됩니다.
            hostname.replace(".local", '')
        }

mac에서만 local host의 이름에 쓸데없는 값이 들어갈 수 있기 때문에 일단 제거를 시켜줍니다. 
그리고, 모든 subproject에서 이와 같은 속성을 사용하기 때문에 subproject를 다음과 같은 gradle script를 구성해줍니다. 
아래의 script결과, hostname이 ykyoon인 경우, 기존 src/main/resources, src/main/resources-ykyoon 이 모두 포함이 됩니다. 개발자들은 공통적인 resource의 경우에는 src/main/resources에 넣어주고, 추가적인 개인 환경에 따른 resources를 모두 자신의 호스트 이름이 들어있는 path에 넣어주고 svn이나 git를 통해서 파일을 관리해주면 됩니다. 

또한, 최종 build시에는 -P argument를 이용해서 target을 지정해주면, 배포환경에 따른 resources 역시 처리가 가능하게 됩니다. 

ext {
    javaVersion = 1.7
}

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

    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]
        }
    }

    repositories {
        mavenCentral()
    }

    dependencies {
        compile 'org.slf4j:slf4j-api:1.7.5'
        testCompile "junit:junit:4.11"
    }
   
    sourceCompatibility = rootProject.ext.javaVersion
    targetCompatibility = rootProject.ext.javaVersion

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


2. webProject의 local tomcat / jetty runner 설정

webProject를 구성할 때, 자신의 local server를 쉽게 구현할 수 있다는 것은 큰 장점입니다. javascript나 css, html의 수정을 바로바로 해볼 수 있다는 점에서 개발자의 편의를 증대시킬 수 있는 방법중 하나입니다. 

먼저, 기본적으로 webProject의 경우에는 모든 project가 'Web'으로 끝이 난다고 가정을 하면 좀 더 편하게 접근이 가능합니다. 모든 project들에 따로 설정을 해줄 필요도 없고 보다 직관적인 접근이 가능하기 때문에 개인적으로는 추천하는 방법입니다. 

project의 이름이 'Web'으로 끝나는 project 만을 선택하기 위해서 다음과 같은 configure를 설정합니다. 

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

이 subproject들은 이제 모두 'war' plugin을 갖습니다. 이제 tomcat과 jetty plug in을 이용해서 tomcat과 jetty를 실행시킬 수 있는 환경을 구축해야 됩니다. 

기본적으로 jetty의 경우, jetty plugin을 gradle에서 기본적으로 지원하고 있으나, jetty의 버젼이 6.0인 관계로 현 상황에서는 그다지 쓸만하지 못합니다. 3rd party plugin을 이용해서 최신의 jetty를 지원할 수 있도록 구성을 변경해야지 됩니다. 

외부 library를 끌어와서 gradle에서 사용해야지 되기 때문에 buildscripts 항목을 다음과 같이 추가합니다. 

    repositories {
        jcenter()
        maven { url = 'http://dl.bintray.com/khoulaiz/gradle-plugins' }
    }
    dependencies {
        classpath (group: 'com.sahlbach.gradle', name: 'gradle-jetty-eclipse-plugin', version: '1.9.+')
    }

이제 jetty를 사용할 수 있는 준비가 모두 마쳐졌습니다. configure에 이제 jetty plugin을 넣어주면, jettyEclipseRun/jettyEclipseWarRun task를 통해서 sub project들을 바로 web 으로  보여주는 것이 가능합니다. 

마지막으로 tomcat입니다. tomcat의 경우에는 gradle에서 제공되는 tomcat plugin이 제공되지 않았으나, 최근에 추가되어 활발히 사용되고 있습니다. 지금 버젼은 1.0이 나왔으며 아주 쉽게 사용이 가능합니다. buildscripts 항목에 다음을 추가합니다. 

classpath 'org.gradle.api.plugins:gradle-tomcat-plugin:1.0'
    
그리고 configure에 다음 항목을 추가합니다. 

    apply plugin: 'tomcat'

    dependencies {
        String tomcatVersion = '7.0.11'
        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'
        }
    }

위 항목의 추가로 tomcatRun을 실행시킬 수 있습니다. 

3. sonar analysis의 추가

code의 품질을 테스트 하고 code 품질에 대한 지속적인 monitoring을 통해서 project를 발전시키는 것이 가능합니다. 개발자들의 실력 향상에도 도움을 줄 수 있을 뿐 아니라 잠재적인 오류를 찾아내기 위해서라도 sonar analysis는 추가하는 것이 좋습니다. gradle에서는 sonar에 대해서 plugin을 지원하고 있으며, sonar version이 3.4 이하인 경우와 3.5 이상인 경우에 사용되는 plugin이 다름을 주의해야지 됩니다.  이 글은 sonar 3.5이상을 기준으로 구성되었습니다. 

sonar plugin은 언제나 multiple project의 root에 위치해야지 됩니다. 

apply plugin : 'sonar-runner'

그리고, sonar 서버에 대한 설정을 다음과 같이 root project에 넣어줍니다. 

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#$'
    }
}

마지막으로 subprojects에 sonar가 해석할 code의 character set을 지정해주면 됩니다. 

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

다만, 이렇게 한 경우 code coverage가 나오지 않습니다. 그 이유는 sonar-runner의 경우, jacoco를 가지고 있지 않습니다. 따라서, jacoco 설정을 추가해야지 됩니다. 

jacoco는 각 subproject들에 설정이 되어야지 되기 때문에 subprojects 안에 다음 plug in을 설정합니다. 

apply plugin : 'jacoco'

그리고, jacoco 설정을 넣습니다. jacoco결과를 sonar에서 해석하기 위한 exec 파일과 classdump 파일의 위치를 결정해야지 됩니다. sonar-runner는 기본적으로 ${projectDir}/target/jacoco.exec 에 있는 output 파일을 읽습니다. 현재 sonar-runner의 output 파일 load 경로를 바꾸는 방법은 없습니다. 따라서 jacoco plugin의 설정을 반드시 ${projectDir}/target/jacoco.exec로 바꿔줘야지 됩니다.  test를 행할때, 반드시 jacoco가 실행이 되도록 다음과 같이 설정합니다. 또한 output 파일 경로를 target/jacoco.exec로 변경시켜줍니다.

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

이제 gradle sonarRunner를 실행하면 sonar에 결과를 모두 표시하는 것을 볼 수 있습니다. 


4. jenkins 등록

gradle project의 경우 jenkins에서는 plugin 형태로 build process를 지원하고 있습니다. plugin 항목에서 gradle을 추가해야지 됩니다.

1) project type은 free-style software project로 등록합니다.

2) build는 invoke Gradle script로 수행합니다. 
* system에 gradle이 이미 설치가 되어 있어야지 됩니다.
* task는 sonarRunner로 등록

3) Test 결과를 jenkins에 등록합니다. sonar를 통해서도 보는 것이 가능하지만, jenkins에서 간단한 정보와 trend는 확인이 가능합니다. 더 자세한 정보를 원한다면 sonar를 통해 확인하면 더 좋습니다. 

a. findbugs analysis results : sonar에서는 ${rootProject}/build/sonar/${subProject}/findbugs-result.xml 로 findbugs result를 기록합니다. 파일 타잎을 **/findbugs-result.xml 로 지정해주면 됩니다. 


b. JUnit test 결과 등록 : sonar의 JUnit test 결과는 ${project}/build/test-reports/TEST-{testClassName}.xml 형태로 저장되어 있습니다. XMLs 항목을 **/TEST-*.xml 로 지정해주면 모든 test결과를 jenkins에 기록합니다.


c. JaCoCo coverage report 등록 : jacoco의 결과값은 위 설정에서 target/jacoco.exec로 이미 지정되어 있습니다. 각각의 project의 source code path만 조정하면 됩니다.
* Path to exec files : **/**.exec
* Path to class directories : **/classes/main
* Path to source directories : **/src/main/java
* Exclusions : **/classes/test


위와 같이 등록하면 이제 jenkins에서 모든 결과를 확인하고 볼 수 있습니다.




5. hot deploy의 지원

작성된 war를 개발자의 PC에서 테스트 환경 또는 배포환경으로 배포하는 과정을 지원하면 java로 만든 web project의 build과정 및 deploy과정이 모두 마쳐지게 됩니다. 
이 부분에 있어서 gradle은 따로 제공되고 있는 것이 없습니다. 대신 cargo plugin을 이용해서 hot deploy를 할 수 있습니다. cargo plugin은 지금 github에서 지원되고 있습니다.

먼저, 외부 library를 사용하기 때문에 buildscript에 다음을 추가해야지 됩니다. 

buildscript {
    repositories {
        mavenCentral()
    }

    dependencies {
        classpath 'org.gradle.api.plugins:gradle-cargo-plugin:0.6.1'
    }
}

다음 war로 묶일 subproject의 configure에 plugin을 추가합니다. 

apply plugin: 'cargo'

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

여기서, dependencies에 cargo는 cargo에서만 임시로 사용될 dependency입니다. war의 providedCompile과 동일합니다. 

hot deploy될 remote server의 정보를 적어줍니다. 

cargo {
    containerId = 'tomcat7x'
    port = 8080

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

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

cargo는 tomcat, jetty, weblogic 등 유수의 web container를 지원합니다. 지원하는 container마다 Id를 부여하고 있으며, 아이디들은 https://github.com/bmuschko/gradle-cargo-plugin 에서 확인 가능합니다. 

이제 다음 명령어를 통해서 tomat에 deploy가 가능합니다. 

gradle cargoRemoteDeploy

기존에 Deploy가 되었다면 재배포가 이루어져야지 되기 때문에 
gradle cargoRemoteRedeploy
를 이용해서 배포가 가능합니다. 

추가로, cargo는 war로 만드는 작업을 하지 않습니다. war로 묶는 작업이 없기 때문에 war가 없으면 에러를 발생합니다. war task와 dependency를 잡아주기 위해서 다음 코드를 추가하면 더 편합니다. 

    cargoRedeployRemote {
        dependsOn war
    }

    cargoDeployRemote {
        dependsOn war
    }

이제 gradle을 이용한 java web project의 build의 전체 과정을 할 수 있는 script가 완성되었습니다. build를 관리하고, 자동화 하는 것은 개발자들이 보다 더 개발에 집중하고 나은 결과를 내기 위해서입니다. 또한 관리의 목적도 있습니다. 관리 및 공유의 목적을 가장 잘 달성하기 위한 방법으로 저는 개인적으로는 아래와 같이 생각합니다.

1. IDE 관련 파일은 SCM에 upload하지 않습니다. 지금 만들어진 script를 이용하면 eclipse, intelliJ 모두의 IDE load file을 만들어낼 수 있습니다. 각자의 개발환경이 겹치게 되면 개발에 어려움을 가지고 오게 됩니다. 개발에 필요하고 환경에 undependency한 file들만 올리는 것이 필요합니다.
2. jenkins와 같은 CI tool을 svn commit과 연동하지 않습니다. 연동 후, commit이 발생할 때마다 code coverage와 같은 report를 commit당으로 만들 필요는 없습니다. 시간을 정해두고 하는 CI가 더 효율적이라고 생각됩니다.

마지막으로 전체 script는 다음과 같습니다.

apply plugin: 'base'
apply plugin: 'sonar-runner'

import com.sahlbach.gradle.plugins.jettyEclipse.*

buildscript {
    repositories {
        jcenter()
        maven { url = 'http://dl.bintray.com/khoulaiz/gradle-plugins' }
    }
    dependencies {
        classpath (group: 'com.sahlbach.gradle', name: 'gradle-jetty-eclipse-plugin', version: '1.9.+')
        classpath 'org.gradle.api.plugins:gradle-tomcat-plugin:1.0'
        classpath (group: 'org.gradle.api.plugins', name: 'gradle-cargo-plugin', version: '0.6.+')
    }
}

ext {
    javaVersion = 1.7
}

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#$'
    }
}

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

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

    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]
        }
    }

    repositories {
        mavenCentral()
    }

    dependencies {
        compile 'org.slf4j:slf4j-api:1.7.5'
        testCompile "junit:junit:4.11"
    }
    
    sourceCompatibility = rootProject.ext.javaVersion
    targetCompatibility = rootProject.ext.javaVersion

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

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

    String configPath = project.rootProject.projectDir.absolutePath.toString() + "/config/jetty/webdefault.xml"

    dependencies {
        String tomcatVersion = '7.0.11'
        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"
    }

    jettyEclipse {
        automaticReload = true
        webDefaultXml = file(configPath)
    }

    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#$'
        }
    }
}


모두 Happy codiing!!

모든 프로젝트는 GitHub에 올려뒀습니다. 참고하실 분들은 참고해주세요. ^^


Posted by Y2K
,
code의 품질을 높이기 위해서 자주 쓰이는 방법중 하나는 static java code analysis를 통해서 code의 품질을 평가하는 겁니다. 

자주 쓰이는 방법으로는 checkstyle, jdepend, PMD, findbugs 등이 있고, 이러한 방법들을 체계적으로 잘 관리해주는 tool로는 sonar가 있습니다. 

1. checkstyle

apply plugin : 'checkstyle'

을 사용해서 checkstyle을 사용할 수 있습니다. checkstyle은 사용되는 check rule에 대한 xml이 반드시 존재해야지 되며, 이는 다음과 같이 설정이 가능합니다. 

checkstyle {
    configFIle = file('config/checkstyle/sun_checks.xml')
}

그리고, checkstyle의 결과는 xml 파일로 나오게 되며 이 결과는 jenkins, hudson 등의 CI tool에서 결과를 확인하는데 사용하게 됩니다. 
report path는 기본적으로 build/checkstyle 폴더에 위치하게 되며, 이 폴더의 위치와 xml 파일의 이름은 다음과 같이 설정하면 됩니다. 

checkstyle {
    reportDir = file("${buildDir}/checkstyle-output")
}
checkStyleMain {
    reports {
       xml.destination = file("${checkstyle.reportsDir}/checkstyle.xml")
     }
}

gradle에서 사용하기 위해서는 gradle checkStyleMain task를 구동하면 checkStyle 검사를 행하게 됩니다. 

2. PMD

PMD는 code style에서 발생할 수 있는 error들을 검증하는 방법입니다. checkstyle은 coding에 있어서 style만을 본다면 PMD는 io에 대한 close check, exception 등에 대한 정확한 handling이 되었는지 판별하게 됩니다. 대체적으로 PMD는 경험적인 rule이 만들어진 결과입니다. 나중에 보실 findbugs와는 조금 다른 code analysis를 보여줍니다.

PMD는 pmd plugin을 설치해서 사용 가능합니다. 

apply plugin : 'pmd'

pmd의 결과는 기본적으로 html format으로 나오게 됩니다. 또한 결과를 xml로도 볼수 있는데, xml결과는 후에 jenkins와 같은 CI tool에서 이용이 가능합니다. 이에 대한 설정은 다음과 같이 해주면 됩니다. 

pmdMain {
     reports {
          xml.destination = file("${pmd.reportsDir}/pmd.xml")
          html.enabled = false
          xml.enabled = true
     }
}


3. findbugs


findbugs는 정형적인 버그를 발견할 수 있는 tool입니다. findbugs를 이용하면 버그가 발생할 수 있는 상황을 확인하는 것이 가능합니다. type의 변환시에 값의 크기가 작아서 오작동을 일으킨다던지, 무한 loop를 돌 수 있는 pattern을 찾아낼 수 있는 방법들을 제공합니다. 

findbugs는 findbugs plugin을 설치해서 사용합니다. 


apply plugin: 'findbugs'


역시 findbugs도 PMD와 checkstyle과 같이 xml/html의 결과를 같이 보여주게 됩니다. report에 대한 설정은 다음과 같습니다. 

findbugsMain {
     reports {
          xml.enabled = true
          html.enabled = false
          xml.destination = file("${findbugs.reportsDir}/findbugs.xml")
     }
}


multi project에서 checkstyle, PMD, findbugs의 이용

multi project의 경우에는 위의 설정이 모두 subprojects에 들어가면 됩니다. 그렇지만, 위의 설정대로라면 조금 문제가 발생하게 됩니다. 각 subproject들의 checkstyle, PMD, findbugs에 warning이 발견되면 즉시 build가 중지되기 때문입니다. 따라서, 각 plugin들에 failure가 발생되더라도 계속해서 진행하는 option을 걸어줘야지 됩니다. 
또한, 각 static code analysis의 결과를 모아서 볼 필요가 있습니다. 각각의 결과들을 깊은 depth를 갖는 folder명에서 보는 것은 조금 힘듭니다. 따라서 root project가 report 결과를 모으는 작업을 할 필요가 존재합니다. 

output folder에 모든 report의 결과는 다음과 같이 표시하면 됩니다. 


ext {
     checkStylePath = file('config/checkstyle.xml')
     reportPath = file('output/report');
     reportPathA = reportPath.absolutePath.toString()
}

if(!ext.reportPath.exists()) { // 폴더가 없는 경우에 신규 생성
     ext.reportPath.mkdir()
}


subprojects {

     apply plugin: 'java'
     apply plugin: 'checkstyle'
     apply plugin: 'pmd'
     apply plugin: 'findbugs'

     sourceCompatibility = 1.7
     targetCompatibility = 1.7

     checkstyle {
          configFile = rootProject.ext.checkStylePath
          ignoreFailures = true
     }

     pmd {
          ignoreFailures = true
     }

     findbugs {
          ignoreFailures = true
     }

     findbugsMain {
          reports {
               xml.enabled = true
               html.enabled = false
               xml.destination = file("${rootProject.ext.reportPathA}/findbug/${project.name}.xml")
          }
     } 

     pmdMain {
          reports {
               xml.destination = file("${rootProject.ext.reportPathA}/pmd/${project. name}.xml")
               html.enabled = false
          }
     }

     checkstyleMain {
          reports {
               xml.enabled = true
               xml.destination = file("${rootProject.ext.reportPathA}/checkstyle/ ${project.name}.xml")
               // html.enabled = false
          }
     }

}


위와 같은 build script를 이용하면 다음과 같이 한개의 폴더 안에 잘 정리된 코드 분석 결과를 얻어낼 수 있습니다.






Posted by Y2K
,
queryDSL을 이용한 java project의 경우에는 gradle로는 조금 과정이 복잡하게 됩니다. 

queryDSL의 경우에는 pre-compile 과정이라고 해서, 기존의 domain entity 들을 이용한 Q class들을 생성해야지 됩니다.
이런 Q class들을 생성하는 build 과정을 먼저 거친 다음에, 기존의 class들을 compile시키는 과정을 거쳐야지 됩니다. 

따라서, compileJava보다 먼저 선행되는 작업이 반드시 필요하게 됩니다. 

먼저, generate될 code가 위치할 곳을 지정해줍니다. 

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

그리고, 새로운 task를 생성시켜줍니다. 
task generateQueryDSL(type: JavaCompile, group: 'build') {
     source = sourceSets.main.java
     classpath = configurations.compile
     options.compilerArgs = [
          "-proc:only",
          "-processor", "com.mysema.query.apt.jpa.JPAAnnotationProcessor"
     ]
     destinationDir = sourceSets.generated.java.srcDirs.iterator().next()
}

위 코드는 compile type의 code라는 것을 먼저 생성시켜주고, 원 source와 Q code를 생성해주는 pre processor를 지정해주는 코드입니다. 마지막줄에서 code를 어느곳에 생성할지를 결정하고 있습니다.
그리고, compileJava에 종속성을 주입해야지 됩니다. 기존에 java plugin을 사용하고 있기 때문에, task를 새로 생성하는 것이 아닌 overwrite 시키는 개념으로 접근하면 됩니다. 

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

이 두 task를 통해서, 기존의 compileJava의 경우 반드시 generateQueryDSL task를 실행 후, 동작하게 되고 source는 generateQueryDSL.destinationDir을 참고해서 compile을 행하게 됩니다. 또한 compileGeneratedJava task 역시 generateQueryDSL task를 실행하고, compile 된 객체들을 같이 이용해서 jar를 만들어주는 cycle을 통하게 됩니다. 


마지막으로, project를 모두 clean 시키기 위한 코드 역시 수정이 필요합니다. generated 되는 코드들도 같이 지워주기 위해서는 clean task를 다음과 같이 재정의하는 것이 좋습니다. 

clean {
     delete sourceSets.generated.java.srcDirs
}

다음은 build.gradle 파일의 전체입니다. 참고해보시길 바랍니다. 

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

dependencies {
    def querydslVersion = '3.2.4'
    def slf4jVersion = "1.7.2"

    def queryDSL = '3.2.4'
    compile project(':common')
    compile group: 'mysql', name: 'mysql-connector-java', version:'5.1.22'
    compile group: 'com.jolbox', name: 'bonecp', version:'0.7.1.RELEASE'
    compile group: 'com.google.guava', name: 'guava', version:'14.0'
    compile group: 'org.springframework', name: 'spring-core', version:'3.2.3.RELEASE'
    compile group: 'org.springframework', name: 'spring-orm', version:'3.2.3.RELEASE'
    compile group: 'org.springframework', name: 'spring-tx', version:'3.2.3.RELEASE'
    compile group: 'org.springframework', name: 'spring-context', version:'3.2.3.RELEASE'
    compile group: 'org.springframework', name: 'spring-context-support', version:'3.2.3.RELEASE'
    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'

    compile group: 'com.mysema.querydsl', name: 'querydsl-core', version:"$queryDSL"
    compile group: 'com.mysema.querydsl', name: 'querydsl-jpa', version:"$queryDSL"
    compile group: 'com.mysema.querydsl', name: 'querydsl-sql', version:"$queryDSL"

    compile "com.mysema.querydsl:querydsl-apt:$queryDSL"
    provided 'org.projectlombok:lombok:0.12.0'

    compile "org.slf4j:jcl-over-slf4j:$slf4jVersion"
    compile "org.slf4j:jul-to-slf4j:$slf4jVersion"

    provided "com.mysema.querydsl:querydsl-apt:$querydslVersion"
    provided 'org.projectlombok:lombok:0.12.0'
}

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')
    }
}


gradle build를 실행하면 이제 Q class들이 모두 compile 되고, jar 안에도 역시 Q class들이 모두 위치한 것을 알 수 있습니다. 



Posted by Y2K
,

gradle 정리 - code analysis

Java 2013. 10. 28. 13:07
code의 품질을 높이기 위해서 자주 쓰이는 방법중 하나는 static java code analysis를 통해서 code의 품질을 평가하는 겁니다. 

자주 쓰이는 방법으로는 checkstyle, jdepend, PMD, findbugs 등이 있고, 이러한 방법들을 체계적으로 잘 관리해주는 tool로는 sonar가 있습니다. 

1. checkstyle

apply plugin : 'checkstyle'

을 사용해서 checkstyle을 사용할 수 있습니다. checkstyle은 사용되는 check rule에 대한 xml이 반드시 존재해야지 되며, 이는 다음과 같이 설정이 가능합니다. 

checkstyle {
    configFIle = file('config/checkstyle/sun_checks.xml')
}

그리고, checkstyle의 결과는 xml 파일로 나오게 되며 이 결과는 jenkins, hudson 등의 CI tool에서 결과를 확인하는데 사용하게 됩니다. 
report path는 기본적으로 build/checkstyle 폴더에 위치하게 되며, 이 폴더의 위치와 xml 파일의 이름은 다음과 같이 설정하면 됩니다. 

checkstyle {
    reportDir = file("${buildDir}/checkstyle-output")
}
checkStyleMain {
    reports {
       xml.destination = file("${checkstyle.reportsDir}/checkstyle.xml")
     }
}

gradle에서 사용하기 위해서는 gradle checkStyleMain task를 구동하면 checkStyle 검사를 행하게 됩니다. 

2. PMD

PMD는 code style에서 발생할 수 있는 error들을 검증하는 방법입니다. checkstyle은 coding에 있어서 style만을 본다면 PMD는 io에 대한 close check, exception 등에 대한 정확한 handling이 되었는지 판별하게 됩니다. 대체적으로 PMD는 경험적인 rule이 만들어진 결과입니다. 나중에 보실 findbugs와는 조금 다른 code analysis를 보여줍니다.

PMD는 pmd plugin을 설치해서 사용 가능합니다. 

apply plugin : 'pmd'

pmd의 결과는 기본적으로 html format으로 나오게 됩니다. 또한 결과를 xml로도 볼수 있는데, xml결과는 후에 jenkins와 같은 CI tool에서 이용이 가능합니다. 이에 대한 설정은 다음과 같이 해주면 됩니다. 

pmdMain {
     reports {
          xml.destination = file("${pmd.reportsDir}/pmd.xml")
          html.enabled = false
          xml.enabled = true
     }
}


3. findbugs

findbugs는 정형적인 버그를 발견할 수 있는 tool입니다. findbugs를 이용하면 버그가 발생할 수 있는 상황을 확인하는 것이 가능합니다. type의 변환시에 값의 크기가 작아서 오작동을 일으킨다던지, 무한 loop를 돌 수 있는 pattern을 찾아낼 수 있는 방법들을 제공합니다. 

findbugs는 findbugs plugin을 설치해서 사용합니다. 


apply plugin: 'findbugs'


역시 findbugs도 PMD와 checkstyle과 같이 xml/html의 결과를 같이 보여주게 됩니다. report에 대한 설정은 다음과 같습니다. 

findbugsMain {
     reports {
          xml.enabled = true
          html.enabled = false
          xml.destination = file("${findbugs.reportsDir}/findbugs.xml")
     }
}


multi project에서 checkstyle, PMD, findbugs의 이용

multi project의 경우에는 위의 설정이 모두 subprojects에 들어가면 됩니다. 그렇지만, 위의 설정대로라면 조금 문제가 발생하게 됩니다. 각 subproject들의 checkstyle, PMD, findbugs에 warning이 발견되면 즉시 build가 중지되기 때문입니다. 따라서, 각 plugin들에 failure가 발생되더라도 계속해서 진행하는 option을 걸어줘야지 됩니다. 
또한, 각 static code analysis의 결과를 모아서 볼 필요가 있습니다. 각각의 결과들을 깊은 depth를 갖는 folder명에서 보는 것은 조금 힘듭니다. 따라서 root project가 report 결과를 모으는 작업을 할 필요가 존재합니다. 

output folder에 모든 report의 결과는 다음과 같이 표시하면 됩니다. 

ext {
     checkStylePath = file('config/checkstyle.xml')
     reportPath = file('output/report');
     reportPathA = reportPath.absolutePath.toString()
}

if(!ext.reportPath.exists()) { // 폴더가 없는 경우에 신규 생성
     ext.reportPath.mkdir()
}


subprojects {

     apply plugin: 'java'
     apply plugin: 'checkstyle'
     apply plugin: 'pmd'
     apply plugin: 'findbugs'

     sourceCompatibility = 1.7
     targetCompatibility = 1.7

     checkstyle {
          configFile = rootProject.ext.checkStylePath
          ignoreFailures = true
     }

     pmd {
          ignoreFailures = true
     }

     findbugs {
          ignoreFailures = true
     }

     findbugsMain {
          reports {
               xml.enabled = true
               html.enabled = false
               xml.destination = file("${rootProject.ext.reportPathA}/findbug/${project.name}.xml")
          }
     } 

     pmdMain {
          reports {
               xml.destination = file("${rootProject.ext.reportPathA}/pmd/${project. name}.xml")
               html.enabled = false
          }
     }

     checkstyleMain {
          reports {
               xml.enabled = true
               xml.destination = file("${rootProject.ext.reportPathA}/checkstyle/ ${project.name}.xml")
               // html.enabled = false
          }
     }

}


위와 같은 build script를 이용하면 다음과 같이 한개의 폴더 안에 잘 정리된 코드 분석 결과를 얻어낼 수 있습니다.


Posted by Y2K
,
일반적인 개발환경에서 한개의 Project만으로 구성되는 경우는 거의 없습니다. 
간단히 다음과 같은 구조를 알아보도록 하겠습니다. 

bookstore
- common             : dependency none
- commonTest       : dependenced common
- domain               : dependenced common, test dependenced commonTest
- publicWeb           : dependenced domain, common, test dependenced commonTest
- privateWeb          : dependenced domain, common, test dependenced commonTest

이와 같은 '간단한' 프로젝트 구성이 있다고 할 때, 이런 구성에 있어서 기존 maven은 multiple project를 구성해서, 선행 project들을 local repository 등에 배포 후, dependency를 얻어와야지 되는 문제가 발생한다. 또한 각각의 project를 통해 생성되는 xml, wsdl등을 만약에 다른 project에서 이용한다면 maven에서는 매우 힘든 일을 거쳐야지 된다. (하는 방법은 잘 모르겠습니다.)

Gradle은 이와 같은 문제를 project dependency를 이용해서 쉽게 해결하고 있습니다. 이런 subproject들을 가진 project의 구성은 project의 folder와 동일하게 구성됩니다.


이러한 folder구조를 만들어주고, root project에서 gradle setupBuild 를 실행시켜줍니다.
지금까지 주로 사용하고 있던 build.gradle 파일뿐 아니라, sub project가 있는 부분은 settings.gradle 파일을 이용해서 구성합니다. 

settings.gradle파일안에 다음과 같이 sub projects들을 모두 추가해줍니다.

rootProject.name = 'GradleBookStore'
include 'common', 'commonTest', 'domain', 'privateWeb', 'publicWeb'
include 시에 순서는 상관이 없습니다. 또는 다음과 같은 코드를 이용해서 처리가 가능합니다. 

rootProject.name = 'GradleBookStore'
String[] modules = ['common', 'commonTest', 'domain', 'privateWeb', 'publicWeb']
include modules

GradleBookStore project를 rootProject라고 칭하고, 나머지 project들은 모두 subproject라고 칭합니다. 


1. 공통 설정

rootProject를 비롯한 모든 project에 필요한 설정이 필요할 때가 있습니다. maven repository설정 및 모든 공용 library들이 바로 여기에 속하겠지요. 
모든 project에서 사용될 설정은 다음과 같이 수정합니다. 

allprojects {
    apply plugin: 'java'    
    repositories {
        mavenCentral()
    }

    dependencies {
        compile 'org.slf4j:slf4j-api:1.7.5'
        testCompile "junit:junit:4.11"
    }
}

allprojects로 설정이 들어가게 되면, 모든 project들은 위 속성을 모두 가지게 됩니다. 이는 모든 sub project 폴더 안에 build.gradle 파일을 만들어주고, 위 내용을 적어주는 것과 동일한 효과를 가지고 옵니다. 

2. SubProject 설정

위 공통설정과 비슷하지만 하나의 차이가 있습니다. 공통 설정으로 넣는 경우, root project 역시 java project로 구성이 됩니다. root project 역시 코드를 가질 수 있는 구조가 되게 됩니다. 그런데 이와 같은 구조는 현업에서는 잘 사용되지 않는 편입니다. root project의 경우에는 project의 이름으로 만들어주고, 내부에 subproject들로 구성이 되는 것이 일반적이지요. 

따라서, 대부분의 project들은 allprojects 보다는 subprojects를 주로 사용하게 되는 것이 일반적입니다. 

3. 특정 project 설정

특정 project에 설정을 해주는 방법은 두가지가 있습니다. 

1) subproject의 build.gradle을 작성해주는 것
2) root project의 build.gradle에서 configure를 이용하는 방법

첫번째 방법의 경우, 매우 단순합니다. 그냥 build.gradle 파일을 만들어서 넣어주면 됩니다. 그렇지만, root project의 build.gradle에서 configure를 이용하는 방법은 조금 생각을 해봐야지 됩니다. configure의 경우에는 약간 이름과 다르게, filtering을 지원하는 방법입니다. 특정 project에서 사용할 설정을 넘겨주는 것을 담당합니다. 지금 구성되어 있는 project 중에서 publicWeb, privateWeb의 경우 war plugin을 이용해서 web으로 구성할 예정입니다. 이 경우에는 다음과 같이 구성을 하면 됩니다. 

task webProjects << {
    subprojects.findAll {
        project.project.name.endsWith('Web')
    }
}

configure(webProjects) {
    apply plugin: 'war'
    task isWar << {
        println "${project.name} is web project"
    }
}

project의 이름이 Web으로 끝나는 project에서만 war plugin을 적용한 코드입니다. task를 작성해서, target이 되는 project가 무엇인지를 확인시켜줘야지 됩니다. groovy에서는 마지막에 실행된 내용이 return 값이기 때문에 전체 build가 실행이 될때, subproject중에서 이름이 'Web'으로 끝나는 project만이 configure에 있는 속성이 적용됩니다. 

이 부분을 잘 사용한다면, android project에서 target project의 모든 classes 파일들을 test project의 classes 폴더로 copy 해와서 처리를 하는 것이 가능하게 됩니다. 

4. Project간의 종속성 설정

Gradle에서 가장 강력한 기능중 하나입니다. project간의 종속성을 설정은 기존 maven에 비하여 가장 강력한 기능입니다. 
Project의 종속성은 만들어진 jar를 상대 project에서 이용하게 됩니다. 그리고, 하나의 Project에만 사용되는 설정이기 때문에, 이는 각 subproject의 build.gradle 파일을 이용합니다. 

위에서 이야기드린 것 처럼, 각각의 dependency를 다시 보면 다음과 같습니다. 

bookstore
- common             : dependency none
- commonTest       : dependenced common
- domain               : dependenced common, test dependenced commonTest
- publicWeb           : dependenced domain, common, test dependenced commonTest
- privateWeb          : dependenced domain, common, test dependenced commonTest

* common의 경우에는 dependecy가 없기 때문에 build.gradle의 내용이 없습니다.

domain jar를 gradle :domain:build를 통해 build 할때, 다음과 같은 코드가 동작합니다.

c:\workspace\GradleBookStore>gradle :domain:build
:common:compileJava UP-TO-DATE
:common:processResources UP-TO-DATE
:common:classes UP-TO-DATE
:common:jar UP-TO-DATE
:domain:compileJava UP-TO-DATE
:domain:processResources UP-TO-DATE
:domain:classes UP-TO-DATE
:domain:jar UP-TO-DATE
:domain:assemble UP-TO-DATE
:commonTest:compileJava UP-TO-DATE
:commonTest:processResources UP-TO-DATE
:commonTest:classes UP-TO-DATE
:commonTest:jar UP-TO-DATE
:domain:compileTestJava UP-TO-DATE
:domain:processTestResources UP-TO-DATE
:domain:testClasses UP-TO-DATE
:domain:test UP-TO-DATE

:domain:check UP-TO-DATE
:domain:build UP-TO-DATE


compile시에 필요한 project가 먼저 build가 되고, 그걸 이용해서 새로운 jar가 만들어지고 있는 것을 알 수 있습니다. 
특정 project만을 처리하는 것 역시 가능합니다. 기존 maven에서 문제가 되던 project -> local repository -> another project 의 build문제를 해결하고 있음을 알 수 있습니다.



Posted by Y2K
,