JIRA DC 플러그인을 개발할땐 기본으로 H2 데이터베이스를 사용한다. H2 데이터베이스는 개발 시 굉장히 가볍고 좋은 데이터베이스이나 운영단계로 넘어가면 이 데이터베이스를 사용하기엔 무리가 있다. 여러가지 이유로 말이다. 그리고 꼭 운영단계가 아니라도 개발 환경에서부터 MySQL로 변경하고 싶을수가 있다.
MySQL, PostgreSQL 둘 중 하나로 변경하면 되는데 여기서는 MySQL로 변경해서 사용하는 방법을 알아본다.
MySQL 설치
우선, 나의 경우 로컬에서 개발할때부터 데이터베이스를 MySQL로 변경하고자 한다. 그래서 로컬에 MySQL을 설치한다.
따라서, MacOS 환경 기준으로 설명한다.
다음 명령어를 터미널에서 실행한다.
(꼭 8.0 버전으로 설치해주자. 그 상위 버전은 지라에서 지원하지 않고 있다 아직까진. 현재 2024-08-20)
brew install mysql@8.0
정상적으로 설치가 되면 다음과 같은 화면이 보인다.
다른거 말고 중간에 이 문구가 있으면 된다.
To connect run:
mysql -u root
기본 설정
세팅을 추가적으로 해줘야한다. 그러기 위해 다음 명령어를 입력한다.
mysql.server start
입력해서 이런 문구가 나오면 된다.
이제 다음 명령어를 통해 보안 관련 설정을 해준다.
mysql_secure_installation
비밀번호 유효성 검사 설정
첫번째로 비밀번호 유효성 설정이 나온다. 권장은 당연히 설정해서 강력한 비밀번호를 만드는게 맞다. 근데 그냥 로컬에서 간단하게 사용할 목적이라면 굳이 이 설정을 하지 않아도 상관은 없다. 난 과감하게 No를 하겠다.
No를 입력하면, 위 사진처럼 root 계정의 패스워드를 설정하라는 메시지가 나온다. 원하는대로 설정해주자.
익명의 사용자 삭제 설정
다음은 익명의 사용자를 제거할지 묻는 화면이다. 제거해주자.
root 계정 원격 접속 차단 설정
다음은 root 계정의 원격 접속을 차단할지 묻는 화면이다. 위 사진처럼 일반적으로 원격 접속을 차단해야 보안상 안전하다.
이 PC가 아닌 다른 곳에서 이 PC의 MySQL로 root 계정으로 접속하는것은 차단하는게 권장된다. 다른곳에서 원격으로 접속이 필요하다면 다른 계정을 만들어서 원격 접속 권한을 제한적으로 주는것이 바람직하다. 나 역시 root 계정의 원격 접속을 제한하기로 한다.
테스트 데이터베이스 삭제 설정
기본으로 제공되는 테스트 데이터베이스를 삭제할건지 묻는다. 삭제한다.
위 작업으로 인한 변경사항 적용 설정
위 작업을 토대로 변경된 내용을 적용할지 묻는다. 적용하자.
여기까지 하면 끝이다. 다음과 같은 화면이 나오면 된다.
root 계정으로 MySQL 접속
위 설정을 다 하면 이제 root 계정으로 접속을 해보자. 패스워드는 위에서 설정한 패스워드로 입력하면 된다.
mysql -u root -p
이렇게 접속이 잘 되면 된다.
JIRA DC 플러그인 용 데이터베이스 생성
이제 데이터베이스를 생성하자.
CREATE DATABASE jira CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
이건 뭐 굳이 이 카테고리여야 싶지만, 이 JIRA 플러그인 개발을 할 때 '개발하고 - 확인하고 - 개발하고'를 반복하다보면 띄워진 서버의 URL(`http://localhost:2990/jira`)에 대한 브라우저 캐시가 남아있어서 변경 사항이 적용이 안되는 경우를 한번은 마주하게 된다.
여러 방법이 있다. 아예 캐시를 다 지우거나, 시크릿 모드를 띄워서 실행하거나 등등.
근데 가장 간단하고 편한 방법은 해당 URL에서 Inspect - Network 탭으로 들어간다.
예외 공통처리는 어떤 프레임워크를 사용하건 심지어 서블릿만 사용하더라도 잘 알아야 하는 부분이다.
개인적으로 중요한 이유 중 가장 큰 이유는, 비즈니스 로직이 깔끔해지고 관심사가 분리된다는 점인것 같다.
JIRA DC 플러그인 개발은 JAX-RS를 사용한다.
이 JAX-RS는 ExceptionMapper를 사용해야 한다.
긴 말 필요없이 바로 코드를 보면 굉장히 간단하다.
IOExceptionMapper
package kr.osci.aijql.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.io.IOException;
@Slf4j
@Provider
@Component
public class IOExceptionMapper implements ExceptionMapper<IOException> {
@Override
public Response toResponse(IOException e) {
log.error("[toResponse] IOException, root cause = ", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build();
}
}
우선, @Provider 애노테이션을 사용해야 한다. 이 애노테이션은 JAX-RS 런타임에 특정 클래스를 자동으로 등록해서 공통으로 사용할 수 있게 해준다. 대표적으로 이렇게 ExceptionMapper를 구현한 구현체를 등록해서 이 지정된 예외가 발생 시 이 클래스가 호출되도록 말이다.
그리고 보면, @Component 애노테이션도 달려있다. 이전 포스팅에서 설명했듯 스프링 스캐너를 사용한다. 그래서 이 클래스 자체가 빈으로 자동 주입이 되어야 한다. 그래야 이 공통 클래스를 사용할수가 있으니까.
그리고 어떤 예외를 처리할지를 제네릭에 넣어준다. 위 코드는 IOException을 처리하는 클래스이다.
그래서 이 IOException이 어디선가 발생하고 그걸 잡아주지 않는다면 외부로 던져질때 이 클래스를 통한다.
그래서, 위 코드는 개발자가 나중에 알아볼 수 있는 에러로그가 찍히고 500 에러 상태 코드를 가진 반환을 한다. 응답 바디는 에러 메시지가 들어가게 된다. 이렇게 공통 처리할 예외 클래스를 ExceptionMapper로 등록하여 공통 처리할 수 있다.
이번에는 이벤트 리스너를 등록해보자. 이벤트 리스너란, 이벤트가 발생했을 때 원하는 후처리 작업을 할 수 있는 방법이다.
JavaScript의 이벤트 리스너랑 완전 똑같은 것이라고 보면 된다.
참고로, 이 포스팅은 공식 문서에서 제공하는 방식과 살짝 다르다. 스프링에서 InitializingBean, DisposableBean 인터페이스를 구현하여 빈으로 등록해서, 스프링 컨텍스트(컨테이너)가 최초로 띄워질때와 마지막에 종료될 때 호출될 메서드와 사용할 이벤트 리스너를 등록해 보았다. 왜 그러냐면, 이 플러그인 관련 포스팅을 Part.1에서 쭉 보다보면 스프링의 기술이 들어가 있는것을 알 수가 있는데 스프링의 기술을 사용중이니까 스프링과 잘 호환되는 기술을 사용해보고자 이런 방식을 구현했다
그리고 스프링 기술을 이용했기 때문에 Add-on Descriptor(atlassian-plugin.xml)에 어떠한 작업도 필요 없고 그래서 더 간결하다는 것을 캐치해서 유심히 봐보자!
IssueCreatedResolvedListener
package kr.osci.kapproval.com.jira.eventlistener;
import com.atlassian.event.api.EventListener;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.jira.event.issue.IssueEvent;
import com.atlassian.jira.event.type.EventType;
import com.atlassian.jira.issue.Issue;
import com.atlassian.plugin.spring.scanner.annotation.imports.JiraImport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class IssueCreatedResolvedListener implements InitializingBean, DisposableBean {
@JiraImport
private final EventPublisher eventPublisher;
/**
* Called when the plugin has been enabled.
*/
@Override
public void afterPropertiesSet() {
log.info("Enabling plugin");
eventPublisher.register(this);
}
/**
* Called when the plugin is being disabled or removed.
*/
@Override
public void destroy() {
log.info("Disabling plugin");
eventPublisher.unregister(this);
}
@EventListener
public void onIssueEvent(IssueEvent issueEvent) {
Long eventTypeId = issueEvent.getEventTypeId();
Issue issue = issueEvent.getIssue();
if (eventTypeId.equals(EventType.ISSUE_CREATED_ID)) {
log.info("Issue {} has been created at {}.", issue.getKey(), issue.getCreated());
// 이슈 Created 이벤트가 발생했을 때 실행되는 부분
} else if (eventTypeId.equals(EventType.ISSUE_RESOLVED_ID)) {
log.info("Issue {} has been resolved at {}.", issue.getKey(), issue.getResolutionDate());
// 이슈 Resolved 이벤트가 발생했을 때 실행되는 부분
} else if (eventTypeId.equals(EventType.ISSUE_CLOSED_ID)) {
log.info("Issue {} has been closed at {}.", issue.getKey(), issue.getUpdated());
// 이슈 Closed 이벤트가 발생했을 때 실행되는 부분
}
}
}
우선, InitializingBean을 구현하려면 재정의 할 메서드가 있다.
afterPropertiesSet()
이 메서드는 스프링 컨텍스트가 완전히 띄워졌을 때, 호출되는 메서드이다. 그러니까 스프링이 진짜 이제 실행될 준비가 됐을 때 자동으로 호출되는 메서드이다. 여기서 무엇을 해야 하냐면 내가 이벤트 퍼블리셔를 등록하겠다고 선언해줘야 한다. 그래야 어떤 이벤트가 발생했을 때 이벤트를 캐치할 수 있게 된다.
그래서 이 메서드안에 다음 코드 한 줄이 있다.
eventPublisher.register(this);
그 다음, DisposableBean을 구현하려면 또 한가지 재정의 할 메서드가 있다.
destroy()
이 메서드는 스프링 컨텍스트가 내려가기 바로 전에 호출되는 메서드이다. 그러니까, 스프링이 내려가기 전 마지막으로 정리할 자원들을 정리하는 메서드라고 생각하면 된다. 그래서 등록한 이벤트 퍼블리셔를 다시 등록 해제하면 된다.
eventPublisher.unregister(this);
그리고, 실제 이벤트가 발생했을 때마다 호출될 메서드가 있다. 바로 다음 메서드.
@EventListener
public void onIssueEvent(IssueEvent issueEvent) {
Long eventTypeId = issueEvent.getEventTypeId();
Issue issue = issueEvent.getIssue();
if (eventTypeId.equals(EventType.ISSUE_CREATED_ID)) {
log.info("Issue {} has been created at {}.", issue.getKey(), issue.getCreated());
// 이슈 Created 이벤트가 발생했을 때 실행되는 부분
} else if (eventTypeId.equals(EventType.ISSUE_RESOLVED_ID)) {
log.info("Issue {} has been resolved at {}.", issue.getKey(), issue.getResolutionDate());
// 이슈 Resolved 이벤트가 발생했을 때 실행되는 부분
} else if (eventTypeId.equals(EventType.ISSUE_CLOSED_ID)) {
log.info("Issue {} has been closed at {}.", issue.getKey(), issue.getUpdated());
// 이슈 Closed 이벤트가 발생했을 때 실행되는 부분
}
}
주의 깊게 볼 건 @EventListener 애노테이션이다. 이 애노테이션은 어떠한 public 메서드라도 상관없이 달 수 있는데 이 애노테이션이 달린 메서드의 파라미터 이벤트가 발생할 때마다 이 메서드가 호출된다. 여기서는, IssueEvent라는 이슈 관련 이벤트를 파라미터로 받는다. 생성, 수정, 삭제 등등의 이벤트가 다 잡히게 될 것이다.
그래서 실제로 원하는 이벤트의 후처리 코드는 이 @EventListener 애노테이션이 달린 메서드에서 작업하면 된다.
이렇게 스프링과 JIRA가 제공하는 @EventListener 애노테이션을 사용해서 스프링의 라이프 사이클을 이용해 스프링 컨테이너가 완전히 올라왔을 때(플러그인이 띄워질 때)와 스프링 컨테이너가 완전히 내려가기 바로 직전에(플러그인이 내려가기 직전에) 딱 한 번씩만 이벤트 퍼블리셔를 등록할 수 있고, 이벤트 리스너 메서드를 만들 수 있다.
공식 문서도 한번 참고해보면 좋을 것 같다.
보너스. 또다른 이벤트 리스너 예시 코드 (RemoteIssueLinkEvent)
RemoteIssueLinkListener
package kr.osci.aijql.eventlistener;
import com.atlassian.event.api.EventListener;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.jira.event.issue.link.RemoteIssueLinkCreateEvent;
import com.atlassian.jira.event.issue.link.RemoteIssueLinkUICreateEvent;
import com.atlassian.jira.issue.link.RemoteIssueLinkManager;
import com.atlassian.plugin.spring.scanner.annotation.imports.JiraImport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class RemoteIssueLinkListener implements InitializingBean, DisposableBean {
@JiraImport
private final EventPublisher eventPublisher;
@JiraImport
private final RemoteIssueLinkManager remoteIssueLinkManager;
/**
* Called when the plugin has been enabled.
*/
@Override
public void afterPropertiesSet() {
log.debug("[afterPropertiesSet]: RemoteIssueLinkListener initialized.");
eventPublisher.register(this);
}
/**
* REST API 또는 애플리케이션에서 직접 Remote Issue Link 추가하는 경우 호출
* @param remoteIssueLinkCreateEvent remoteIssueLinkCreateEvent
*/
@EventListener
public void onCreateRemoteIssueLinkEvent(RemoteIssueLinkCreateEvent remoteIssueLinkCreateEvent) {
log.info("[onCreateRemoteIssueLinkEvent] called");
log.info("[onCreateRemoteIssueLinkEvent] remote issue link id = {}", remoteIssueLinkCreateEvent.getRemoteIssueLinkId());
log.info("[onCreateRemoteIssueLinkEvent] global id = {}", remoteIssueLinkCreateEvent.getGlobalId());
}
/**
* 오직 애플리케이션에서 사용자가 Remote Issue Link 추가하는 경우 호출
* @param remoteIssueLinkUiCreateEvent remoteIssueLinkUiCreateEvent
*/
@EventListener
public void onCreateUiRemoteIssueLinkEvent(RemoteIssueLinkUICreateEvent remoteIssueLinkUiCreateEvent) {
log.info("[onCreateUiRemoteIssueLinkEvent] called");
log.info("[onCreateUiRemoteIssueLinkEvent] remote issue link id = {}", remoteIssueLinkUiCreateEvent.getRemoteIssueLinkId());
log.info("[onCreateUiRemoteIssueLinkEvent] global id = {}", remoteIssueLinkUiCreateEvent.getGlobalId());
}
/**
* Called when the plugin is being disabled or removed.
*/
@Override
public void destroy() {
log.info("[destroy]: RemoteIssueLinkListener destroyed.");
eventPublisher.unregister(this);
}
}
이번에는 서블릿 필터를 만들어보자. 서블릿 필터는 사실 그냥 Java로 서블릿을 사용하면 거의 무조건 사용하는 컴포넌트이다.
그래서 이건 뭐 JIRA 플러그인을 개발하기 위해 따로 알아야 하는 개념이 아니라 아마 익숙할 것 같다.
우선 서블릿 필터를 만드려면 당연히 dependencies로 서블릿이 있어야 할 것이고, 이건 이미 이 전 포스팅에서 다뤘다.
그리고 필터를 구현하는 클래스를 만들면 된다.
CustomServletFilter
package kr.osci.kapproval.admin.servlet.filter;
import lombok.RequiredArgsConstructor;
import javax.servlet.*;
import java.io.IOException;
@RequiredArgsConstructor
public class CustomServletFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
}
@Override
public void destroy() {
}
}
void init(): 필터 초기화 작업이 필요하다면 이 메서드에 작성하면 된다.
doFilter(): 각 필터마다 이 메서드에서 필터 처리를 한다. 필터는 요청을 다음 필터 또는 서블릿으로 전달할지 여부를 결정할 수 있습니다.
destroy(): 필터 종료 작업이 필요하다면 이 메서드에 작성하면 된다.
필터의 주요 역할
요청(Request)에 대한 전처리: 클라이언트 요청이 서블릿이나 JSP로 전달되기 전에 요청을 수정하거나, 인증/인가 검사를 수행하거나, 로깅 등을 할 수 있다.
응답(Response)에 대한 후처리: 서블릿이나 JSP가 응답을 만들고, 클라이언트에게 전달되기 전에 응답을 수정하거나, 로깅 등을 할 수 있다.
필터의 동작 과정
클라이언트의 요청 수신: 클라이언트로부터 HTTP 요청이 들어오면 웹 서버는 이를 필터 체인(Filter Chain)에 전달한다.
필터 체인 통과: 요청은 필터 체인을 따라 이동하며, 각 필터는 요청을 처리할 기회를 가진다.
각 필터는 `doFilter` 메서드를 통해 요청을 처리한다.
필터는 요청을 다음 필터 또는 서블릿으로 전달할지 여부를 결정할 수 있다.
서블릿 또는 JSP로 전달: 필터 체인을 모두 통과한 요청은 최종적으로 서블릿이나 JSP에 도달하여 본래의 비즈니스 로직을 수행한다.
응답 생성: 서블릿이나 JSP가 응답을 생성하면, 응답은 다시 필터 체인을 따라 클라이언트로 돌아간다.
필터 체인 역순 통과: 응답은 필터 체인을 역순으로 통과하며 각 필터는 응답을 처리할 기회를 가진다.
클라이언트 응답 전달: 최종적으로 처리된 응답이 클라이언트에게 전달된다.
다음과 같은 필터를 만들어보자!
CustomServletFilter
public class CustomServletFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 필터 초기화 작업 (필요한 경우)
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 요청 전처리 작업
System.out.println("Request received at MyFilter");
// 필터 체인의 다음 요소로 요청을 전달
chain.doFilter(request, response);
// 응답 후처리 작업
System.out.println("Response leaving MyFilter");
}
@Override
public void destroy() {
// 필터 종료 작업 (필요한 경우)
}
}
이 필터는 다음과 같은 동작을 한다.
클라이언트로부터 요청이 들어오면 `Request received at MyFilter`를 출력한다.
요청을 다음 필터 또는 서블릿으로 넘긴다.
요청에 대한 응답을 생성한 서블릿 또는 JSP가 응답을 다시 필터로 넘기고 그 응답이 여러 필터를 거쳐 이 필터로 도착한다.
`Response leaving MyFilter`를 출력한다.
응답을 클라이언트에게 최종적으로 전달한다.
이렇게 만든 필터를 결국 등록을 해야 사용할 수 있는데, 이 JIRA DC 플러그인을 개발할땐 언제나 리소스는? Add-on Descriptor(atlassian-plugin.xml)에 등록한다. 참고로 JIRA DC 플러그인을 개발하는게 아니면 개발 방식에 따라 필터 등록하는 방법은 다 가지각색이라 목적에 맞는 방법을 찾으면 된다.
서블릿 필터를 등록하고, url-pattern 태그로 어떤 URL의 요청이 이 필터를 거칠지를 결정하면 된다. 이렇게 설정하면 끝이다.
여기서 location 이라는 attribute가 있다. 이건 이 필터가 어디쯤에 위치할지를 정하는 것이다.
나는 `before-dispatch` 라는 값을 주었다. 이게 기본값이고 이건 서블릿 필터 체인의 가장 마지막에 이 필터를 추가하는 것이다. 그러니까 이 요청을 처리하는 서블릿이나 JSP에 도달하기 바로 직전에. 그리고 이러한 옵션들에 대한 내용, 또한 서블릿 필터에 대한 자세한 내용은 아래 공식 문서를 참고하자.
서블릿 필터가 어떤 원리로 동작하고 어떻게 사용되는지 알아보았다!
만약, 요청을 가로채서 하는 작업이 인증/인가를 확인하는 처리라면 인증이 되지 않은 경우 다음 필터 또는 서블릿으로 넘기기 전에 그냥 바로 사용자에게 응답을 돌려줄 수 있다. 예를 들면 이런 코드를 작성할 수 있다.
public class CustomServletFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 필터 초기화 작업 (필요한 경우)
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 인증 여부 확인 (여기서는 간단하게 세션에 "authenticated" 속성이 있는지 확인)
Boolean isAuthenticated = (Boolean) httpRequest.getSession().getAttribute("authenticated");
if (isAuthenticated == null || !isAuthenticated) {
// 인증이 안된 경우, 바로 응답 생성
httpResponse.setContentType("text/html");
PrintWriter out = httpResponse.getWriter();
out.println("<html><body>");
out.println("<h3>Authentication Required</h3>");
out.println("<p>You are not authenticated. Please <a href=\"login.html\">login</a>.</p>");
out.println("</body></html>");
out.close();
} else {
// 인증이 된 경우, 다음 필터 또는 서블릿으로 요청 전달
chain.doFilter(request, response);
}
}
@Override
public void destroy() {
// 필터 종료 작업 (필요한 경우)
}
}
이거는 단순 예시일 뿐, 구현은 원하는대로 어떻게든 할 수 있다!
결론
서블릿 필터를 통해 클라이언트의 요청을 가로채서 추가 작업을 할 수 있고 응답을 내보내기 전 마지막 작업을 할 수 있다. 그러려면 Filter를 구현한 서블릿 필터 클래스가 필요하고, 이 클래스는 init, doFilter, destroy 라는 메서드를 재정의해야 하는데 여기서 가장 중요한 건 doFilter 메서드이다. 이 doFilter 메서드에 chain.doFilter()를 호출하기 전에 작성한 코드가 클라이언트의 요청이 서블릿으로 넘어가기 전 작업하는 부분이고 chain.doFilter()를 호출한 이후 코드가 생성된 응답을 클라이언트에게 최종적으로 전달하기 전 작업하는 부분이 된다.
위 코드를 보자. 가장 중요한 건 당연하겠지만 데이터를 쿼리하는 메서드인 getValues()이다. 비즈니스 로직에 따라 이 메서드 안에서 내가 이 JQL Function이 어떤 이슈들을 보여줄건지 정해야 한다. 그리고 반환 타입은 List<QueryLiteral>이다.
QueryLiteral의 첫번째 인자는 파라미터로 받는 FunctionOperand 객체를 넘겨주면 된다. 두번째 객체는 String 타입 또는 Long 타입의 가져올 데이터의 값이다. 만약 getDataType()이 반환하는 값이 JiraDataTypes.ISSUE로 되어 있다면 이슈의 키를 반환하면 된다.
그래서 위 코드를 보면 결국 반환은 가져온 모든 QueryLiteral 객체 데이터를 리스트로 넘긴다.
이 메서드의 내부 로직은 본인이 원하는 이슈들을 가져오게끔 작성하면 된다. 예를 들어, 이슈의 상태가 `Closed` 상태인 모든 이슈라던가, Assignee가 `XXX`인 사람의 모든 이슈 등 원하는 이슈 쿼리를 하면 된다.
JQL Function 리소스 등록
이제 익숙해질때도 됐다. 플러그인의 모든 리소스는 다 Add-on Descriptor(atlassian-plugin.xml)에 등록해야 한다.
이제 JIRA 관리자든 일반 사용자든 접근 가능한 상단 네비게이션 화면에 플러그인 관련 링크를 노출하는 방법을 소개한다.
화면으로 보면 이해가 좀 더 빠를 것 같다. 바로 이 부분.
여기에 플러그인 관련 링크를 노출시키고 빠른 접근성을 확보하기 위해 해야하는 절차들을 소개한다.
Web Section과 Web Item
이것 역시 Web Section과 Web Item과 연관이 있다.
그리고 아무튼 JIRA Plugin 개발은 무조건 다 Add-on Descriptor (atlassian-plugin.xml)에 리소스를 등록해야 한다.
그래서 우선 첫번째 단계는 아래와 같이 Web Item을 등록하는 것이다.
<web-item key="topNavKapproval" name="Link on My Links Main Section" section="system.top.navigation.bar" weight="150">
<condition class="com.atlassian.jira.plugin.webfragment.conditions.UserLoggedInCondition" />
<label key="K-Approval"></label>
<link linkId="topNavKapproval"></link>
</web-item>
<web-section name="K-Approval Request Approval Section" i18n-name-key="section.kapproval.request.approval.name" key="kapprovalRequestSection" location="topNavKapproval" weight="10">
<label key="section.kapproval.request.approval.label"></label>
</web-section>
이 Web Item의 section attribute는 `system.top.navigation.bar`라는 미리 정해져 있는 값이다.
그리고 지금껏 보지 못했던 내용이 하나 추가됐는데 바로 `condition` 태그이다. 이 컨디션 태그는 뭐냐면 어떤 특정 조건에 만족할 경우에만 이 상단 네비게이션 바에 버튼(링크)가 보여지게 하는 방법이다.
그리고 이 컨디션은 또한 클래스가 이미 다 정해져있다. 그 중 하나가 바로 `com.atlassian.jira.plugin.webfragment.conditions.UserLoggedInCondition`이다.
클래스 이름만 봐도 유저가 로그인 된 상태인 조건이라고 짐작할 수 있다. 이 조건은 어떻게 알까? 다음 링크를 참고하자.
참고로 컨디션을 사용하려면 그냥 태그만 추가해서는 안된다. 이 컨디션의 클래스는 말 그대로 클래스를 가져오는 것이고 클래스를 가져온다는 것은 모듈(라이브러리)가 프로젝트에 포함된 상태여야 한다. 그리고 그건 프로젝트의 pom.xml 파일에서 추가한다.
pom.xml
<build>
<plugins>
<plugin>
<groupId>com.atlassian.maven.plugins</groupId>
<artifactId>jira-maven-plugin</artifactId>
<version>${amps.version}</version>
<extensions>true</extensions>
<configuration>
<productVersion>${jira.version}</productVersion>
<productDataVersion>${jira.version}</productDataVersion>
<enableQuickReload>true</enableQuickReload>
<instructions>
<Atlassian-Plugin-Key>${atlassian.plugin.key}</Atlassian-Plugin-Key>
<!-- Add package to export here -->
<!-- Add package import here -->
<Import-Package>
com.atlassian.jira.plugin.webfragment.conditions,
org.eclipse.gemini.blueprint.*;resolution:="optional",
*
</Import-Package>
<!-- Ensure plugin is spring powered -->
<Spring-Context>*</Spring-Context>
</instructions>
<log4jProperties>src/main/resources/log4j.properties</log4jProperties>
</configuration>
</plugin>
...
</plugins>
</build>
이 build 태그 안에 plugins 태그 안에 plugin 중 `com.atlassian.maven.plugins` 안에 자세히 보면 <Import-Package> 라는 태그가 있다. 이 안에 `com.atlassian.jira.plugin.webfragment.conditions`를 추가해줘야 한다. 그러면 아래 라이브러리 안에 구현된 컨디션들을 확인해 볼 수 있을 것이다.
다시 돌아와서, 이제 컨디션까지 확인을 해봤고 label은 말 그대로 화면에 보여지는 Label을 의미한다.
그리고 그 다음에 Web Section이 나오는데 Web Section은 그 상단 네비게이션 바에서 버튼을 클릭하면 하단에 나오는 드롭다운 메뉴에서 섹션을 구분할 수가 있다. 그 구분 섹션을 의미한다. 다음 사진을 보자.
이렇게 빨간 박스로 구분된 섹션들을 말한다. 그리고 이 location 중요한데, 이 location은 상단 네비게이션 바 Item의 key가 되어야 한다. 그래야 그 상단 네비게이션 바에 섹션들이 들어가겠지?라는 합리적인 발상이 가능해진다. 그리고 label 태그는 i18n 리소스를 사용했다.
그 다음, 섹션이 있으면 그 섹션에 각 링크들이 달릴것이다. 그래서 그 링크(버튼)들을 만들어야 한다.
여기서도 역시 중요한건 section이다. 여기에 보면 `topNavKapproval/kapprovalRequestSection`으로 되어 있는데 `topNavKapproval`은 상단 네비게이션 바를 가리키는 Web Item의 Key이다. 그리고 `kapprovalRequestSection`은 그 상단 네비게이션 바에 달리는 섹션의 Key이다.
그리고, 그 실질적으로 클릭 가능한 버튼인 `menuKapprovalRequestApproval`이라는 키를 가진 Web Item은 의미있는 링크를 가지고 있다. 그 링크는 특정 JQL을 실행하는 링크이고 이 JQL 함수는 내가 따로 만든 JQL이다. 이건 이후에 설명하겠다. 아무튼 이 버튼을 클릭하면? 지정된 JQL을 실행하는 화면으로 이동한다.
그래서 아래 사진이 완성된 화면이다.
이렇게 상단 네비게이션 바에 플러그인의 링크를 노출시킬 수 있다. 그리고 그 각 버튼은 의미있는 화면으로의 이동이 되면 된다. 나같은 경우는 내가 만든 커스텀 JQL 함수를 실행하는 화면으로 이동시키게 만든 것이고 말 나온김에 다음 포스팅은 커스텀 JQL 함수를 만드는 방법을 포스팅 해보겠다.
참고로, 섹션을 또 추가하고 싶으면 위 방식대로 Web Section 추가하고 그 섹션 하위에 Web Item들을 위 방식 그대로 똑같이 추가해주면 된다.
우선 인터페이스를 하나 만들고 net.java.ao.Entity를 상속받아야 한다. 그리고 각 엔티티에 필요한 필드들은 Getter, Setter를 만듦으로써 생성된다. 그리고 Primary Key는 net.java.ao.Entity 안으로 들어가보면 Primary Key가 이미 선언이 되어있다.
net.java.ao.Entity
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package net.java.ao;
import net.java.ao.schema.AutoIncrement;
import net.java.ao.schema.NotNull;
import net.java.ao.schema.PrimaryKey;
public interface Entity extends RawEntity<Integer> {
@AutoIncrement
@NotNull
@PrimaryKey("ID")
int getID();
}
그래서 기본키는 따로 만들지 않아도 된다. Entity를 상속받기 때문에!
엔티티끼리 Relationship도 생성할 수 있다. 그 부분은 이 공식 문서를 참조하자.
저기서 @Table 애노테이션은 이 엔티티에 대한 테이블명을 명시해주는 방법이다.
저렇게 @Table("KAPP_USER")로 애노테이션을 달면 데이터베이스에서 테이블 명은 이렇게 된다.
AO_28BE2D_KAPP_USER
그리고 @Table 애노테이션으로 테이블 명을 명시하지 않으면 테이블 명은 기본이 클래스명을 따라간다.
그래서 이렇게 테이블을 만들면, 이 AO 인터페이스로 만든 테이블을 atlassian-plugin.xml 파일에 등록해야 한다.
<ao key="ao-module">
<description>The module configuring the Active Objects service used by this plugin</description>
...
<entity>kr.osci.kapproval.com.entity.KapprovalUserEntity</entity>
...
</ao>
이렇게 등록을 하고 나서 다음 명령어로 서버를 실행해보자.
atlas-run
띄워진 서버에서 알려준대로 `localhost:2990/jira`로 접속해보면 띄워진 지라 서버가 보여질텐데 거기에 DbConsole 버튼을 클릭해보자.
그럼 기본으로 연동된 H2 데이터베이스의 콘솔이 노출된다.
테이블 목록을 쭉 보면 내가 등록한 테이블이 보여진다.
그럼 테이블은 정상적으로 만들어졌으니 이제 CRUD에 대한 작업을 해보자.
Create
기본적으로 ORM이면 CRUD에 대한 메서드가 이미 있다. 솔직히 AO는 너무 불편하고 부실하지만, 이게 Atlassian에서 제공하는 ORM이기 때문에 사용해야 한다. 다른 ORM을 사용할 수 있는지 계속해서 알아보는 중인데 쉽지 않다. 새로운 사실을 알게 되면 업데이트 해야겠다!
우선 Create 작업이 필요한 서비스 내에서 ActiveObjects 객체를 주입받자.
KapprovalSettingsServiceImpl
package kr.osci.kapproval.admin.service.impl;
import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import kr.osci.kapproval.admin.service.KapprovalSettingsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class KapprovalSettingsServiceImpl implements KapprovalSettingsService {
@ComponentImport
private final ActiveObjects activeObjects;
}
그리고 이 activeObjects가 가지고 있는 메서드를 쭉 살펴보면 다음과 같이 create()가 있다.
참고로, 아주 많은 양의 데이터를 읽어 들일땐 find(), get() 말고 stream()을 사용하라고 나와있다. 일단 아주 많은 양의 데이터를 읽는다는 것은 업데이트와 거리가 멀다. 정말 읽어들이기 위한 (Read-Only) 데이터를 찾는 경우가 대다수이다. 이럴땐 Stream API를 사용하라고 공식 문서에 표기되어 있다.
만약, 간단한 쿼리가 아닌 복잡한 쿼리가 필요하다면, findWithSQL()을 사용하자.
String sql = "SELECT id, name, value FROM my_table WHERE column1 = ? AND column2 = ?";
Object[] params = new Object[] {value1, value2};
String keyField = "id"; // 쿼리 결과의 'id' 컬럼을 키로 사용
MyEntity[] results = ao.findWithSQL(MyEntity.class, keyField, sql, params);
return results;
여기서 keyField라는 파라미터가 사용되는데 이는 SELECT 절에 사용되는 컬럼 중 임의의 것을 사용해도 되지만 일반적으로 그리고 권장되는 것은 PK를 사용하는 것이다. 이 keyField를 통해 AO가 쿼리로부터 받아오는 결과를 적절하게 매핑할 수 있기 때문에 사용한다고 한다. 그리고 keyField로 사용되는 값은 반드시 SELECT절로 받아오는 컬럼 중 하나여야 한다.
Update
업데이트는 읽어서 쓴다. 즉, 먼저 업데이트 할 객체를 가져와서 그 객체를 변경한 후에 저장하는 방식으로 수행한다.
web-section 태그에서 가장 중요한 attribute는 location이다. 이 location은 아무값이나 적을 수 있는게 아니고 지정된 값만을 적을 수 있다. 그리고 그 지정된 값들 중에 관리자 화면에 있는 Manage Apps에 메뉴로 등록할 수 있는 위치는 `admin_plugins_menu`이다.
이걸 어떻게 알았냐면 공식 문서를 진짜 열심히 찾다보면 겨우 찾을 수 있다. 찾기가 굉장히 어렵다.
위 링크에 들어가면 관리자 화면의 locations 정보를 알 수 있다.
다시 돌아와서, web-section 태그에서 key, name, location attritbutes를 작성했는데 key, name은 원하는 값을 적으면 된다. 그렇지만 Unique해야 한다. 이 key를 통해서 섹션 아래 여러 아이템(메뉴버튼)들을 등록할 수 있기 때문에.
그리고 label은 보여지는 값을 의미한다. 그러니까 아래 사진에 빨간 박스.
그리고 여기선, 국제화를 위해 i18n 리소스를 사용했다. menu.admin.section 이라는 키는 i18n 리소스 파일에서 등록한 값이다.
kapproval.properties
menu.admin.section=K-Approval
...
kapproval_ko.properties
menu.admin.section=k-결재
여기까지 하면 web-section에 대한 내용은 다 한 것이다. 이제 그 섹션아래 여러 버튼이 존재한다. 아래 사진을 봐보자.
섹션 아래 여러 버튼들이 있다. 이것들은 web-item이라는 리소스이다. 이것들도 다 atlassian-plugin.xml에 등록해줘야 한다.
여기서 key, name attributes는 역시 원하는 값을 그대로 넣어주면 된다. 중요한 것은 section attribute.
보면 `admin_plugins_menu/adminPluginsMenuSection`이라고 되어 있다.
admin_plugins_menu 이 부분은 정해져 있는 값이다. 위에서도 다룬 바 있다. 그리고 adminPluginsMenuSection이 값은 위에 만든 web-section 태그의 key 값이다. 그래서 합쳐지면 관리자 화면의 내가 만든 섹션 하단에 아이템을 넣겠다는 의미가 된다.
그리고 여기서도 label은 역시 i18n 리소스를 사용한것이다.
link 태그는 이제 이 버튼(아이템)을 클릭하면 보여질 화면에 대한 path라고 생각하면 된다. 그리고 이건 서블릿을 등록하고 서블릿을 코드를 직접 만들어야 한다. 그래서 일단은 link 태그를 위처럼 작성해두자. 아 물론 만약 버튼을 클릭한게 구글 브라우저를 띄우는 거라면 그냥 아래처럼 하면 된다.
두 가지가 필요한데 나는 뷰 템플릿을 사용한다. JIRA Plugin 개발에서 공식문서에 나와있는 뷰 템플릿은 Velocity인데, 다른게 가능한지는 모르겠다. 크게 다른게 없기 때문에 그냥 Velocity를 사용해도 무방하다고 생각한다. 그리고 이 템플릿을 렌더링 하는 Atlassian의 라이브러리인 ATR(Atlassian Template Renderer)를 사용한다.
그리고 Servlet을 사용하려면 관련 의존성을 내려 받아야 하기 때문에 두가지 dependencies를 추가한다.
그리고 뷰 템플릿을 렌더링 할 것이기 때문에 TemplateRenderer 클래스 역시 주입받아야 한다.
@ComponentImport private final TemplateRenderer renderer;
그리고 이전 포스팅에서 스프링 스캐너를 사용해서 애노테이션 기반의 주입을 사용하게 설정했었다. 그래서 @ComponentImport 애노테이션을 사용해서 필요한 모듈을 이렇게 쉽게 주입받을 수가 있다. 이 @ComponentImport는 주입받는 모든것들에 다 적용하는건 아니고 Atlassian 패키지 하위에 있는 모듈들을 주입받을때만 사용하면 된다. 이 말은 이후에 좀 더 잘 이해하게 된다.
스프링에 익숙한 사람들은 생성자 주입을 통해 주입받는다는 것을 알 것이다. 그리고 Lombok을 사용하면 @RequiredArgsConstructor 애노테이션으로 편리하게 생성자를 만들 수 있다. 그래서 위 코드가 그 방식을 따르고 있다.
그리고 doGet() 안에서 WebSudoManager의 willExecuteWebSudoRequest(req); 를 호출해주면 현재 요청을 관리자 권한을 가진 유저가 요청한것인지 확인해준다. 그 이후에 코드가 실제 이 서블릿을 호출할 때 실행될 코드가 된다. 특별히 뭐 다른게 필요없다면 바로 뷰 템플릿을 렌더링하면 된다.
더해서, 이 뷰 템플릿에서 사용하고자 하는 자원이 있을 수 있다. 예를 들면 JS, CSS 파일 또는 JIRA Plugin 개발하면서 아주 많이 자주 사용되는 AUI 리소스들. 그런것들을 뷰 템플릿에서 사용하고 싶으면 이 또한 atlassian-plugin.xml 파일에 웹 리소스를 등록해서 그 리소스를 뷰 템플릿에서 가져와야 한다.
이게 큰 그림에서의 설정, 리소스 등록, 서비스를 주입하는 방법이다. 이제 그 내부의 비즈니스 로직은 요구사항에 맞게 원하는대로 구현하면 된다. 관리자 화면의 섹션 생성, 섹션 하단에 메뉴 등록, 메뉴에서 보여지는 화면을 호출하는 서블릿, 서블릿에서 주입하는 서비스까지 전부 알아보았다. 서비스 중에선 atlassian 모듈 하위의 서비스인 경우 @ComponentImport 애노테이션을 붙여서 임포트하는 것까지.