programing

Spring을 사용한 REST API 버전 관리 방법

copyandpastes 2022. 9. 25. 22:25
반응형

Spring을 사용한 REST API 버전 관리 방법

Spring 3.2.x를 사용하여 REST API 버전을 관리하는 방법을 찾아봤지만 유지보수가 용이한 것을 찾을 수 없었습니다.내가 가진 문제를 먼저 설명하고 그 다음에 해결책을...여기서 바퀴를 다시 발명하는 건 아닌지 궁금하네요

헤더 헤더가 있는 경우 등)를합니다.Accept(승인)application/vnd.company.app-1.1+json봄 MVC에서 이 버전을 처리하는 메서드로 전송해 주었으면 합니다.또한 API의 모든 메서드가 동일한 릴리스에서 변경되는 것은 아니기 때문에 각 컨트롤러로 이동하여 버전 간에 변경되지 않은 핸들러에 대해 아무것도 변경하고 싶지 않습니다.또한 Spring은 이미 어떤 메서드를 호출해야 하는지를 인식하고 있기 때문에 컨트롤러 자체에서 어떤 버전을 사용할지(서비스 로케이터를 사용하여) 결정하는 논리를 가지고 싶지 않습니다.

버전 1.0에서 1.8로 API를 취해서 버전 1.0에서 핸들러가 도입되고 v1.7에서 변경된 경우 다음과 같이 처리하겠습니다.코드가 컨트롤러 안에 있고 헤더에서 버전을 추출할 수 있는 코드가 있다고 상상해 보십시오.(다음은 봄에는 유효하지 않습니다.)

@RequestMapping(...)
@VersionRange(1.0,1.6)
@ResponseBody
public Object method1() {
   // so something
   return object;
}

@RequestMapping(...) //same Request mapping annotation
@VersionRange(1.7)
@ResponseBody
public Object method2() {
   // so something
   return object;
}

이 두 가지 에 이 은 불가능합니다.RequestMapping주석 및 스프링 로드 실패.은 '우리'가 '우리'가VersionRange주석은 열린 버전 또는 닫힌 버전 범위를 정의할 수 있습니다.첫 번째 방법은 버전 1.0에서 1.6까지 유효하며 두 번째 방법은 버전 1.7 이후(최신 버전 1.8 포함)입니다.누군가 버전 99.99를 통과하기로 결정하면 이 접근법이 깨진다는 것을 알지만, 저는 그런 것을 감수해도 괜찮습니다.

스프링의 을 할 수 에 특히 을 수정하여 .ProducesRequestCondition버전 범위를 지정합니다.를 들면, 「」

코드:

@RequestMapping(..., produces = "application/vnd.company.app-[1.0-1.6]+json)
@ResponseBody
public Object method1() {
   // so something
   return object;
}

@RequestMapping(..., produces = "application/vnd.company.app-[1.7-]+json)
@ResponseBody
public Object method2() {
   // so something
   return object;
}

이렇게 하면 주석의 일부를 생성하는 에 정의된 닫힌 버전 범위 또는 열린 버전 범위를 지정할 수 있습니다.Spring MVC 클래스)를 교체해야 가 있어 이 방법을 .RequestMappingInfoHandlerMapping,RequestMappingHandlerMapping ★★★★★★★★★★★★★★★★★」RequestMappingInfo봄의 새로운 버전으로 업그레이드 할 때마다 추가 작업이 필요하기 때문에 마음에 들지 않습니다.

어떤 생각이라도 해주면 고맙겠어...특히, 이것을 보다 간단하고 유지보수가 용이한 방법으로 실시하도록 제안합니다.


편집

현상금 추가.현상금을 받으려면 컨트롤러 자체에 이 논리를 적용하도록 제안하지 말고 위의 질문에 답하십시오.Spring에는 호출할 컨트롤러 메서드를 선택하는 로직이 이미 많이 있습니다.그걸 업고 싶습니다.


편집 2

github에서 원래의 POC(일부 개선사항 있음)를 공유했습니다.https://github.com/augusto/restVersioning

이전 버전과 호환되는 변경(일부 회사 가이드라인에 구속되거나 API 클라이언트가 버그로 구현되어 있지 않아도 고장날 수 있는 경우 항상 가능하지 않을 수 있음)을 수행함으로써 버전 관리를 피할 수 있는지 여부에 관계없이 추상화된 요건은 흥미로운 것입니다.

메서드 본문에서 평가하지 않고 요청에서 헤더 값을 임의로 평가하는 커스텀 요구 매핑을 실행하려면 어떻게 해야 합니까?

SO 답변에서 설명한 바와 같이 실제로 동일한 정보를 얻을 수 있습니다.@RequestMapping실행 시 발생하는 실제 라우팅에서 다른 주석을 사용하여 구별합니다.그러기 위해서는 다음 작업을 수행해야 합니다.

  1. 만들기 새ation create create create create 。VersionRange.
  2. ★★★★의 실장RequestCondition<VersionRange> 때문에 다른 best-match에 을 단 가 있는지 VersionRange값을 지정하면 현재 요구에 더 적합한 값을 얻을 수 있습니다.
  3. ★★★★의 실장VersionRangeRequestMappingHandlerMapping주석 및 요청 조건에 따라(@RequestMapping 커스텀 속성 구현 방법에서 설명)
  4. 을 합니다.VersionRangeRequestMappingHandlerMapping 「」를 에,RequestMappingHandlerMapping('0'은 0'을 클릭합니다.

이것은 스프링 컴포넌트를 제대로 교체할 필요가 없지만 스프링 구성 및 확장 메커니즘을 사용하기 때문에 스프링 버전을 업데이트해도 작동합니다(새로운 버전이 이러한 메커니즘을 지원하는 한).

방금 맞춤형 솔루션을 만들었습니다.를 사용하고 있습니다.@ApiVersion과 를 한 주석@RequestMapping부の ationの ationの @Controller②.

예제:

@Controller
@RequestMapping("x")
@ApiVersion(1)
class MyController {

    @RequestMapping("a")
    void a() {}         // maps to /v1/x/a

    @RequestMapping("b")
    @ApiVersion(2)
    void b() {}         // maps to /v2/x/b

    @RequestMapping("c")
    @ApiVersion({1,3})
    void c() {}         // maps to /v1/x/c
                        //  and to /v3/x/c

}

구현:

ApiVersion.java 주석:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
    int[] value();
}

ApiVersionRequestMappingHandlerMapping.java(대부분 복사앤페이스트)RequestMappingHandlerMapping

public class ApiVersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping {

    private final String prefix;

    public ApiVersionRequestMappingHandlerMapping(String prefix) {
        this.prefix = prefix;
    }

    @Override
    protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
        RequestMappingInfo info = super.getMappingForMethod(method, handlerType);
        if(info == null) return null;

        ApiVersion methodAnnotation = AnnotationUtils.findAnnotation(method, ApiVersion.class);
        if(methodAnnotation != null) {
            RequestCondition<?> methodCondition = getCustomMethodCondition(method);
            // Concatenate our ApiVersion with the usual request mapping
            info = createApiVersionInfo(methodAnnotation, methodCondition).combine(info);
        } else {
            ApiVersion typeAnnotation = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
            if(typeAnnotation != null) {
                RequestCondition<?> typeCondition = getCustomTypeCondition(handlerType);
                // Concatenate our ApiVersion with the usual request mapping
                info = createApiVersionInfo(typeAnnotation, typeCondition).combine(info);
            }
        }

        return info;
    }

    private RequestMappingInfo createApiVersionInfo(ApiVersion annotation, RequestCondition<?> customCondition) {
        int[] values = annotation.value();
        String[] patterns = new String[values.length];
        for(int i=0; i<values.length; i++) {
            // Build the URL prefix
            patterns[i] = prefix+values[i]; 
        }

        return new RequestMappingInfo(
                new PatternsRequestCondition(patterns, getUrlPathHelper(), getPathMatcher(), useSuffixPatternMatch(), useTrailingSlashMatch(), getFileExtensions()),
                new RequestMethodsRequestCondition(),
                new ParamsRequestCondition(),
                new HeadersRequestCondition(),
                new ConsumesRequestCondition(),
                new ProducesRequestCondition(),
                customCondition);
    }

}

WebMvc Configuration Support에 주입:

public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    public RequestMappingHandlerMapping requestMappingHandlerMapping() {
        return new ApiVersionRequestMappingHandlerMapping("v");
    }
}

저는 나머지 버전 관리 문제를 완벽하게 처리하는 솔루션을 구현했습니다.

일반적인 관점에서 말하면, rest 버전 관리에는 3가지 주요 접근법이 있습니다.

  • 패스 베이스의 어프로치.클라이언트가 URL에서 버전을 정의합니다.

    http://localhost:9001/api/v1/user
    http://localhost:9001/api/v2/user
    
  • 클라이언트가 Accept 헤더에 버전을 정의하는 Content-Type 헤더:

    http://localhost:9001/api/v1/user with 
    Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json
    
  • Custom Header: 클라이언트가 커스텀헤더의 버전을 정의합니다.

번째 접근법의 문제는 예를 들어 v1 -> v2에서 버전을 변경하는 경우 v2 경로에 변경되지 않은v1 리소스를 복사 붙여넣어야 한다는 것입니다.

번째 접근법의 문제는 http://swagger.io/ 등의 툴에 따라서는 같은 경로로 다른 콘텐츠 타입의 조작을 구별할 수 없다는 것입니다(https://github.com/OAI/OpenAPI-Specification/issues/146) 참조).

해결 방법

저는 휴식 문서 도구를 많이 사용하고 있기 때문에 첫 번째 방법을 사용하는 것을 선호합니다.이 솔루션은 첫 번째 접근 방식의 문제를 처리하므로 엔드포인트를 새 버전에 복사 붙여넣을 필요가 없습니다.

사용자 컨트롤러의 v1 및 v2 버전이 있다고 가정합니다.

package com.mspapant.example.restVersion.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

/**
 * The user controller.
 *
 * @author : Manos Papantonakos on 19/8/2016.
 */
@Controller
@Api(value = "user", description = "Operations about users")
public class UserController {

    /**
     * Return the user.
     *
     * @return the user
     */
    @ResponseBody
    @RequestMapping(method = RequestMethod.GET, value = "/api/v1/user")
    @ApiOperation(value = "Returns user", notes = "Returns the user", tags = {"GET", "User"})
    public String getUserV1() {
         return "User V1";
    }

    /**
     * Return the user.
     *
     * @return the user
     */
    @ResponseBody
    @RequestMapping(method = RequestMethod.GET, value = "/api/v2/user")
    @ApiOperation(value = "Returns user", notes = "Returns the user", tags = {"GET", "User"})
    public String getUserV2() {
         return "User V2";
    }
 }

사용자 리소스에 대해 v1을 요청하는 경우 "User V1" 응답을 수신해야 하며, 그렇지 않은 경우 v2, v3 등을 요청하는 경우 "User V2" 응답을 수신해야 합니다.

여기에 이미지 설명 입력

봄에 이를 구현하려면 기본 Request Mapping Handler Mapping 동작을 덮어쓸 필요가 있습니다.

package com.mspapant.example.restVersion.conf.mapping;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;

public class VersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping {

    @Value("${server.apiContext}")
    private String apiContext;

    @Value("${server.versionContext}")
    private String versionContext;

    @Override
    protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
        HandlerMethod method = super.lookupHandlerMethod(lookupPath, request);
        if (method == null && lookupPath.contains(getApiAndVersionContext())) {
            String afterAPIURL = lookupPath.substring(lookupPath.indexOf(getApiAndVersionContext()) + getApiAndVersionContext().length());
            String version = afterAPIURL.substring(0, afterAPIURL.indexOf("/"));
            String path = afterAPIURL.substring(version.length() + 1);

            int previousVersion = getPreviousVersion(version);
            if (previousVersion != 0) {
                lookupPath = getApiAndVersionContext() + previousVersion + "/" + path;
                final String lookupFinal = lookupPath;
                return lookupHandlerMethod(lookupPath, new HttpServletRequestWrapper(request) {
                    @Override
                    public String getRequestURI() {
                        return lookupFinal;
                    }

                    @Override
                    public String getServletPath() {
                        return lookupFinal;
                    }});
            }
        }
        return method;
    }

    private String getApiAndVersionContext() {
        return "/" + apiContext + "/" + versionContext;
    }

    private int getPreviousVersion(final String version) {
        return new Integer(version) - 1 ;
    }

}

실장에서는, URL 의 버전을 읽어내, 봄부터 URL 의 해결을 요구합니다.이 URL 이 존재하지 않는 경우(클라이언트가 v3 를 요구한 경우 등), 자원의 최신 버전을 찾을 때까지 v2 를 사용해 보겠습니다.

이 구현의 이점을 확인하기 위해 다음 두 가지 리소스가 있다고 가정합니다.사용자 및 회사:

http://localhost:9001/api/v{version}/user
http://localhost:9001/api/v{version}/company

예를 들어, 고객이 망가지는 회사의 「계약」에 변경을 가했다고 합시다.는 '우리'를 하고 있습니다.http://localhost:9001/api/v2/company클라이언트에 v1 대신 v2로 변경해 달라고 요청합니다.

클라이언트로부터의 새로운 요구는 다음과 같습니다.

http://localhost:9001/api/v2/user
http://localhost:9001/api/v2/company

다음 대신:

http://localhost:9001/api/v1/user
http://localhost:9001/api/v1/company

여기서 가장 좋은 점은 이 솔루션을 사용하면 v2에서 새로운 (같은) 엔드포인트를 생성할 필요 없이 클라이언트가 v1에서 사용자 정보를 얻고 v2에서 회사 정보를 얻을 수 있다는 것입니다.

Rest Documentation 제가 URL 기반 버전 관리 방식을 선택한 이유는 swagger와 같은 일부 툴이 동일한 URL을 가진 엔드포인트를 다르게 문서화하는 것이 아니라 다른 컨텐츠 유형이 다르기 때문입니다.이 솔루션에서는 의 URL이 다른 양쪽 엔드포인트가 표시됩니다.

여기에 이미지 설명 입력

GIT

솔루션 구현: https://github.com/mspapant/restVersioningExample/

URL @RequestMapping에서는 regexp에서 지정할 수 있는 패턴과 경로 파라미터를 지원하므로 버전 관리에 URL을 사용하는 것이 좋습니다.

또한 클라이언트 업그레이드(댓글에서 언급)를 처리하기 위해 '최신'과 같은 별칭을 사용할 수 있습니다.또는 최신 버전을 사용하는 api의 미버전 버전을 가지고 있을 수도 있습니다(예).

또한 경로 파라미터를 사용하여 복잡한 버전 처리 로직을 구현할 수 있습니다.또한 이미 범위를 설정하고 싶은 경우에는 더 빠른 시일 내에 필요한 것이 있을 수 있습니다.

다음은 몇 가지 예를 제시하겠습니다.

@RequestMapping({
    "/**/public_api/1.1/method",
    "/**/public_api/1.2/method",
})
public void method1(){
}

@RequestMapping({
    "/**/public_api/1.3/method"
    "/**/public_api/latest/method"
    "/**/public_api/method" 
})
public void method2(){
}

@RequestMapping({
    "/**/public_api/1.4/method"
    "/**/public_api/beta/method"
})
public void method2(){
}

//handles all 1.* requests
@RequestMapping({
    "/**/public_api/{version:1\\.\\d+}/method"
})
public void methodManual1(@PathVariable("version") String version){
}

//handles 1.0-1.6 range, but somewhat ugly
@RequestMapping({
    "/**/public_api/{version:1\\.[0123456]?}/method"
})
public void methodManual1(@PathVariable("version") String version){
}

//fully manual version handling
@RequestMapping({
    "/**/public_api/{version}/method"
})
public void methodManual2(@PathVariable("version") String version){
    int[] versionParts = getVersionParts(version);
    //manual handling of versions
}

public int[] getVersionParts(String version){
    try{
        String[] versionParts = version.split("\\.");
        int[] result = new int[versionParts.length];
        for(int i=0;i<versionParts.length;i++){
            result[i] = Integer.parseInt(versionParts[i]);
        }
        return result;
    }catch (Exception ex) {
        return null;
    }
}

마지막 접근법에 따라 원하는 것을 실제로 구현할 수 있습니다.

예를 들어 버전 처리를 포함한 메서드스탭만을 포함하는 컨트롤러를 설정할 수 있습니다.

이 처리에서는 (반사/AOP/코드 생성 라이브러리를 사용하여) 일부 스프링 서비스/컴포넌트 또는 동일한 이름/시그니처 및 필수 @VersionRange 메서드의 같은 클래스를 검색하여 모든 파라미터를 사용하여 호출합니다.

@RequestMapping은 ""를 합니다.headers일치하는 요청을 좁힐 수 있는 요소입니다. '어느 때 보다'를 할 수 .Accept머리글

@RequestMapping(headers = {
    "Accept=application/vnd.company.app-1.0+json",
    "Accept=application/vnd.company.app-1.1+json"
})

이것은 범위를 직접 처리하지 않기 때문에 설명과 정확히 일치하지는 않지만 요소는 * 와일드카드 및 !=를 지원합니다.따라서 적어도 모든 버전이 해당 엔드포인트를 지원하는 경우 또는 지정된 메이저버전의 모든 마이너버전(예: 1.*)을 지원하는 경우에는 와일드카드를 사용할 필요가 없습니다.

이 요소는 실제로 사용해 본 적이 없는 것 같습니다(기억이 나지 않는 경우).다음 사이트에서 설명하겠습니다.

http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/bind/annotation/RequestMapping.html

URI Versioning을 사용하여 API 버전을 이미 시도했습니다.다음은 예를 제시하겠습니다.

/api/v1/orders
/api/v2/orders

그러나 이 작업을 수행할 때 몇 가지 과제가 있습니다. 코드를 다른 버전으로 어떻게 구성합니까?두 개 이상의 버전을 동시에 관리하는 방법은 무엇입니까?일부 버전을 삭제하면 어떤 영향이 있습니까?

제가 찾은 최선의 대안은 API 전체를 버전화하는 것이 아니라엔드포인트에서 버전을 제어하는 것이었습니다.이 패턴은 Accept 헤더를 사용한 버전 설정 또는 콘텐츠네고시에이션을 통한 버전 설정이라고 불립니다.

이 방법을 사용하면 API 전체를 버전화하는 대신 단일 리소스 표현을 버전화할 수 있으므로 버전 관리를 보다 세밀하게 제어할 수 있습니다.또한 새 버전을 만들 때 전체 애플리케이션을 분리할 필요가 없으므로 코드 베이스에 설치 공간이 줄어듭니다.이 접근법의 또 다른 장점은 URI 경로를 통한 버전 관리를 통해 도입된 URI 라우팅 규칙을 구현할 필요가 없다는 것입니다.

봄철 실장소

'를 '컨트롤러'로 .producesAtribute: 같은 클래스 내의 각 엔드포인트에 기본적으로 적용됩니다.

@RestController
@RequestMapping(value = "/api/orders/", produces = "application/vnd.company.etc.v1+json")
public class OrderController {

}

그 후, 「주문 작성」을 위한 엔드 포인트의 2가지 버전(v1v2)이 있는 경우를 생각할 수 있습니다.

@Deprecated
@PostMapping
public ResponseEntity<OrderResponse> createV1(
        @RequestBody OrderRequest orderRequest) {

    OrderResponse response = createOrderService.createOrder(orderRequest);
    return new ResponseEntity<>(response, HttpStatus.CREATED);
}

@PostMapping(
        produces = "application/vnd.company.etc.v2+json",
        consumes = "application/vnd.company.etc.v2+json")
public ResponseEntity<OrderResponseV2> createV2(
        @RequestBody OrderRequestV2 orderRequest) {

    OrderResponse response = createOrderService.createOrder(orderRequest);
    return new ResponseEntity<>(response, HttpStatus.CREATED);
}

완료! 원하는 Http Header 버전을 사용하여 각 엔드포인트를 호출합니다.

Content-Type: application/vnd.company.etc.v1+json

또는 v2를 호출하려면:

Content-Type: application/vnd.company.etc.v2+json

고민에 대해서:

API의 모든 메서드가 같은 릴리스에서 변경되는 것은 아니기 때문에 각 컨트롤러로 이동하여 버전 간에 변경되지 않은 핸들러에 대해 아무것도 변경하고 싶지 않습니다.

설명한 바와 같이 이 전략은 각 컨트롤러와 엔드포인트를 실제 버전으로 유지합니다.수정 사항이 있고 새 버전이 필요한 끝점만 수정할 수 있습니다.

그리고 스웨거는?

이 전략을 사용하면 다양한 버전으로 Swagger를 설치하는 것도 매우 간단합니다.자세한 내용은 이 답변을 참조하십시오.

상속을 사용하여 버전 관리를 모델링하는 것은 어떨까요?이것이 제가 프로젝트에서 사용하고 있는 것입니다.특별한 스프링 구성이 필요 없고, 원하는 것을 정확하게 얻을 수 있습니다.

@RestController
@RequestMapping(value = "/test/1")
@Deprecated
public class Test1 {
...Fields Getters Setters...
    @RequestMapping(method = RequestMethod.GET)
    @Deprecated
    public Test getTest(Long id) {
        return serviceClass.getTestById(id);
    }
    @RequestMapping(method = RequestMethod.PUT)
    public Test getTest(Test test) {
        return serviceClass.updateTest(test);
    }

}

@RestController
@RequestMapping(value = "/test/2")
public class Test2 extends Test1 {
...Fields Getters Setters...
    @Override
    @RequestMapping(method = RequestMethod.GET)
    public Test getTest(Long id) {
        return serviceClass.getAUpdated(id);
    }

    @RequestMapping(method = RequestMethod.DELETE)
    public Test deleteTest(Long id) {
        return serviceClass.deleteTestById(id);
    }
}

이 셋업에서는 코드 복제가 거의 이루어지지 않고 새로운 버전의 API로 메서드를 덮어쓸 수 있습니다.또한 버전 전환 로직을 사용하여 소스 코드를 복잡하게 만들 필요가 없습니다.버전에서 엔드포인트를 코드화하지 않으면 엔드포인트는 기본적으로 이전 버전을 가져옵니다.

다른 사람들이 하는 것에 비해 이것은 훨씬 쉬워 보인다.제가 놓친 게 있나요?

이치노력하다, 「1」이라고 하면,produces="!...1.7"2번입니다.

은 products라고 할 수 .produces={"...1.6","!...1.7","...1.8"}한 모든 것을 ) etc(1.7을 제외한 모든 을 받아들인다)

물론 당신이 생각하는 범위만큼 이상적인 것은 아니지만, 만약 이것이 당신의 시스템에서 흔치 않은 것이라면 다른 커스텀 제품보다 유지보수가 더 쉽다고 생각합니다.행운을 빕니다.

AOP를 사용하여 가로채기를 회피할 수 있습니다.

해 주십시오./**/public_api/* 이 에서는 아무것도 않는다; 아무것도 하지 않습니다.

@RequestMapping({
    "/**/public_api/*"
})
public void method2(Model model){
}

끝나고

@Override
public void around(Method method, Object[] args, Object target)
    throws Throwable {
       // look for the requested version from model parameter, call it desired range
       // check the target object for @VersionRange annotation with reflection and acquire version ranges, call the function if it is in the desired range


}

유일한 제약사항은 모든 것이 동일한 컨트롤러 내에 있어야 한다는 것입니다.

AOP 의 설정에 대해서는, http://www.mkyong.com/spring/spring-aop-examples-advice/ 를 참조해 주세요.

언급URL : https://stackoverflow.com/questions/20198275/how-to-manage-rest-api-versioning-with-spring

반응형