2021년 8월 17일 화요일

토이프로젝트 API 개선하기 (REST 하게..)

 

  • 토이프로젝트에 생성한 api는 REST 하지 못한 것 같다. (단순 json 응답임)

    • REST하게 개선해보자 (백기선님의 REST API 강의 참고)

      // 기존 소스
      @RequiredArgsConstructor
      @RestController
      @Slf4j
      @RequestMapping(value = "/api/board")
      public class BoardApiController {
          private final BoardService boardService;
      
      
              @PostMapping
              public ResponseEntity addBoard(@RequestBody BoardRequestDto boardRequestDto, @LoginUser SessionUser user) {
                  Map<String, Object> resultMap = new HashMap<>();
      
      
                  Long result = boardService.save(boardRequestDto, user.getEmail());
      
      
                  resultMap.put("result", result);
                  resultMap.put("resultMessage", "success");
      
      
                  return new ResponseEntity<>(resultMap, HttpStatus.OK);
              }
              ...
      }
      

REST API

  • REpresentational State Transfer(REST) + Application Programming Interface(API)

  • 서로 다른 시스템간의 독립적인 진화를 위함

  • REST 아키텍처 스타일을 따르는 API

    • Client-Server, Cache, Stateless, Layered System, ...

    • Uniform Interface : URL로 지정된 리소스에 대한 조작을 통일하고 한정된 인터페이스로 수행하는 아키텍쳐 스타일

      • Indentification of resources, ...

      • Hypermedia as the Engine of Application State(HATEOAS)

        • 하이퍼미디어(링크)를 통해 애플리케이션 상태 변화가 가능해야한다.

        • 링크 정보를 동적으로 바꿀 수 있다.(Versioning 할 필요 없이!)

        • 응답을 받은 다음에 다음 애플리케이션 상태로 전이를 하려면 서버가 보내준 응답에 들어있는 링크정보를 사용해서 이동을 해야한다.

        • HATEOAS 로 만들려면?

          • HAL을 통해 링크를 정의 (다른 방법도 있음)
      • self-descriptive message

        • 메시지 스스로 메시지에 대한 설명이 가능해야함

        • 서버가 변해도 클라이언트는 그 메시지를 보고 해석이 가능하다

        • 확장 가능한 커뮤니케이션

        • self-descriptive message로 만들려면?

          • HAL의 링크 데이터에 profile 링크 추가 (다른 방법도 있음)

HAL ?

  • Hypertext Application Language

  • API의 리소스 간에 일관되고 쉬운 하이퍼링크 방법을 제공하는 간단한 형식

  • REST API에 HAL을 포함하면 기본적으로 자체 문서화될 뿐만 아니라 사용자가 훨씬 더 쉽게 탐색할 수 있음

Spring HATEOAS ?

  • 스프링 프로젝트 중 하나로 주요 목적은 API를 만들 때 리소스를 REST 하게 쉽게 제공해주기 위한 툴을 편리하게 사용할 수 있게 해주는 라이브러리

  • 하이퍼미디어를 사용해서 클라이언트가 애플리케이션 서바와 동적으로 정보를 주고 받을 수 있는 방법

  • 여러 기능을 제공하는데 가장 중요한 기능은 링크를 만드는 기능, 리소스(응답+링크)를 만드는 기능임

    • 링크
      • HREF
      • REL (현재 릴레이션과의 관계)
        • self
        • profile
        • ... (조회, 업데이트 등 링크)

Spring Rest Docs ?

  • Spring MVC Test를 사용해서 REST API 문서의 조각들을 생성하는데 유용한 툴

    • 테스트에 사용된 정보가 문서로 생성됨
  • 이 문서 조각을 모아서 REST API 가이드 문서를 생성할 수 있음 (html)


실습

Spring HATEOAS

  • 백기선님의 REST API 강의에서는 Resource 사용하였지만 Spring HATEOAS 버전 변경에 따라 이름이 변경됨

  • Resource를 사용하여 구성한 body를 EntityModel 으로 바꿔보자

    • 강의 예제 소스

      public class EventResource extends Resource<Event> {
          public EventResource(Event event, Link... links){
              super(event, links);
              add(linkTo(EventController.class).slash(event.getId()).withSelfRel());
          }
      }
      
      
      ...
      
      
      @PostMapping
      public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto,
                                        Errors errors, @CurrentUser Account currentUser){
      
      
          // @Valid를 통해 검증한 에러들을 errors에 담고 검증하여 badRequest
          if(errors.hasErrors()){
              return badRequest(errors);
          }
          // validate를 통해 검증한 에러들을 errors에 담고 검증하여 badRequest
          eventValidator.validate(eventDto, errors);
          if(errors.hasErrors()){
              return badRequest(errors);
          }
      
      
          Event event = modelMapper.map(eventDto, Event.class);
          event.update();
          event.setManager(currentUser);
      
      
          Event newEvent = this.eventRepository.save(event);
          ControllerLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(newEvent.getId());
          URI createUri = selfLinkBuilder.toUri();
          EventResource eventResource  = new EventResource(event);
          eventResoruce.add(linkTo(EventController.class).withRel("query-events"));
          eventResoruce.add(selfLinkBuilder.withRel("update-event"));
          eventResoruce.add(new Link("/docs/index.html#resources-events-create").withRel("profile"));
      
      
          return ResponseEntity.created(createUri).body(eventResource);
      }
      
    • 예제를 참고하여 개선한 토이프로젝트 소스

      @PostMapping
      public ResponseEntity addBoard(@RequestBody BoardRequestDto boardRequestDto, @LoginUser SessionUser user, Errors errors) {
      
      
          boardValidator.validate(boardRequestDto, errors);
          if(errors.hasErrors()){
              log.debug("잘못된 요청입니다. error: " + errors.getFieldError());
              return ResponseEntity.badRequest().body(CollectionModel.of(errors.getAllErrors()));
          }
      
      
          Long result = boardService.save(boardRequestDto, user.getEmail());
          boardRequestDto.setId(result);
      
      
          WebMvcLinkBuilder webMvcLinkBuilder = linkTo(BoardApiController.class).slash(result);
      
      
          // 링크 제공
          EntityModel<BoardRequestDto> entityModel = EntityModel.of(boardRequestDto);
          // profile
          entityModel.add(linkTo(IndexController.class).slash("/docs/index.html#resources-add-board").withRel("profile"));
          // self
          entityModel.add(webMvcLinkBuilder.withSelfRel());
          // update
          entityModel.add(webMvcLinkBuilder.withRel("update-board"));
      
      
          return ResponseEntity.created(webMvcLinkBuilder.toUri()).body(entityModel);
      }
      
    • response body

      • _links 를 통해 다양한 상태의 링크를 서버가 제공한다. (HATEOAS)
      {
          "id":30,
          "boardName":"boardName1",
          "delFlag":"N",
          "_links":{
              "profile":{
                  "href":"http://localhost:8080/docs/index.html#resources-add-board"
              },
              "self":{
                  "href":"http://localhost:8080/api/board/30"
              },
              "update-board":{
                  "href":"http://localhost:8080/api/board/30"
              }
          }
      }
      

Spring Rest Docs

  • 문서조각 만들기

    • build.gradle 에 다음을 추가하고 test 코드에 document 관련 코드를 추가하면 문서 조각이 생성된다.

      dependencies {
              ...
              testImplementation('org.springframework.restdocs:spring-restdocs-mockmvc')
              ...
      }
      
      @SpringBootTest
      @Transactional
      @AutoConfigureMockMvc
      @AutoConfigureRestDocs
      @ActiveProfiles("test")
      class BoardApiControllerTest {
      
      
      @Test
      @DisplayName("보드 등록 api 테스트")
      public void addBoardApiTest() throws Exception{
          //given
          BoardRequestDto boardRequestDto = new BoardRequestDto("boardName1");
      
      
          User user = new User("testUser1", "email", "picture", Role.ADMIN);
          userRepository.save(user);
      
      
          // 세션 정보
          SessionUser sessionUser = new SessionUser(user);
          mockHttpSession = new MockHttpSession();
          mockHttpSession.setAttribute("user", sessionUser);
      
      
          //when then
          mockMvc.perform(
                  post("/api/board")
                          .session(mockHttpSession)
                          .contentType(MediaType.APPLICATION_JSON_UTF8)
                          .accept(MediaTypes.HAL_JSON)
                          .content(objectMapper.writeValueAsString(boardRequestDto))
          )
                  .andDo(print())
                  .andExpect(status().is2xxSuccessful())
                  .andExpect(jsonPath("_links.update-board").exists())
                  .andDo(document("create-board", 
                          links(
                                  linkWithRel("profile").description("link to profile"),
                                  linkWithRel("self").description("link to self"),
                                  linkWithRel("update-board").description("link to update an existing board")
                              ),
                          relaxedResponseFields(
                                  fieldWithPath("id").description("identifier of new Board"),
                                  fieldWithPath("boardName").description("name of new Board"),
                                  fieldWithPath("delFlag").description("delFlag of new Board")
                                  )
                          ));
           }
      }
      
    • 테스트 코드 실행 후 생성된 adoc 파일들 (문서조각)



  • 문서 만들기

    • gradle에 맞는 위치에 종합할 문서 파일을 직접 생성한다.(src/docs/asciidoc) https://spring.io/guides/gs/testing-restdocs/

      The default location for Asciidoctor sources in Gradle is src/doc/asciidoc
      
      • 아까 생성한 조각을 사용할 수 있다!

        include::{snippets}/create-board/curl-request.adoc[]
        


    • 생성한 index.adoc 파일을 html로 변환시켜야한다!! 여러 사이트를 참고해서 build.gradle 을 수정해보자

      • https://github.com/spring-projects/spring-restdocs/blob/main/samples/rest-notes-spring-hateoas/build.gradle

      • https://spring.io/guides/gs/testing-restdocs/

      • https://shinsunyoung.tistory.com/85

        plugins {
            id 'org.springframework.boot' version '2.3.8.RELEASE'
            id 'io.spring.dependency-management' version '1.0.11.RELEASE'
            id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
            id 'org.asciidoctor.convert' version '1.5.3' // 추가
            id 'java'
        }
        
        
        group = 'com.toy'
        version = '0.0.1-SNAPSHOT'
        sourceCompatibility = '11'
        
        
        repositories {
            mavenCentral()
            maven { url "https://plugins.gradle.org/m2/" }
        }
        
        
        subprojects {
            group = 'com.toy'
            version = '0.0.1-SNAPSHOT'
        
        
            apply plugin: 'java'
            apply plugin: 'org.springframework.boot'
            apply plugin: 'io.spring.dependency-management'
            apply plugin: 'com.ewerk.gradle.plugins.querydsl'
            apply plugin: 'org.asciidoctor.convert' // 추가
        
        
            sourceCompatibility = 11
        
        
            repositories {
                mavenCentral()
            }
        
        
            dependencies {
                asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor' // 추가
                compileOnly 'org.projectlombok:lombok'
                annotationProcessor 'org.projectlombok:lombok'
                compile("org.mariadb.jdbc:mariadb-java-client")
        
        
                testImplementation('org.springframework.boot:spring-boot-starter-test') {
                    exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
                }
                testImplementation('org.springframework.restdocs:spring-restdocs-mockmvc')
                testImplementation 'org.springframework.security:spring-security-test'
                developmentOnly 'org.springframework.boot:spring-boot-devtools'
            }
        
        
                // 추가
            ext {
                snippetsDir = "${buildDir}/generated-snippets"
            }
        
        
            configurations {
                compileOnly {
                    extendsFrom annotationProcessor
                }
            }
        
        
            test {
                outputs.dir snippetsDir // 추가
                useJUnitPlatform()
            }
        
        
            // 추가
            asciidoctor {
                inputs.dir snippetsDir
                dependsOn test
            }
        
        
            def querydslDir = "$buildDir/generated/querydsl"
        
        
            querydsl {
                library = "com.querydsl:querydsl-apt"
                jpa = true
                querydslSourcesDir = querydslDir
            }
            sourceSets {
                main {
                    java {
                        srcDirs = ['src/main/java', querydslDir]
                    }
                }
            }
            compileQuerydsl{
                options.annotationProcessorPath = configurations.querydsl
            }
            configurations {
                querydsl.extendsFrom compileClasspath
            }
        
        
        }
        
        
        project(':module-web') {
            dependencies {
                implementation project(path: ':module-domain', configuration: 'default')
            }
        
        
            // 추가
            task copyDocument(type: Copy) {
                dependsOn asciidoctor
        
        
                from file("build/asciidoc/html5/")
                into file("src/main/resources/static/docs")
            }
        
        
            // 추가
            build {
                dependsOn copyDocument
            }
        }
        
        
        project(':module-batch') {
            dependencies {
                implementation project(path: ':module-domain', configuration: 'default')
            }
        }
        
        
        bootJar { enabled = false }
        
      • gradle build 후 다음 경로에 html 파일이 생성된 것을 볼 수 있으며 profile에 해당 문서를 링크하면 API에 대한 정보, 명세 등을 볼 수 있고, 스스로 설명할 수 있다! (self-descriptive message)


                    

            
[간단히 생성한 html 파일]
                  


2021년 6월 19일 토요일

gradle task - bootJar, build 비교 메모

젠킨스에서 토이프로젝트 jar 생성을 위해 ./gradlew bootJar로 사용하다 ./gradlew build 로 변경했는데 테스트 실패 오류가 빵빵 터졌다.

그렇다면 bootJar는 test를 안해서 그동안 성공을 했던건가??

동일한 모듈을 실행해보고 로그를 비교해보자!!


  • bootJar : Assembles an executable jar archive containing the main classes and their dependencies.

8:11:17 오전: Executing task 'bootJar'...

> Task :module-domain:initQuerydslSourcesDir
> Task :module-domain:compileQuerydsl UP-TO-DATE
> Task :module-domain:compileJava UP-TO-DATE
> Task :module-domain:processResources UP-TO-DATE
> Task :module-domain:classes UP-TO-DATE
> Task :module-domain:jar UP-TO-DATE
> Task :module-web:initQuerydslSourcesDir
> Task :module-web:compileQuerydsl UP-TO-DATE
> Task :module-web:compileJava UP-TO-DATE
> Task :module-web:processResources UP-TO-DATE
> Task :module-web:classes UP-TO-DATE
> Task :module-web:bootJar UP-TO-DATE
...
BUILD SUCCESSFUL in 1s
10 actionable tasks: 2 executed, 8 up-to-date
  • build : Assembles and tests this project.

8:13:01 오전: Executing task 'build'...

> Task :module-domain:initQuerydslSourcesDir
> Task :module-domain:compileQuerydsl UP-TO-DATE
> Task :module-domain:compileJava UP-TO-DATE
> Task :module-domain:processResources UP-TO-DATE
> Task :module-domain:classes UP-TO-DATE
> Task :module-domain:jar UP-TO-DATE
> Task :module-web:initQuerydslSourcesDir
> Task :module-web:compileQuerydsl UP-TO-DATE
> Task :module-web:compileJava UP-TO-DATE
> Task :module-web:processResources UP-TO-DATE
> Task :module-web:classes UP-TO-DATE
> Task :module-web:bootJar UP-TO-DATE

> Task :module-web:jar SKIPPED
> Task :module-web:assemble UP-TO-DATE
> Task :module-web:compileTestJava
> Task :module-web:processTestResources NO-SOURCE
> Task :module-web:testClasses
> Task :module-web:test
> Task :module-web:check
> Task :module-web:build
...
BUILD SUCCESSFUL in 35s
12 actionable tasks: 4 executed, 8 up-to-date
  • bootJar와 비교했을 때 빨간 부분이 추가되었다. 일단 설명만 봐도 test를 한다고 써있다!!

    • bootJar는 실행 가능한 jar만 생성하는 것으로 보여진다.

    상황에 맞게 task 를 잘 선택하자!!

  • 다음과 같이 그룹 및 task를 쉽게 추가할 수 있으며 기존 task 활용, 순서 등 다양하게 활용할 수 있다.

    • 설정하지 않아도 보여지는 task 그룹들은 build.gradle 에 추가한 플러그인들을 통해 기본으로 보여지는 것 같음
    • 프로젝트 환경에 따라 build 구성을 할 수 있다.
    • 다음에 해보는 걸로...

2021년 5월 20일 목요일

AWS 자동배포 연습해보기

 

이전 포스팅(https://cotmulgyu.blogspot.com/2020/07/blog-post.html)에서 github의 webhooks 를 통해 push가 되면 젠킨스에서 빌드하고 특정 명령어를 실행해서 배포할 수 있음을 확인하였다.

이번에는 CI/CD 서버에서 빌드 후 실제 배포 서버(aws)에 빌드된 파일을 보내고 배포서버에서 바로 배포까지 할 수 있도록 해본다!

  • aws 인스턴스 생성과정은 생략

1_프로젝트 설정

REPOSITORY=/home/ec2-user/project/todolist
PROJECT_NAME=to-do-list

echo "> Build 파일복사"

cp $REPOSITORY/zip/*.jar $REPOSITORY/


echo "> 현재 구동중인 애플리케이션 pid 확인"

CURRENT_PID=$(pgrep -f ${PROJECT_NAME}.*.jar)

echo "현재 구동중인 애플리케이션 pid: $CURRENT_PID"


if [ -z "$CURRENT_PID" ]; then
        echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
else
        echo "> kill -15 $CURRENT_PID"
        kill -15 $CURRENT_PID
        sleep 5
fi

echo "> 새 애플리케이션 배포"

JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1)

echo "> JAR Name: $JAR_NAME"


echo "> $JAR_NAME 실행"

nohup java -jar \
        -Dspring.config.location=classpath:/application.yml,/home/ec2-user/project/properties/application-oauth.properties \
        -Dspring.profiles.active=real \
        $JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
  • AWS CodeDeploy에서 사용할 appspec.yml 파일을 생성한다.

version: 0.0
os: linux
files:
  - source:  /
    destination: /home/ec2-user/project/todolist/zip # S3에서 이동시킬 경로
    overwrite: yes

permissions: # 권한설정이 없으면 root로 설정되므로 다음의 설정 필요
  - object: /
    pattern: "**"
    owner: ec2-user
    group: ec2-user

hooks:
  ApplicationStart:
    - location: start.sh # 애플리케이션 실행
      runas: ec2-user


2_AWS 설정

  • http://m.yes24.com/Goods/Detail/83849117 해당 책을 참고하면 자세하게 설명되어있다...

  • IAM

    • Identity and Access Management(IAM)는 AWS 리소스에 대한 액세스를 안전하게 제어할 수 있는 웹 서비스. IAM을 사용하여 리소스를 사용하도록 인증(로그인) 및 권한 부여(권한 있음)된 대상을 제어함
    • IAM > 사용자 추가
      • 엑세스유형: 프로그래밍 방식 엑세스
      • 권한 설정: 기존정책 직접 연결 > AmazonS3FullAccess , AWSCodeDeployFullAccess 체크
      • 엑세스키와 비밀 엑세스 키는 잘 관리하도록한다..
  • S3 버킷

    • AWS의 S3 서비스는 일종의 파일 서버임 (파일들을 저장하고, 접근 권한을 관리, 검색을 지원)
    • S3 > 버킷 만들기
      • 다른 건 기본설정..
      • 퍼블릭 엑세스 차단 : 모든퍼블릭 엑세스 차단 체크
  • CodeDeploy

    • AWS CodeDeploy는 Amazon EC2 인스턴스 및 온프레미스에서 실행 중인 인스턴스를 비롯하여 모든 인스턴스에 대한 코드 배포를 자동화하는 서비스

    • IAM > 역할만들기

      • 역할은 EC2에서 사용할 것임
      • 신뢰할 수 있는 유형의 개체 선택 : AWS 서비스
      • 이 역할을 사용할 서비스 선택 : EC2
      • 정책 : AmazonEC2RoleforAWSCodeDeploy 체크
    • EC 인스턴스로 이동 > 인스턴스 우클릭 > 인스턴스 설정 > IAM 역할 연결/바꾸기 > 아까 역할 추가후 재부팅 (보안-IAM 연결 수정)

    • CodeDeploy 에이전트 설치

      • CodeDeploy의 요청을 받을 수 있도록 EC2에 에이전트를 설치한다.

      • EC2에서 다음 명령어 입력

        aws s3 cp s3://aws-codedeploy-ap-northeast-2/latest/install . --region ap-  northeat-2
        
        
        chmod -x ./install
        
        
        sudo ./install auto
        
    • CodeDeploy 권한 생성

      • CodeDeploy가 EC2에 접근하려면 권한이 필요하다.
      • IAM > 역할 생성
        • 서비스 : AWS 서비스 > CodeDeploy
        • 사용사례 선택 : CodeDeploy
        • 권한 : AWSCodeDeployRole
    • CodeDeploy 생성

      • CodeDeploy > 애플리케이션 생성

      • 컴퓨팅 플랫폼 : EC2/온프레미스

      • 배포그룹 생성

        • 서비스역할: CodeDeploy IAM 역할 선택
        • 배포유형 : 현재 위치
        • 환경 구성 : Amazon EC2 인스턴스
        • 배포 구성 : CodeDeployDefault.AllAtOnce
        • 로드 밸런서 : 로드 밸런싱 활성화 해제


3_젠킨스 설정

  • 젠킨스 - Jenkins 관리 - 플러그인 관리 - 다음 플러그인 설치 (AWS CodeDeploy Plugin for Jenkins)

    • 해당 플러그인은 빌드 후 AWS CodeDeploy를 사용할 수 있는 플러그인으로 빌드 후 특정 경로를 압축하여 S3에 업로드한다. 그리고 설정한 배포를 시작한다



  • 이전에 설정한 Build-Execute Shell 부분에 자신의 프로젝트에 맞는 빌드명령어를 실행한다. (build, bootJar 등)




  • Build-Execute Shell 부분에 AWS CodeDeploy에서 사용할 파일(appspec.yml, start.sh)과 빌드된 jar를 포함하여 복사시킨다.

    • 여기있는 파일들을 aws S3 Bucket으로 이동시킴 (zip)

    • CodeDeploy 말고 다른플러그인으로 S3 업로드만도 가능한데 그러면 여기서 미리 압축이 필요하다.




  • 빌드 후 조치 추가 - Deploy an application to AWS CodeDeploy

    • AWS CodeDeploy Application Name : AWS CodeDeploy 에서 만든 어플리케이션 이름

    • AWS CodeDeploy Deployment Group : 설정한 그룹 이름

    • AWS CodeDeploy Deployment Config : 설정한 CodeDeploy 배포 구성

    • S3 Bucket : AWS S3 Bucket 이름

    • S3 Prefix : 버킷에 생성될 디렉토리

    • Subdirectory : 젠킨스 서버에서 포함될 파일이 저장될 디렉토리

    • Include Files : 포함될 파일

    • Use Access/Secret keys : AWS 에서 생성한 IAM 정보





  • 젠킨스 빌드 후 AWS S3 버킷을 확인해보면 다음과 같이 설정한 디렉토리 밑에 zip 파일이 생기는 것을 확인할 수 있음



  • AWS CodeDeploy 에서 아까 생성한 애플리케이션을 들어가보면 다음과 같이 배포 이력을 볼 수 있음



    • 배포 실패 시 /var/log/aws/codedeploy-agent 경로에서 로그확인 가능!!
    • appspec.yml 에 명시된 경로에 s3 zip파일이 풀려있는 것을 확인할 수 있다.



    • start.sh에 의해 jar가 복사되고 애플리케이션 로그를 볼 수 있는 nohup.out 파일이 생성된 것을 확인할 수 있음