728x90
반응형
SMALL

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;

 

데이터베이스 명은 원하는대로 작성하면 된다. 위는 단지 예시일 뿐이다.

데이터베이스를 생성했으면, 유저를 생성하고 데이터베이스에 권한을 주자. 원격으로도 접속이 가능해야 하니까.

CREATE USER 'jirauser'@'localhost' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON jira.* TO 'jirauser'@'localhost';
FLUSH PRIVILEGES;

이 또한, 유저명과 패스워드는 원하는대로 만들어주면 된다. 이렇게 권한까지 다 주고 나면 MySQL 설정은 끝이다.

 

MySQL JDBC 드라이버 설정

다음 링크에서 적절한 버전의 드라이버를 다운받는다. MySQL 8.0 버전을 사용하니까 드라이버도 8 버전으로 설치하면 된다.

 

MySQL :: Download Connector/J

MySQL Connector/J is the official JDBC driver for MySQL. MySQL Connector/J 8.0 and higher is compatible with all MySQL versions starting with MySQL 5.7. Additionally, MySQL Connector/J 8.0 and higher supports the new X DevAPI for development with MySQL Ser

dev.mysql.com

다운 받은 jar 파일을 다음 경로에 추가해줘야 한다.

target/jira/home/webapp/WEB-INF/lib

참고로 이 경로는, JIRA DC 플러그인 프로젝트를 빌드하면 생기는 target 폴더이다. 그래서 최초 빌드 한번이 필요하다!

다음과 같이 잘 추가가 됐으면 끝이다.

 

JIRA 데이터베이스 연결 설정 변경

JIRA DC 플러그인 프로젝트를 빌드하면 target 폴더가 생긴다. 이 폴더 내부에 홈 디렉토리가 있다. 그리고 그 안에 dbconfig.xml 파일이 존재한다.

target/jira/home/dbconfig.xml

 

이 파일을 삭제한다! 이 파일 삭제하면, 설치 마법사가 다시 실행되면서 내가 원하는 데이터베이스를 연결할 수 있다. 

 

삭제한 후 다음 명령어를 실행!

atlas-run

다음과 같이 정상 실행이 된것을 확인하자.

보이는 것 그대로 링크 주소를 브라우저에 입력하면 다음 화면이 나온다!

설치 마법사가 실행된다! 여기서 `I'll set it up myself` 를 선택하고 Next.

이런 화면이 나온다. 여기서 `My Own Database`를 선택하자. 그러면 하단에 설정정보 입력 칸이 보여진다.

설정 정보를 입력하고 `Test Connection` 버튼을 클릭해서 연결이 잘 확인되어야 한다! 그리고 Next

한참 데이터베이스를 세팅한 후에 다음 화면이 보여진다.

그대로 Next.

이 화면이 나오면 `generate a Jira trial license` 버튼을 클릭해서 Trial license를 받으면 된다. 라이센스 받으면 다음과 같이 자동으로 입력된다. 

Trial license를 받는 방법은 이 포스팅의 범주를 넘어서기 때문에 따로 설명하지 않는다. 어렵지 않으니 그냥 들어가서 발급하면 된다.

그리고 Next를 클릭하면, 이제 Admin 유저 정보를 입력하는 화면이 나오고 그 화면에서 적절하게 Admin 정보를 기입 후 다음으로 넘어가면 된다! 그러면 다음과 같이 MySQL 데이터베이스와 연동된 Jira가 띄워진다!

 

728x90
반응형
LIST
728x90
반응형
SMALL

이건 뭐 굳이 이 카테고리여야 싶지만, 이 JIRA 플러그인 개발을 할 때 '개발하고 - 확인하고 - 개발하고'를 반복하다보면 띄워진 서버의 URL(`http://localhost:2990/jira`)에 대한 브라우저 캐시가 남아있어서 변경 사항이 적용이 안되는 경우를 한번은 마주하게 된다.

 

여러 방법이 있다. 아예 캐시를 다 지우거나, 시크릿 모드를 띄워서 실행하거나 등등.

 

근데 가장 간단하고 편한 방법은 해당 URL에서 Inspect - Network 탭으로 들어간다.

 

여기서 보면, Disable cache 체크박스가 보인다.

 

이거 체크하고 새로고침하자! 그럼 캐시가 싹 날라가고 전부 다 새로 받아온다 👍 

728x90
반응형
LIST
728x90
반응형
SMALL

예외 공통처리는 어떤 프레임워크를 사용하건 심지어 서블릿만 사용하더라도 잘 알아야 하는 부분이다.

개인적으로 중요한 이유 중 가장 큰 이유는, 비즈니스 로직이 깔끔해지고 관심사가 분리된다는 점인것 같다.

 

JIRA DC 플러그인 개발은 JAX-RS를 사용한다.

JAX-RSExceptionMapper를 사용해야 한다.

 

긴 말 필요없이 바로 코드를 보면 굉장히 간단하다.

 

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로 등록하여 공통 처리할 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

개발하면서 로그 남기는 건 필수인데, 로그 찍는법을 드디어 알아냈다.

우선 버전이 Jira 9.x 이상인 경우 log4j2를 사용한다. 그리고 이 로그 설정 파일의 경로는 다음 위치에 있다.

 

Step 1. 로그 파일 위치와 수정

<jira-application-dir>/atlassian-jira/WEB-INF/classes/log4j2.xml

 

예를 들어, 프로젝트를 만들고 프로젝트를 실행하면, target 폴더가 생기는데 그 위치는 이렇다.

<project-root-path>/target/jira/webapp/WEB-INF/classes/log4j2.xml

 

이 파일을 열어보면 다음과 같이 생겼다.

log4j2.xml

<?xml version="1.0" encoding="UTF-8"?>
<Configuration packages="com.atlassian.logging.log4j,com.atlassian.jira.logging">
    <Properties>
        <Property name="StackTraceFilteringPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %t %p %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} [%q{2}] %nlm%n%stf{stackTracePackagingExamined(false)}{filteringApplied(true)}{filteredFrames(@jira-filtered-frames.properties)}</Property>
        <Property name="NonFilteringPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %t %p %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} [%q{2}] %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="NewLineIndentingNotFilteringPattern">%nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="NoLevelNonFilteringPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %t %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.url} %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="IpAddressNonFilteringPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %t %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="JustMessagePattern">%nlm%n%stf{filteringApplied(false)}</Property>

        <Property name="ProfilerPattern">%d | %t | %X{jira.request.id} | %X{jira.username} | %X{jira.request.assession.id}%n%nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="OutgoingMailPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %p [%X{jira.mailserver}] %t %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} [%q{2}] %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="IncomingMailPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %p [%X{jira.mailserver}] %t %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="ApdexPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="InProductDiagnosticPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %nlm%n</Property>
    </Properties>

    <Appenders>
        <Console name="console" target="SYSTEM_OUT">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <ThresholdFilter level="DEBUG" onMatch="ACCEPT" onMismatch="DENY"/>
        </Console>
        <JiraHomeAppender name="filelog"
                          fileName="atlassian-jira.log"
                          filePattern="atlassian-jira.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="10"/>
        </JiraHomeAppender>
        <JiraHomeAppender name="httpaccesslog"
                          fileName="atlassian-jira-http-access.log"
                          filePattern="atlassian-jira-http-access.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NewLineIndentingNotFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>
        <JiraHomeAppender name="httpdumplog"
                          fileName="atlassian-jira-http-dump.log"
                          filePattern="atlassian-jira-http-dump.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NewLineIndentingNotFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>
        <JiraHomeAppender name="profilerlog"
                          fileName="atlassian-jira-profiler.log"
                          filePattern="atlassian-jira-profiler.log.%i">
            <PatternLayout>
                <Pattern>${ProfilerPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>
        <JiraHomeAppender name="sqllog"
                          fileName="atlassian-jira-sql.log"
                          filePattern="atlassian-jira-sql.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NoLevelNonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="slowquerylog"
                          fileName="atlassian-jira-slow-queries.log"
                          filePattern="atlassian-jira-slow-queries.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="querydsllog"
                          fileName="atlassian-jira-querydsl-sql.log"
                          filePattern="atlassian-jira-querydsl-sql.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NoLevelNonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="slowsqlquerylog"
                          fileName="atlassian-jira-slow-querydsl-queries.log"
                          filePattern="atlassian-jira-slow-querydsl-queries.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="xsrflog"
                          fileName="atlassian-jira-xsrf.log"
                          filePattern="atlassian-jira-xsrf.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NoLevelNonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="securitylog"
                          fileName="atlassian-jira-security.log"
                          filePattern="atlassian-jira-security.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${IpAddressNonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="outgoingmaillog"
                          fileName="atlassian-jira-outgoing-mail.log"
                          filePattern="atlassian-jira-outgoing-mail.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${OutgoingMailPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="incomingmaillog"
                          fileName="atlassian-jira-incoming-mail.log"
                          filePattern="atlassian-jira-incoming-mail.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${IncomingMailPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="remoteappssecurity"
                          fileName="atlassian-remoteapps-security.log"
                          filePattern="atlassian-remoteapps-security.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="apdexlog"
                          fileName="atlassian-jira-apdex.log"
                          filePattern="atlassian-jira-apdex.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${ApdexPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="startupjdbc"
                          fileName="jdbc-startup.log"
                          filePattern="jdbc-startup.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${JustMessagePattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="10480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="2"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="diagnostics"
                          fileName="jira-diagnostics.log"
                          filePattern="jira-diagnostics.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="performance"
                          fileName="atlassian-jira-perf.log"
                          filePattern="atlassian-jira-perf.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>%d ${JustMessagePattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="app"
                          fileName="atlassian-jira-app-monitoring.log"
                          filePattern="atlassian-jira-app-monitoring.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>%d ${JustMessagePattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="ipd"
                          fileName="atlassian-jira-ipd-monitoring.log"
                          filePattern="atlassian-jira-ipd-monitoring.log.%i">
            <PatternLayout>
                <Pattern>${InProductDiagnosticPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <FluentdAppender name="fluentdAppender"
                         fluentdEndpoint="http://localhost:9880">
            <AtlassianJsonLayout
                    filteringApplied="true"
                    filteredFrames="@jira-filtered-frames.properties"
                    minimumLines="6"
                    showEludedSummary="false"
                    includeLocation="true"/>
            <ThresholdFilter level="INFO" onMatch="ACCEPT" onMismatch="DENY"/>
        </FluentdAppender>

    </Appenders>
    <Loggers>
        <Root level="WARN">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="fluentdAppender"/>
        </Root>
        <!--        #####################################################-->
        <!--        # Log Marking-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.util.log.LogMarker" level="INFO" additivity="false">
            <AppenderRef ref="console"/>
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="httpaccesslog"/>
            <AppenderRef ref="httpdumplog"/>
            <AppenderRef ref="sqllog"/>
            <AppenderRef ref="querydsllog"/>
            <AppenderRef ref="slowquerylog"/>
            <AppenderRef ref="slowsqlquerylog"/>
            <AppenderRef ref="xsrflog"/>
            <AppenderRef ref="securitylog"/>
            <AppenderRef ref="outgoingmaillog"/>
            <AppenderRef ref="incomingmaillog"/>
            <AppenderRef ref="remoteappssecurity"/>
            <AppenderRef ref="apdexlog"/>
        </Logger>
        <!--        #####################################################-->
        <!--        # Access logs-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.web.filters.accesslog.AccessLogFilter" level="OFF" additivity="false">
            <AppenderRef ref="httpaccesslog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.filters.accesslog.AccessLogFilterIncludeImages" level="OFF"
                additivity="false">
            <AppenderRef ref="httpaccesslog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.filters.accesslog.AccessLogFilterDump" level="OFF" additivity="false">
            <AppenderRef ref="httpdumplog"/>
        </Logger>
        <!--    #####################################################-->
        <!--    # SQL logs-->
        <!--    #####################################################-->
        <!--    #-->
        <!--    # Beware of turning this log level on.  At INFO level it will log every SQL statement-->
        <!--    # and at DEBUG level it will also log the calling stack trace.  Turning this on will DEGRADE your-->
        <!--    # JIRA database throughput.-->
        <!--    #-->
        <Logger name="com.atlassian.jira.ofbiz.LoggingSQLInterceptor" level="OFF" additivity="false">
            <AppenderRef ref="sqllog"/>
        </Logger>
        <Logger name="com.atlassian.jira.security.xsrf.XsrfVulnerabilityDetectionSQLInterceptor" level="OFF"
                additivity="false">
            <AppenderRef ref="xsrflog"/>
        </Logger>
        <!--    #####################################################-->
        <!--    # Security logs-->
        <!--    #####################################################-->
        <Logger name="com.atlassian.jira.login.security" level="INFO" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>

        <!--        The following log levels can be useful to set when login problems occur within JIRA-->
        <Logger name="com.atlassian.jira.login" level="WARN" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.session.currentusers" level="WARN" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>

        <!--        BEWARE - Turning on Seraph debug logs will result in many logs lines per web request.-->
        <Logger name="com.atlassian.seraph" level="WARN" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>
        <Logger name="com.atlassian.seraph.filter.LoginFilter" level="INFO" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # CLASS-SPECIFIC LOGGING LEVELS-->
        <!--        #####################################################-->
        <!--        # This stuff you may wish to debug, but it produces a high volume of logs.-->
        <!--        # Uncomment only if you want to debug something particular-->
        <Logger name="com.atlassian" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.plugin" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="atlassian.plugin" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="org.twdata.pkgscanner" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.plugin.osgi.factory" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.plugin.osgi.container" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="org.apache.shindig" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.gadgets" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # The directory may produce errors of interest to admins when adding gadgets with features that aren't supported-->
        <!--        # (for example).-->
        <Logger name="com.atlassian.gadgets.directory" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Felix annoyingly dumps some pretty silly INFO level messages. So we have to set logging to WARN here.  Means-->
        <!--        # we miss out on some useful startup logging.  Should probably remove this if Felix ever fix this.-->
        <Logger name="com.atlassian.plugin.osgi.container.felix.FelixOsgiContainerManager" level="WARN"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <Logger name="com.atlassian.plugin.servlet" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.plugin.classloader" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # PluginEnabler spams startup log with 200+ messages about plugins getting enabled-->
        <Logger name="com.atlassian.plugin.manager.PluginEnabler" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # DevModeBeanInitialisationLoggerBeanPostProcessor spams with shit tonne of boring debug-level messages at WARN-->
        <Logger name="com.atlassian.plugin.spring.scanner.extension.DevModeBeanInitialisationLoggerBeanPostProcessor"
                level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.util.system.JiraSystemRestarterImpl" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.upgrade" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <Logger name="com.atlassian.jira.upgrade" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <Logger name="com.atlassian.jira.upgrade.tasks.role" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.startup" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <Logger name="com.atlassian.jira.config.database" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.action.util.LDAPConfigurer" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.imports" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.plugin" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.bc.dataimport" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.security" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.issue.index" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.index.LuceneCorruptionChecker" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.index.AccumulatingResultBuilder" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # DefaultIndexManager should run at INFO level, because we want to see messages when we force an optimise etc.-->
        <Logger name="com.atlassian.jira.issue.index.DefaultIndexManager" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # Allow the Composite IndexLifecycleManager to log info-->
        <Logger name="com.atlassian.jira.util.index" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.project" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.project.version" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.issue.search.providers" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <Logger name="com.atlassian.jira.issue.search.providers.LuceneSearchProvider_SLOW" level="INFO"
                additivity="false">
            <AppenderRef ref="slowquerylog"/>
        </Logger>
        <Logger name="com.atlassian.jira.issue.search.providers.DbSearchProvider_SLOW" level="INFO" additivity="false">
            <AppenderRef ref="slowsqlquerylog"/>
        </Logger>
        <Logger name="com.atlassian.jira.action.admin" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.opensymphony" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.user" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.bc.user" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.workflow" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.service" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.service.services.DebugService" level="DEBUG" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.dispatcher.JiraWebworkActionDispatcher" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="webwork" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="webwork.util.ServletValueStack" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="org.ofbiz.core.entity.jdbc.DatabaseUtil" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="org.ofbiz" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.plugin.ext.perforce" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="logMessage.jsp" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.issue.views" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # Project Imports should be logged at INFO level so we can see the steps running.-->
        <Logger name="com.atlassian.jira.imports.project" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.plugin.userformat.DefaultUserFormats" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.scheduler.JiraSchedulerLauncher" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.sal.jira.scheduling" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="httpclient.wire" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.propertyset.ComponentCachingOfBizPropertyEntryStore" level="INFO"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Crowd Embedded-->
        <!--        #####################################################-->

        <!--        # We want to get INFO level logs about Directory events-->
        <Logger name="com.atlassian.crowd.directory" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        #####################################################-->
        <!--        # REST-->
        <!--        #####################################################-->

        <!--        # only show WARN for WADL generation doclet-->
        <Logger name="com.atlassian.plugins.rest.doclet" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # JRADEV-12012: suppress irrelevant warnings.-->
        <Logger name="com.sun.jersey.spi.container.servlet.WebComponent" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        #####################################################-->
        <!--        # JQL-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.jql" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.jql.resolver" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # UAL-->
        <!--        #####################################################-->

        <Logger name="com.atlassian.applinks" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # ActiveObjects-->
        <!--        #####################################################-->

        <Logger name="net.java.ao" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="net.java.ao.sql" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="net.java.ao.DelegateConnectionHandler" level="WARN" additivity="false">
            <AppenderRef ref="sqllog"/>
        </Logger>
        <Logger name="net.java.ao.schema.SchemaGenerator" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Long Running Tasks-->
        <!--        #####################################################-->

        <Logger name="com.atlassian.jira.workflow.migration" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.action.admin.index.IndexAdminImpl" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # PROFILING-->
        <!--        #####################################################-->

        <Logger name="com.atlassian.util.profiling.filters" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.util.profiling" level="DEBUG" additivity="false">
            <AppenderRef ref="profilerlog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.filters.ThreadLocalQueryProfiler" level="DEBUG" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # By default we ignore some usually harmless exception such as Client Abort Exceptions.  However-->
        <!--        # if this proves problematic then we can turn this to DEBUG log on.-->
        <Logger name="com.atlassian.jira.web.exception.WebExceptionChecker" level="OFF" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Errors in the logs occur at this logger if the user cancels a form upload. The actual exception-->
        <!--        # is rethrown and dealt with elsewhere so there is no need to keep these logs around.-->
        <Logger name="webwork.multipart.MultiPartRequestWrapper" level="OFF" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.plugins.monitor" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Mails-->
        <!--        #####################################################-->

        <!--        #-->
        <!--        # outgoing mail log includes also some logging information from classes which handle both incoming and outgoing mails-->
        <!--        # that's why the appender is configured at com.atlassian.mail level (not com.atlassian.mail.outgoing)-->
        <!--        #-->

        <Logger name="com.atlassian.mail" level="INFO" additivity="false">
            <AppenderRef ref="outgoingmaillog"/>
        </Logger>
        <Logger name="com.atlassian.mail.incoming" level="INFO" additivity="false">
            <AppenderRef ref="incomingmaillog"/>
        </Logger>
        <!--        # changes in mail settings need to be logged-->
        <Logger name="com.atlassian.jira.mail.settings.MailSetting" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # Need to ensure that the actual discovery of duplicates is logged-->
        <Logger name="com.atlassian.jira.upgrade.tasks.UpgradeTask_Build663" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # JRADEV-19240: Suppress useless warnings (will be fixed in atlassian-soy-templates-2.0.0, see SOY-18)-->
        <Logger name="com.atlassian.soy.impl.GetTextFunction" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # JRADEV-19613: Remote should log security messages to a separate log file-->
        <Logger name="com.atlassian.plugin.remotable.plugin.module.oauth.OAuth2LOAuthenticator" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>
        <Logger name="com.atlassian.plugin.remotable.plugin.module.permission.ApiScopingFilter" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>
        <Logger name="com.atlassian.plugin.remotable.plugin.OAuthLinkManager" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>
        <Logger name="com.atlassian.plugin.remotable.plugin.util.http.CachingHttpContentRetriever" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>
        <Logger name="com.atlassian.plugin.remotable.plugin.service.LocalSignedRequestHandler" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>

        <Logger name="com.atlassian.jira.web.bean.BackingI18n" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.studio.jira.homepage.CloudHomepageFilter" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Suppress excessive config warnings from EHCACHE-->
        <Logger name="net.sf.ehcache.config.CacheConfiguration" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # This one so it is in the UI and can be set-->
        <Logger name="net.sf.ehcache.distribution" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.cluster.cache.ehcache.BlockingParallelCacheReplicator" level="WARN"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Asynchronous EHCACHE replication logging-->
        <!--        # when set to DEBUG produces similar logs to BlockingParallelCacheReplicator-->
        <Logger name="com.atlassian.jira.cluster.distribution.localq.LocalQCacheReplicator" level="INFO"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # on INFO logs queue stats per node, on DEBUG logs queue stats per queue-->
        <Logger name="com.atlassian.jira.cluster.distribution.localq.LocalQCacheManager" level="INFO"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Cluster Authentication stats-->
        <Logger name="com.atlassian.jira.cluster.distribution.localq.rmi.auth.ClusterAuthStatsManager" level="INFO"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Decryption of the DB password-->
        <Logger name="com.atlassian.secrets" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <!--        # Logging cache flush events for ALL caches; stacktraces must still be enabled manually.-->
        <Logger name="com.atlassian.cache.event" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.cache.stacktrace" level="OFF" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # Added to give more information on AO startup-->
        <Logger name="com.atlassian.activeobjects.osgi" level="DEBUG" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="jdbc.startup.log" level="INFO" additivity="false">
            <AppenderRef ref="startupjdbc"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Apdex logs-->
        <!--        #####################################################-->
        <!--        #-->
        <Logger name="com.atlassian.jira.apdex.impl.SendAnalyticsJobRunner" level="INFO" additivity="false">
            <AppenderRef ref="apdexlog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # App Diagnostics-->
        <!--        #####################################################-->
        <Logger name="atlassian-diagnostics" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="atlassian-diagnostics-data-logger" level="INFO" additivity="false">
            <AppenderRef ref="diagnostics"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # App Diagnostics - performance metrics logging-->
        <!--        #####################################################-->
        <Logger name="atlassian-performance" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="atlassian-performance-data-logger" level="INFO" additivity="false">
            <AppenderRef ref="performance"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # App Diagnostics - app monitoring logging-->
        <!--        #####################################################-->
        <Logger name="app-monitoring" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="app-monitoring-data-logger" level="INFO" additivity="false">
            <AppenderRef ref="app"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Rate Limiting-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.ratelimiting" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Index replay and DBR-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.index.ha.DefaultNodeReindexService" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.cluster.dbr" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.cluster.distribution.localq" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.index.WriterWrapper" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.index.DefaultIndexEngine" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Startup index fetching-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.cluster.DefaultClusterManager" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.cluster.DefaultIndexFetcher" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # In-product diagnostics monitoring logging-->
        <!--        #####################################################-->
        <Logger name="ipd-monitoring" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="ipd-monitoring-data-logger" level="INFO" additivity="false">
            <AppenderRef ref="ipd"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Integrity Checks-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.appconsistency.integrity.IntegrityChecker" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
    </Loggers>
</Configuration>

 

여기서 <Loggers></Loggers> 태그 안에 가장 하단 부분에 원하는 패키지의 로그 레벨과 출력 부분을 추가해준다. 나의 경우 이렇게 추가했다.

<Logger name="kr.osci.aijql" level="DEBUG" additivity="false">
    <AppenderRef ref="filelog"/>
    <AppenderRef ref="console"/>
</Logger>

 

Step 2.서버 재실행

이렇게 설정한 후 서버 재실행을 해주면 된다! 그럼 atlassian-jira.log 파일에도 로그가 남고, 콘솔에도 로그가 잘 찍힌다!

 

이제 개발하면서 로그를 남기고 볼 수 있다 😆

728x90
반응형
LIST
728x90
반응형
SMALL

이번에는 이벤트 리스너를 등록해보자. 이벤트 리스너란, 이벤트가 발생했을 때 원하는 후처리 작업을 할 수 있는 방법이다.

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 애노테이션을 사용해서 스프링의 라이프 사이클을 이용해 스프링 컨테이너가 완전히 올라왔을 때(플러그인이 띄워질 때)와 스프링 컨테이너가 완전히 내려가기 바로 직전에(플러그인이 내려가기 직전에) 딱 한 번씩만 이벤트 퍼블리셔를 등록할 수 있고, 이벤트 리스너 메서드를 만들 수 있다.

 

공식 문서도 한번 참고해보면 좋을 것 같다.

 

Writing Jira event listeners with the atlassian-event library

Writing Jira event listeners with the atlassian-event library Applicable:Jira 7.1.0 and later.Level of experience:Intermediate. You should have completed at least one beginner tutorial before working through this tutorial. See the list of developer tutoria

developer.atlassian.com

 

보너스. 또다른 이벤트 리스너 예시 코드 (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);
    }
}
728x90
반응형
LIST
728x90
반응형
SMALL

이번에는 서블릿 필터를 만들어보자. 서블릿 필터는 사실 그냥 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가 응답을 만들고, 클라이언트에게 전달되기 전에 응답을 수정하거나, 로깅 등을 할 수 있다. 

필터의 동작 과정

  1. 클라이언트의 요청 수신: 클라이언트로부터 HTTP 요청이 들어오면 웹 서버는 이를 필터 체인(Filter Chain)에 전달한다.
  2. 필터 체인 통과: 요청은 필터 체인을 따라 이동하며, 각 필터는 요청을 처리할 기회를 가진다.
    1. 각 필터는 `doFilter` 메서드를 통해 요청을 처리한다.
    2. 필터는 요청을 다음 필터 또는 서블릿으로 전달할지 여부를 결정할 수 있다.
  3. 서블릿 또는 JSP로 전달: 필터 체인을 모두 통과한 요청은 최종적으로 서블릿이나 JSP에 도달하여 본래의 비즈니스 로직을 수행한다.
  4. 응답 생성: 서블릿이나 JSP가 응답을 생성하면, 응답은 다시 필터 체인을 따라 클라이언트로 돌아간다.
  5. 필터 체인 역순 통과: 응답은 필터 체인을 역순으로 통과하며 각 필터는 응답을 처리할 기회를 가진다.
  6. 클라이언트 응답 전달: 최종적으로 처리된 응답이 클라이언트에게 전달된다.

다음과 같은 필터를 만들어보자!

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 플러그인을 개발하는게 아니면 개발 방식에 따라 필터 등록하는 방법은 다 가지각색이라 목적에 맞는 방법을 찾으면 된다.

 

atlassian-plugin.xml

<servlet-filter key="licenseServletFilter" class="kr.osci.kapproval.admin.servlet.filter.LicenseServletFilter" location="before-dispatch" >
	<url-pattern>/plugins/servlet/kapproval/admin/*</url-pattern>
</servlet-filter>

 

서블릿 필터를 등록하고, url-pattern 태그로 어떤 URL의 요청이 이 필터를 거칠지를 결정하면 된다. 이렇게 설정하면 끝이다.

여기서 location 이라는 attribute가 있다. 이건 이 필터가 어디쯤에 위치할지를 정하는 것이다.

 

나는 `before-dispatch` 라는 값을 주었다. 이게 기본값이고 이건 서블릿 필터 체인의 가장 마지막에 이 필터를 추가하는 것이다. 그러니까 이 요청을 처리하는 서블릿이나 JSP에 도달하기 바로 직전에. 그리고 이러한 옵션들에 대한 내용, 또한 서블릿 필터에 대한 자세한 내용은 아래 공식 문서를 참고하자.

 

Servlet filter

Servlet filter Available:Servlet Filter plugin modules are available in JIRA 4.0 and later. Purpose of this Module Type Servlet Filter plugin modules allow you to deploy Java Servlet filters as a part of your plugin, specifying the location and ordering of

developer.atlassian.com

서블릿 필터가 어떤 원리로 동작하고 어떻게 사용되는지 알아보았다!

 

만약, 요청을 가로채서 하는 작업이 인증/인가를 확인하는 처리라면 인증이 되지 않은 경우 다음 필터 또는 서블릿으로 넘기기 전에 그냥 바로 사용자에게 응답을 돌려줄 수 있다. 예를 들면 이런 코드를 작성할 수 있다.

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()를 호출한 이후 코드가 생성된 응답을 클라이언트에게 최종적으로 전달하기 전 작업하는 부분이 된다.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

당연하게도 JIRA Plugin을 개발할 땐 나만의 JQL Function도 만들 수 있다.

"JQL Function이 뭔가요? JQL 그냥 이런거 아닌가요? `issuekey = TEST-1`".

 

JQL Function은 사용자들이 많이 사용하는 JQL을 아예 함수로 만들어 버린 것들을 말한다. 대표적인 JQL Function은 이런것들이 있다.

  • currentUser()
  • endOfDay()
  • ...

이런게 바로 JQL Function이다. 바로 한번 만들어보자!

참고 자료:

 

Adding a JQL function to Jira

Adding a JQL function to Jira Applicable:Jira 7.0.0 and later.Level of experience:Intermediate. You should have completed at least one beginner tutorial before working through this tutorial. See the list of developer tutorials.Time estimate:It should take

developer.atlassian.com

 

JQL Function 클래스

우선, JQL Function을 만드려면 AbstractJqlFunction이라는 클래스를 상속받아야 한다.

 

그리고 이 클래스는 다음 5가지를 오버라이딩 해야 한다.

  • getFunctionName() : JQL Function 이름을 지정한다.
  • validate() : 유효성 검사 메서드. 
  • getValues() : 실제 JQL Function으로부터 가져올 데이터를 쿼리하는 메서드
  • getMinimumNumberOfExpectedArguments() : 최소한으로 필요한 Arguments 개수
  • getDataType() : 데이터 타입

KapprovalApprovalFunction

package kr.osci.kapproval.com.jira.jql;

import com.atlassian.jira.JiraDataType;
import com.atlassian.jira.JiraDataTypes;
import com.atlassian.jira.jql.operand.QueryLiteral;
import com.atlassian.jira.jql.query.QueryCreationContext;
import com.atlassian.jira.plugin.jql.function.AbstractJqlFunction;
import com.atlassian.jira.plugin.jql.function.JqlFunction;
import com.atlassian.jira.user.ApplicationUser;
import com.atlassian.jira.util.MessageSet;
import com.atlassian.query.clause.TerminalClause;
import com.atlassian.query.operand.FunctionOperand;
import kr.osci.kapproval.admin.service.CustomJiraService;
import kr.osci.kapproval.user.service.KapprovalApprovalService;
import lombok.RequiredArgsConstructor;

import javax.annotation.Nonnull;
import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
public class KapprovalApprovalFunction extends AbstractJqlFunction implements JqlFunction {

    private final KapprovalApprovalService approvalService;

    private final CustomJiraService jiraService;

    @Nonnull
    @Override
    public String getFunctionName() {
        return "kapprovalApproval";
    }

    @Nonnull
    @Override
    public MessageSet validate(ApplicationUser applicationUser,
                               @Nonnull FunctionOperand functionOperand,
                               @Nonnull TerminalClause terminalClause) {
        return validateNumberOfArgs(functionOperand, 0);
    }

    /**
     * 이슈들을 쿼리하는 메서드
     * @param queryCreationContext
     * @param functionOperand
     * @param terminalClause
     * @return
     */
    @Nonnull
    @Override
    public List<QueryLiteral> getValues(@Nonnull QueryCreationContext queryCreationContext,
                                        @Nonnull FunctionOperand functionOperand,
                                        @Nonnull TerminalClause terminalClause) {
        return approvalService
                .getApprovalIssueKeyByApproverId(queryCreationContext.getApplicationUser().getId())
                .stream()
                .map(issueId -> new QueryLiteral(functionOperand, jiraService.getIssueKey(issueId)))
                .collect(Collectors.toList());
    }

    @Override
    public int getMinimumNumberOfExpectedArguments() {
        return 0;
    }

    @Nonnull
    @Override
    public JiraDataType getDataType() {
        return JiraDataTypes.ISSUE;
    }
}

 

위 코드를 보자. 가장 중요한 건 당연하겠지만 데이터를 쿼리하는 메서드인 getValues()이다. 비즈니스 로직에 따라 이 메서드 안에서 내가 이 JQL Function이 어떤 이슈들을 보여줄건지 정해야 한다. 그리고 반환 타입은 List<QueryLiteral>이다. 

 

QueryLiteral의 첫번째 인자는 파라미터로 받는 FunctionOperand 객체를 넘겨주면 된다. 두번째 객체는 String 타입 또는 Long 타입의 가져올 데이터의 값이다. 만약 getDataType()이 반환하는 값이 JiraDataTypes.ISSUE로 되어 있다면 이슈의 키를 반환하면 된다.

 

그래서 위 코드를 보면 결국 반환은 가져온 모든 QueryLiteral 객체 데이터를 리스트로 넘긴다.

이 메서드의 내부 로직은 본인이 원하는 이슈들을 가져오게끔 작성하면 된다. 예를 들어, 이슈의 상태가 `Closed` 상태인 모든 이슈라던가, Assignee가 `XXX`인 사람의 모든 이슈 등 원하는 이슈 쿼리를 하면 된다.

 

JQL Function 리소스 등록

이제 익숙해질때도 됐다. 플러그인의 모든 리소스는 다 Add-on Descriptor(atlassian-plugin.xml)에 등록해야 한다.

그래서 다음과 같이 등록해보자.

<jql-function name="K-Approval Approval" i18n-name-key="jql.approval" key="jqlKapprovalApproval" class="kr.osci.kapproval.com.jira.jql.KapprovalApprovalFunction">
    <fname>Approval</fname>
    <description key="KapprovalApprovalDescription">Approval function</description>
    <list>true</list>
</jql-function>

jql-function 태그의 class attribute를 보자. 이 attribute에 방금 위에서 만든 클래스의 타입(패키지 + 클래스)을 적어주면 된다.

이 부분이 제일 중요하고 나머지는 메타데이터에 가깝다.

 

fname 태그는 그냥 Function 이름이라고 생각하면 된다. 그리고 list 태그는 이 JQL Function의 결과가 단일 이슈인지 리스트인지를 알려준다. 이렇게 등록을 하면 끝난다. 실제로 이 JQL Function이 잘 보일것이다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

이제 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 Itemsection attribute는 `system.top.navigation.bar`라는 미리 정해져 있는 값이다.

그리고 지금껏 보지 못했던 내용이 하나 추가됐는데 바로 `condition` 태그이다. 이 컨디션 태그는 뭐냐면 어떤 특정 조건에 만족할 경우에만 이 상단 네비게이션 바에 버튼(링크)가 보여지게 하는 방법이다. 

 

그리고 이 컨디션은 또한 클래스가 이미 다 정해져있다. 그 중 하나가 바로 `com.atlassian.jira.plugin.webfragment.conditions.UserLoggedInCondition`이다.

클래스 이름만 봐도 유저가 로그인 된 상태인 조건이라고 짐작할 수 있다. 이 조건은 어떻게 알까? 다음 링크를 참고하자. 

 

Web Item conditions

Web Item conditions Web Item Conditions Conditions control whether a given web item will be displayed. com.atlassian.fisheye.plugin.web.conditions.HasCrucible This condition measures whether the product runs with a Crucible license. com.atlassian.fisheye.p

developer.atlassian.com

 

참고로 컨디션을 사용하려면 그냥 태그만 추가해서는 안된다. 이 컨디션의 클래스는 말 그대로 클래스를 가져오는 것이고 클래스를 가져온다는 것은 모듈(라이브러리)가 프로젝트에 포함된 상태여야 한다. 그리고 그건 프로젝트의 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은 상단 네비게이션 바 Itemkey가 되어야 한다. 그래야 그 상단 네비게이션 바에 섹션들이 들어가겠지?라는 합리적인 발상이 가능해진다. 그리고 label 태그는 i18n 리소스를 사용했다. 

 

그 다음, 섹션이 있으면 그 섹션에 각 링크들이 달릴것이다. 그래서 그 링크(버튼)들을 만들어야 한다.

<web-item key="menuKapprovalRequestApproval" i18n-name-key="menu.kapproval.request.approval.name" name="kapprovalRequestApproval" section="topNavKapproval/kapprovalRequestSection" weight="10">
    <label key="menu.kapproval.request.approval.label" ></label>
    <link linkId="menuKapprovalRequestApproval">/issues/?jql=issuekey in kapprovalRequestApproval()</link>
</web-item>

Web Item은 방금 본 Web Section 하단에 달릴 링크(버튼)이다. 

여기서도 역시 중요한건 section이다. 여기에 보면 `topNavKapproval/kapprovalRequestSection`으로 되어 있는데 `topNavKapproval`은 상단 네비게이션 바를 가리키는 Web ItemKey이다. 그리고 `kapprovalRequestSection`은 그 상단 네비게이션 바에 달리는 섹션의 Key이다.

 

전체 코드로 보면 아래와 같다.

<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 key="menuKapprovalRequestApproval" i18n-name-key="menu.kapproval.request.approval.name" name="kapprovalRequestApproval" section="topNavKapproval/kapprovalRequestSection" weight="10">
    <label key="menu.kapproval.request.approval.label" ></label>
    <link linkId="menuKapprovalRequestApproval">/issues/?jql=issuekey in kapprovalRequestApproval()</link>
</web-item>

 

그리고, 그 실질적으로 클릭 가능한 버튼인 `menuKapprovalRequestApproval`이라는 키를 가진 Web Item은 의미있는 링크를 가지고 있다. 그 링크는 특정 JQL을 실행하는 링크이고 이 JQL 함수는 내가 따로 만든 JQL이다. 이건 이후에 설명하겠다. 아무튼 이 버튼을 클릭하면? 지정된 JQL을 실행하는 화면으로 이동한다.

 

그래서 아래 사진이 완성된 화면이다. 

 

이렇게 상단 네비게이션 바에 플러그인의 링크를 노출시킬 수 있다. 그리고 그 각 버튼은 의미있는 화면으로의 이동이 되면 된다. 나같은 경우는 내가 만든 커스텀 JQL 함수를 실행하는 화면으로 이동시키게 만든 것이고 말 나온김에 다음 포스팅은 커스텀 JQL 함수를 만드는 방법을 포스팅 해보겠다.

 

참고로, 섹션을 또 추가하고 싶으면 위 방식대로 Web Section 추가하고 그 섹션 하위에 Web Item들을 위 방식 그대로 똑같이 추가해주면 된다.
728x90
반응형
LIST
728x90
반응형
SMALL

거의 모든 애플리케이션은 데이터가 필요하다. 데이터를 저장하고, 읽고, 쓰는 작업이 안들어가는 애플리케이션은 없을것이다.

JIRA DC 플러그인도 마찬가지로 데이터가 필요한데 이 데이터를 다루기 위해 JIRA 플러그인에서는 AO(ActiveObjects)를 공식적으로 사용한다.

 

그래서 이 AO를 사용하는 방법을 알아본다. 우선, 라이브러리를 내려받아야 한다.

<dependency>
    <groupId>com.atlassian.activeobjects</groupId>
    <artifactId>activeobjects-plugin</artifactId>
    <version>${ao.version}</version>
    <scope>provided</scope>
</dependency>

 

내가 지정한 ao.version은 다음과 같다.

<properties>
    ...
    <ao.version>6.0.0-m03</ao.version>
    ...
</properties>

 

이렇게 라이브러리를 내려받았으면 이제 Entity를 만들어야 한다. 이 AO는 방식이 좀 신기하게 되어있다.

 

KapprovalUserEntity

package kr.osci.kapproval.com.entity;

import net.java.ao.Entity;
import net.java.ao.schema.Indexed;
import net.java.ao.schema.NotNull;
import net.java.ao.schema.StringLength;
import net.java.ao.schema.Table;

@Table("KAPP_USER")
public interface KapprovalUserEntity extends Entity {

      @NotNull
      @Indexed
      Long getUserId();
      void setUserId(Long userId);

      Integer getOrgId();
      void setOrgId(Integer orgId);

      Integer getPositionId();
      void setPositionId(Integer positionId);

      @StringLength(1)
      String getSuperUserYn();
      void setSuperUserYn(String superUserYn);

}

우선 인터페이스를 하나 만들고 net.java.ao.Entity를 상속받아야 한다. 그리고 각 엔티티에 필요한 필드들은 Getter, Setter를 만듦으로써 생성된다. 그리고 Primary Keynet.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도 생성할 수 있다. 그 부분은 이 공식 문서를 참조하자.

 

Developing your plugin with Active Objects

Last updated Jul 12, 2024

developer.atlassian.com

저기서 @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()가 있다.

첫번째 메서드 타입을 선택하면 된다. DBParam 객체를 받아 생성하는 메서드.

KapprovalSettingsEntity entity = activeObjects.create(KapprovalSettingsEntity.class
                    ,new DBParam("LINE_TYPE", "1")
                    ,new DBParam("PASSWORD_USE_YN", "Y")

이런식으로 작성하면 된다. 보면 2번째 파라미터의 타입이 DBParam... 이기 때문에 계속해서 추가적으로 넣어주는게 가능하다.

그리고 DBParam()에는 컬럼명과 값이 들어간다. 여기서 컬럼명이 어떻게 저렇게 되냐? 만약 아래와 같은 엔티티가 있다면,

@Table("KAPP_USER")
public interface KapprovalUserEntity extends Entity {

      @NotNull
      @Indexed
      Long getUserId();
      void setUserId(Long userId);

}

각 대문자 사이에 `_`가 들어간다고 보면 된다. `getUserId` -> `USER_ID`

그래서 각 컬럼에 원하는 값을 DBParam 객체로 하나씩 넣어주면 된다. 물론 위에 오버로딩된 메서드 시그니쳐를 보면 알 수 있듯 Map을 사용하거나 MapList를 사용해도 된다. 

 

Read

한개의 레코드를 찾을땐 get() 메서드를 사용하면 된다.

KapprovalOrgEntity kapprovalOrgEntity = activeObjects.get(KapprovalOrgEntity.class,id);

 

두번째 인자엔 해당 엔티티에 대한 PK를 집어넣으면 된다. 그리고 이 get()메서드도 여러 PK를 넣으면 그 PK에 해당하는 모든 레코드를 가져온다. 다음 사진을 참고해보자. 넣은 PK만큼 배열로 리턴하고 있는것을 확인할 수 있다.

 

그리고 여러개의 레코드를 찾을땐 위 사진과 같이 get()을 사용해도 되지만, 일반적으로 find()를 사용한다.

다음 코드를 보자.

KapprovalUserEntity[] arrKapprovalUserEntity =
                activeObjects.find(KapprovalUserEntity.class, "USER_ID = ? ", jiraUser.getId());

첫번째 인자는 엔티티 타입이다. 어떤 엔티티로부터 조회할건지에 대한 정보.

두번째 인자는 특정 조건이다. 위 코드와 같이 특정 컬럼(USER_ID)값에 대한 조건을 건다.

세번째 인자는 두번째 인자에서 사용되는 Variable에 대한 값이다.

 

다른 방식을 사용할수도 있다. 아예 Query 라는 객체가 있는데, 생김새가 QueryDSL과 유사하게 생겼다.

activeObjects.find(KapprovalApprovalPathMappingEntity.class,
                Query
                        .select()
                        .where("APPROVAL_PATH_ID = ? AND PROJECT_ID = ? AND ISSUE_TYPE_ID = ? AND APPLY_STATUS_ID = ? AND PROCESS_ACTION_ID = ? AND REJECTED_ACTION_ID = ?",
                                approvalPathId,
                                projectId,
                                issueTypeId,
                                applyStatusId,
                                processActionId,
                                rejectedActionId))

그래서 조금 더 복잡한 쿼리에 대해서 아예 객체로 쿼리를 만들어낸다. 

어떤 방법을 사용해도 상관없다. 그리고 어떠한 조건도 넣지 않는다면 그냥 `SELECT * FROM TABLE_NAME`이랑 똑같다.

activeObjects.find(KapprovalApprovalPathMappingEntity.class)
참고로, 아주 많은 양의 데이터를 읽어 들일땐 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

업데이트는 읽어서 쓴다. 즉, 먼저 업데이트 할 객체를 가져와서 그 객체를 변경한 후에 저장하는 방식으로 수행한다.

예를 들어서 이렇게 사용하면 된다.

KapprovalPositionEntity entity = activeObjects.get(KapprovalPositionEntity.class, id);

entity.setXxx(...);

entity.save();

읽고, 변경하고, 저장한다. 오히려 이 방식이 더 좋을수도 있다. 딱 필요한 것만 업데이트 친 후에 변경하기 때문에.

JPA를 써봤던 사람들은 변경감지 개념과 유사하다고 볼 수 있다. 물론 save()라는 메서드를 따로 호출하지 않아도 영속시켰다면 저절로 변경 감지가 일어나서 쿼리가 날라가지만! 

 

Delete

이제 AO는 Delete가 불편하다. 왜냐하면, AO는 Cascade를 지원하지 않는다.

그에 대한 내용은 다음 문서에 있다.

 

Best practices for developing with Active Objects

Best practices for developing with Active Objects This page contains some guidelines for best practices when developing a plugin that uses the Atlassian Active Objects (AO) plugin to store and retrieve data. The information takes the form of a quick refere

developer.atlassian.com

그래서 연관 레코드가 있다면 적절한 순서에 맞춰 먼저 그들을 삭제해 준 후에 원하는 레코드를 삭제해야 한다.

메서드는 간단하다.

activeObjects.delete(Your_Entity);

이는 단일 객체를 삭제하는 방식인데 여러 객체를 삭제해야 한다면 또는 복잡한 쿼리가 필요하다면 deleteWithSQL()을 사용하자.

activeObjects.deleteWithSQL(YourEntity.class, "SPECIFIC_COLUMN_ID = ?", id);

 

 

참고 자료

다른 데이터베이스로 연결하는 방법에 대한 참조 3개:

https://community.developer.atlassian.com/t/how-to-config-atlassian-sdks-jira-to-use-custom-jdbc-database-instead-of-h2/25457/2

 

How to config Atlassian-SDK's Jira to use custom JDBC database instead of H2

Hello @hy.duc.nguyen, The way I did it is to use maven-amps-plugin in my POM and declaring the datasource there. In a nutshell, I did it like this (for mySql but should be similar with Postgre) <plugin> <groupId>com.atlassian.maven.plugins</groupId> <artif

community.developer.atlassian.com

https://developer.atlassian.com/server/framework/atlassian-sdk/declaring-jndi-datasources-in-amps/#declaring-jndi-datasources-in-amps

 

Declaring JNDI datasources in AMPS

Last updated Jul 12, 2024

developer.atlassian.com

https://bitbucket.org/aragot/amps-examples/src/master/start-jira-with-datasource/

 

Bitbucket

 

bitbucket.org

 

728x90
반응형
LIST
728x90
반응형
SMALL

JIRA 플러그인을 개발할 땐 플러그인이 허용하는 범위 내에서 새로운 섹션을 추가할 수 있다.

예를 들어, JIRA 관리자라면 볼 수 있는 화면인 이 화면을 보자.

이 화면에 Manage apps 탭을 선택하면 보이는 좌측 사이드바에 플러그인의 메뉴들을 등록할 수 있다.

사진에 보이는것처럼 K-APPROVAL도 만들고 있는 플러그인의 메뉴이다.

 

Web Section과 Web Item

이걸 어떻게 하는지 하나씩 알아보자. 방법은 크게 2가지가 있다.

  • 배치 스크립트 실행
  • 직접 등록

우선 배치 스크립트를 실행하는건 다음 명령어를 실행하는 것이다.

atlas-create-jira-plugin-module

이 명령어를 프로젝트의 루트 경로, 다른 말로 프로젝트의 pom.xml 파일이 존재하는 경로에서 실행하면 이런 화면이 보일것이다.

이게 커스텀하고 새로 만들어낼 수 있는 여러 모듈들이다. 그 중 Web Section이 보일것이다. 30번. 

이걸 선택해서 진행하면 되는데, 나는 직접 등록하는 방식으로 Admin 화면에 메뉴를 새로 만드는 걸 작성하고자 한다. 이젠 직접 등록하는 방식이 더 편하기도 하고. 

 

그러기 위해선 일단 atlassian-plugin.xml 파일에 아래와 같이 작성을 하나 해준다.

<web-section key="adminPluginsMenuSection" name="k-approval admin plugins menu section" location="admin_plugins_menu" >
    <label key="menu.admin.section"/>
</web-section>

web-section 태그에서 가장 중요한 attribute는 location이다. 이 location은 아무값이나 적을 수 있는게 아니고 지정된 값만을 적을 수 있다. 그리고 그 지정된 값들 중에 관리자 화면에 있는 Manage Apps에 메뉴로 등록할 수 있는 위치는 `admin_plugins_menu`이다.

 

이걸 어떻게 알았냐면 공식 문서를 진짜 열심히 찾다보면 겨우 찾을 수 있다. 찾기가 굉장히 어렵다.

 

Administration UI locations

The section attribute of this web item module (for versions of Jira prior to 4.4) defines which section of the Administration area the web item will appear in.

developer.atlassian.com

위 링크에 들어가면 관리자 화면의 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에 등록해줘야 한다.

그래서 다음과 같이 작성해준다.

atlassian-plugin.xml

<web-item key="approvalManagement" name="k-approval management" section="admin_plugins_menu/adminPluginsMenuSection" >
    <label key="menu.admin.section.approval"/>
    <link>/plugins/servlet/kapproval/admin/approval</link>
</web-item>

여기서 key, name attributes는 역시 원하는 값을 그대로 넣어주면 된다. 중요한 것은 section attribute.

보면 `admin_plugins_menu/adminPluginsMenuSection`이라고 되어 있다. 

admin_plugins_menu 이 부분은 정해져 있는 값이다. 위에서도 다룬 바 있다. 그리고 adminPluginsMenuSection 이 값은 위에 만든 web-section 태그의 key 값이다. 그래서 합쳐지면 관리자 화면의 내가 만든 섹션 하단에 아이템을 넣겠다는 의미가 된다.

 

그리고 여기서도 label은 역시 i18n 리소스를 사용한것이다.

link 태그는 이제 이 버튼(아이템)을 클릭하면 보여질 화면에 대한 path라고 생각하면 된다. 그리고 이건 서블릿을 등록하고 서블릿을 코드를 직접 만들어야 한다. 그래서 일단은 link 태그를 위처럼 작성해두자. 아 물론 만약 버튼을 클릭한게 구글 브라우저를 띄우는 거라면 그냥 아래처럼 하면 된다.

<link>https://www.google.com</link>

아무튼 결국 이 link 태그는 버튼을 클릭하면 이동하는 링크이다.

 

그럼 아래가 위에서 다룬 전체 코드이다.

atlassian-plugin.xml

<web-section key="adminPluginsMenuSection" name="k-approval admin plugins menu section" location="admin_plugins_menu" >
    <label key="menu.admin.section"/>
</web-section>

<web-item key="approvalManagement" name="k-approval management" section="admin_plugins_menu/adminPluginsMenuSection" >
    <label key="menu.admin.section.approval"/>
    <link>/plugins/servlet/kapproval/admin/approval</link>
</web-item>

여기까지 한다고 끝난건 아니다. 이제 서블릿을 등록하고 서블릿 코드를 작성해야 한다. 

 

Web Item에 대한 Servlet

https://developer.atlassian.com/server/framework/atlassian-sdk/creating-an-admin-configuration-form/#render-the-form-with-the-atlassian-template-renderer

 

Creating an admin configuration form

Namespace your keys! Because this is global application configuration data, it is important that you do some kind of namespacing with your keys. Whether you use your plugin key, a class name or something else entirely is up to you. Just make sure it is uni

developer.atlassian.com

 

서블릿을 등록하려면 먼저 서블릿 클래스를 만들어야 한다.

나도 2024년에 서블릿을 직접 사용해서 MVC 구조를 만들줄은 몰랐는데, Atlassian JIRA DC 플러그인을 만들려면 이게 정해진 규칙이니 따라야 했다. 우선 의존성을 내려받아야 한다. 

 

pom.xml

<dependency>
    <groupId>com.atlassian.templaterenderer</groupId>
    <artifactId>atlassian-template-renderer-api</artifactId>
    <version>1.1.1</version>
    <scope>provided</scope>
</dependency>

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>servlet-api</artifactId>
    <version>2.3</version>
    <scope>provided</scope>
</dependency>

두 가지가 필요한데 나는 뷰 템플릿을 사용한다. JIRA Plugin 개발에서 공식문서에 나와있는 뷰 템플릿은 Velocity인데, 다른게 가능한지는 모르겠다. 크게 다른게 없기 때문에 그냥 Velocity를 사용해도 무방하다고 생각한다. 그리고 이 템플릿을 렌더링 하는 Atlassian의 라이브러리인 ATR(Atlassian Template Renderer)를 사용한다.

 

그리고 Servlet을 사용하려면 관련 의존성을 내려 받아야 하기 때문에 두가지 dependencies를 추가한다.

 

이제 Servlet 클래스를 하나 만들어보자.

ApprovalManagementServlet

package kr.osci.kapproval.admin.servlet;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class ApprovalManagementServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {}
}

우선 HttpServlet을 상속받아야 한다. 서블릿을 사용할거라면!

 

첫번째로 할 일은 doGet()을 오버라이딩 하는 것이다. 뭐 GET인 이유는 데이터를 전송하는 과정이 아니라 그저 관리자 화면의 특정 메뉴를 클릭하면 보여지는 화면을 구현할것이기 때문에.

 

그리고, 이게 관리자 화면이다 보니까 지금 요청한 사람이 실제로 관리자인지 아닌지 검증이 필요하다.

그리고 Atlassian에서 제공하는 방법 중 편리하게 사용할 수 있는 WebSudoManager라는게 있다.

 

Adding WebSudo support to your plugin

Adding WebSudo support to your plugin Support for Secure Administrator Sessions (also called websudo) was added in Confluence 3.3 and Jira 4.3. When an administrator who is logged into Confluence or Jira attempts to access an administration function, they

developer.atlassian.com

WebSudoManager가 뭘 해주는지 위 링크에서 자세하게 볼 수 있고 간단하게 설명해서 현재 요청한 사람이 관리자인지 아닌지 체크해서 화면을 보여주거나 로그인 화면으로 이동시키거나 둘 중 하나를 해주는 서비스라고 생각하면 된다.

 

그래서 다음과 같이 WebSudoManager를 필드로 선언해준다.

@ComponentImport private final WebSudoManager webSudo;

참고로 이 친구의 패키지는 다음과 같다.

com.atlassian.sal.api.websudo.WebSudoManager;

그리고 이 라이브러리는 기본으로 이 라이브러리가 내포하고 있다. 그리고 이 라이브러리 역시 기본으로 설정되어 있다.

<dependency>
    <groupId>com.atlassian.jira</groupId>
    <artifactId>jira-api</artifactId>
    <version>${jira.version}</version>
    <scope>provided</scope>
</dependency>

 

그리고 뷰 템플릿을 렌더링 할 것이기 때문에 TemplateRenderer 클래스 역시 주입받아야 한다.

@ComponentImport private final TemplateRenderer renderer;

 

그리고 이전 포스팅에서 스프링 스캐너를 사용해서 애노테이션 기반의 주입을 사용하게 설정했었다. 그래서 @ComponentImport 애노테이션을 사용해서 필요한 모듈을 이렇게 쉽게 주입받을 수가 있다. 이 @ComponentImport는 주입받는 모든것들에 다 적용하는건 아니고 Atlassian 패키지 하위에 있는 모듈들을 주입받을때만 사용하면 된다. 이 말은 이후에 좀 더 잘 이해하게 된다.

 

그래서 가장 먼저 실행할 코드는 다음과 같다.

ApprovalManagementServlet

package kr.osci.kapproval.admin.servlet;

import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.atlassian.sal.api.websudo.WebSudoManager;
import com.atlassian.templaterenderer.TemplateRenderer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
public class ApprovalManagementServlet extends HttpServlet {

    @ComponentImport private final TemplateRenderer renderer;

    @ComponentImport private final WebSudoManager webSudo;

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {

        webSudo.willExecuteWebSudoRequest(req);

    }
}

스프링에 익숙한 사람들은 생성자 주입을 통해 주입받는다는 것을 알 것이다. 그리고 Lombok을 사용하면 @RequiredArgsConstructor 애노테이션으로 편리하게 생성자를 만들 수 있다. 그래서 위 코드가 그 방식을 따르고 있다. 

 

그리고 doGet() 안에서 WebSudoManagerwillExecuteWebSudoRequest(req); 를 호출해주면 현재 요청을 관리자 권한을 가진 유저가 요청한것인지 확인해준다. 그 이후에 코드가 실제 이 서블릿을 호출할 때 실행될 코드가 된다. 특별히 뭐 다른게 필요없다면 바로 뷰 템플릿을 렌더링하면 된다.

renderer.render("templates/admin/approvals.vm", resp.getWriter());

이 코드가 이제 뷰 템플릿을 렌더링하는 코드이다. 뷰 템플릿의 경로는 classpath(`src/main/resources`) 아래 templates에 넣어두면 된다. 이건 스프링 부트랑 다른게 없기 때문에 쉽게 와 닿았다.

 

근데 뷰 템플릿을 사용하는 이유는 동적으로 뷰를 렌더링하기 위함이다. 그렇다는 것은 데이터가 동적으로 변경되고 필요한 데이터가 그때 그때 달라질텐데 그럴때 데이터를 담아 전송하는 방법은 간단하게 Map으로 Key/Value 자료구조를 넘기면 된다.

Map<String, Object> context = new HashMap<>();
context.put("approvalPaths", "pathA");

 

이렇게 만든 context를 다음과 같이 넘긴다.

renderer.render("templates/admin/approval.vm", context, resp.getWriter());

 

render() 메서드는 2가지가 있다. Context를 넘기는 경우와 넘기지 않는 경우. 필요에 따라 넘기거나 넘기지 않거나 알아서 선택하면 된다. 

 

그리고 보통은 비즈니스 로직을 처리하기 위해 필요한 서비스를 주입받는다. 지금까지 작성한 내용은 쉽게 설명하기 위해 그런 내용을 다 뺐지만, 아마 대부분은 특정 서비스를 주입받아 사용할 것이다.

 

임의의 서비스가 아래처럼 있다고 가정해보자. 

public interface ApprovalManagementService {...}
@Slf4j
@Service
@RequiredArgsConstructor
public class ApprovalManagementServiceImpl implements ApprovalManagementService {...}

스프링 스캐너를 사용하고 있기 때문에 @Service 애노테이션으로 간단하게 이 서비스를 스캔할 수 있다.

그리고 주입받는것도 역시 스프링이랑 동일하게 이렇게 주입받으면 된다.

 

ApprovalManagementServlet

package kr.osci.kapproval.admin.servlet;

import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.atlassian.sal.api.websudo.WebSudoManager;
import com.atlassian.templaterenderer.TemplateRenderer;
import kr.osci.kapproval.admin.service.ApprovalManagementService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Slf4j
@RequiredArgsConstructor
public class ApprovalManagementServlet extends HttpServlet {

    private final ApprovalManagementService approvalManagementService;

    @ComponentImport private final TemplateRenderer renderer;

    @ComponentImport private final WebSudoManager webSudo;

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {

        webSudo.willExecuteWebSudoRequest(req);
		
        ...
        
        renderer.render("templates/admin/approval.vm", context, resp.getWriter());
    }
}

그리고 이 서블릿을 역시나 마찬가지로 리소스로 등록해야 한다.

atlassian-plugin.xml

<servlet key="approvalManagementServlet" class="kr.osci.kapproval.admin.servlet.ApprovalManagementServlet">
    <url-pattern>/kapproval/admin/approval</url-pattern>
</servlet>

리소스로 등록할 때 url-pattern 이라는 태그가 있는데 이 태그는 이 서블릿을 호출하는 URL이라고 생각하면 된다.

이 URL이 위에서 등록했던 web-item 태그의 link 경로가 된다.

근데 서블릿은 기본 context path가 존재한다. 그 기본 context path가 `/plugins/servlet`이 된다.

그래서 web-itemlink가 이렇게 등록되는 것이다.

<web-section key="adminPluginsMenuSection" name="k-approval admin plugins menu section" location="admin_plugins_menu" >
    <label key="menu.admin.section"/>
</web-section>

<web-item key="approvalManagement" name="k-approval management" section="admin_plugins_menu/adminPluginsMenuSection" >
    <label key="menu.admin.section.approval"/>
    <link>/plugins/servlet/kapproval/admin/approval</link>
</web-item>

 

이런식으로 서블릿을 만들었으면 서블릿이 렌더링 할 뷰 템플릿이 있어야 한다. renderer.render()에서 파라미터로 작성한 경로 그대로 뷰 템플릿을 Velocity로 만들어서 사용하면 된다. 

src/main/resources/templates/admin/approval.vm

<html>
  <head>
    <title>My Admin</title>
  </head>
  <body>
    <form id="admin">
      <div>
        <label for="name">Name</label>
        <input type="text" id="name" name="name">
      </div>
      <div>
        <label for="age">Age</label>
        <input type="text" id="age" name="age">
      </div>
      <div>
        <input type="submit" value="Save">
      </div>
    </form>
  </body>
</html>

 

더해서, 이 뷰 템플릿에서 사용하고자 하는 자원이 있을 수 있다. 예를 들면 JS, CSS 파일 또는 JIRA Plugin 개발하면서 아주 많이 자주 사용되는 AUI 리소스들. 그런것들을 뷰 템플릿에서 사용하고 싶으면 이 또한 atlassian-plugin.xml 파일에 웹 리소스를 등록해서 그 리소스를 뷰 템플릿에서 가져와야 한다. 

atlassian-plugin.xml

<!-- admin approval management 화면 resources -->
<web-resource key="admin-approval-resources" name="admin approval resource">
    <!-- 화면 모듈 include -->
    <resource type="download" name="approval.css" location="/css/admin/approval.css"/>
    <resource type="download" name="approval.js" location="/js/admin/approval.js"/>

    <transformation extension="js">
        <transformer key="jsI18n"/>
    </transformation>

    <context>kapproval</context>
</web-resource>

이렇게 웹 리소스를 등록한다. 이 경우에는 CSS, JS 파일이 필요한것으로 보인다. 그리고 JS에서도 사용할 i18n 리소스까지 이렇게 웹 리소스를 등록을 한 상태에서 뷰 템플릿에서 가장 상단에 아래 코드를 추가한다.

$webResourceManager.requireResource("<atlassian-plugin-key>:<web-resource-key>")

 

`atlassian-plugin-key`atlassian-plugin.xml 파일에 가장 상단에 있는 key attribute 값을 말한다.

`web-resource-key`는 바로 위에 작성한 web-resource 태그의 key attribute값을 말한다.

 

`atlassian-plugin-key`atlassian-plugin.xml 파일에 가장 최상단에 보이는 atlassian-plugin 태그의 key

<atlassian-plugin key="${atlassian.plugin.key}" name="${project.name}" plugins-version="2">
        ...
</atlassian-plugin>

 

그리고 저 리소스를 추가한 최종 뷰 템플릿 코드는 이렇게 생겼다.

$webResourceManager.requireResource("com.atlassian.tutorial:admin-approval-resources")

<html>
  <head>
    <title>My Admin</title>
  </head>
  <body>
    <form id="admin">
      <div>
        <label for="name">Name</label>
        <input type="text" id="name" name="name">
      </div>
      <div>
        <label for="age">Age</label>
        <input type="text" id="age" name="age">
      </div>
      <div>
        <input type="submit" value="Save">
      </div>
    </form>
  </body>
</html>

 

정리

이게 큰 그림에서의 설정, 리소스 등록, 서비스를 주입하는 방법이다. 이제 그 내부의 비즈니스 로직은 요구사항에 맞게 원하는대로 구현하면 된다. 관리자 화면의 섹션 생성, 섹션 하단에 메뉴 등록, 메뉴에서 보여지는 화면을 호출하는 서블릿, 서블릿에서 주입하는 서비스까지 전부 알아보았다. 서비스 중에선 atlassian 모듈 하위의 서비스인 경우 @ComponentImport 애노테이션을 붙여서 임포트하는 것까지. 

 

다음엔 DB를 연동하고 데이터를 읽고 쓰는 방법까지 알아보자!

728x90
반응형
LIST

+ Recent posts