콘텐츠 페이지 매김
페이지 매김 기능을 사용하면 대량의 데이터를 관리 가능한 조각으로 분할할 수 있습니다.
개요
Rapid 콘텐츠 API를 사용하면 대량의 숙박 시설 데이터를 사용할 수 있습니다. 이 데이터는 용량이 크기 때문에 콘텐츠 API는 페이지 매김을 지원하여 데이터를 관리 가능한 조각으로 분할합니다. 이 문서는 페이지 매김 기능 사용을 위한 몇 가지 예와 모범 사례를 제공합니다.
기본 예
페이지 매김 프로세스는 숙박 시설을 검색해서 단일 페이지에 표시될 수 있는 것보다 더 많은 결과를 얻는 것부터 시작됩니다. 이 경우 응답에는 결과의 첫 번째 페이지가 포함되고 다음 페이지로 이동하기 위해 따라갈 수 있는 Link
응답 헤더가 포함됩니다.
요청 예:
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia
응답 헤더 예:
Link: <https://api.ean.com/v3/properties/content?token=WVZTCUNRUQ4SUSNHAXIWFk0VRQ5JZhFWExRaXAgRVnpDA1RWTUkBB10FHFYGQwZyHwBNFA1HBhIMC1IGAUsGBhkHBGcHBBUGdlMHQAd1UA8WBwwMB1NcBAhdahBWUAdRXjtfVwpEBiEdASBHREpdEVwUQRxuRVgRWg1UaVkHS1kcA3IWBXEVAFZMAz1VBVRWXT5KRQNKFQVEACMXASJFVlRBVzoTRQZVQQRVOUdHVUAVDRRXIBNXJxdYAwtWQFJeVgpHAiYTCwoEWhRmZ0MHCxwFJhNbUEcGU1tCHW1dAWwAGlEIEAFVXEYNIRQBIRcTSltIAUVHTTxdAghAU3VDDSFCVkYCXFE8XgIMQwF7QAAlFwVZEVJpTUdWBBcHU2cBXgEKRgFwFVdxR1tWQQtHQhhuUFgAAA5WE1oKH0JcBEZVDGdVBBdVQl4BVQgFVRIVEFwWBBdHS2xKBU1RDANvDFFfX0cNekZTcxJeE1gQW24XDw8RDEdTIUBTJhFTAxZXb1lUAVNRa1ZZAFxHAXQVUHxDVxdDUAxcFRVmVFpQBlRbFFNxEAwgRXcMXAdfFUZbBFQAXFQGV1YCAVI=>; rel="next"; expires=2023-06-01T17:13:19.699379618Z
만료 시간 전에 제공된 링크를 따라가면 결과의 다음 페이지가 그 이후 페이지에 대한 새 Link
헤더와 함께 반환됩니다. 전체 응답을 페이징하려면 더 이상 Link
헤더가 반환되지 않을 때까지 반환되는 각 Link
헤더를 계속 따라갑니다. 이는 요청된 데이터 세트가 더 이상 없음을 의미합니다.
요청된 데이터 필터링
위의 예는 페이지 매김이 어떻게 이루어지는지를 간단하게 보여주지만 동시에 매우 큰 규모의 검색이기도 합니다. 여러 숙박 시설이 있는 경우 모든 숙박 시설을 페이징하는 데 시간이 걸릴 수 있습니다. 추가 쿼리 매개변수를 포함하여 실제로 필요한 숙박 시설만 검색하는 것이 좋습니다.
예를 들어 모든 숙박 시설을 요청하는 대신 미국에 있는 숙박 시설만 필요할 수 있습니다. 이 숙박 시설 하위 집합은 country_code
개체를 사용해 쿼리 매개변수를 포함하도록 요청을 변경하여 요청할 수 있습니다.
국가 매개변수가 포함된 요청 예:
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US
이는 여전히 위와 동일한 페이지 매김 기능을 제공하지만 페이징할 숙박 시설이 더 적습니다.
요청된 숙박 시설 수를 줄이는 또 다른 방법은 숙박 시설 데이터를 마지막으로 가져온 이후에 변경 사항이 있는 숙박 시설만 가져오는 것입니다. date_updated_start
개체를 사용하면 주어진 날짜 이후에 변경 사항이 있는 숙박 시설만 반환됩니다.
국가 및 날짜 매개변수가 포함된 요청 예:
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US&date_updated_start=2023-01-02
페이지 매김 속도를 높이고 전송되는 데이터 양을 줄이려면 필요한 숙박 시설만 요청하는지 확인하는 것이 중요합니다.
병렬화를 위한 검색 분할
때로는 필요한 숙박 시설만 요청하더라도 결과가 너무 많은 경우가 있습니다. 이 경우 신속한 처리를 위해 여러 검색을 병렬로 수행하는 것이 유용합니다.
첫 번째 단계는 원하는 검색을 더 작은 검색으로 나누는 것입니다. 이는 사용 사례에 따라 달라질 수 있지만 원하는 검색으로 시작한 다음 해당 검색에 서로 겹치지 않는 더 많은 쿼리 매개변수를 추가하는 방식으로 진행할 수 있습니다.
예를 들어 미국의 모든 숙박 시설을 검색하려고 한다면 위의 예와 같이 국가별로 필터링하여 시작합니다.
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US
이 검색은 property_rating_min
및 property_rating_max
같은 개체를 사용하여 추가로 분할할 수 있습니다.
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US&property_rating_min=0.0&property_rating_max=0.9
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US&property_rating_min=1.0&property_rating_max=1.9
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US&property_rating_min=2.0&property_rating_max=2.9
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US&property_rating_min=3.0&property_rating_max=3.9
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US&property_rating_min=4.0&property_rating_max=4.9
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US&property_rating_min=5.0
이제 독립적으로 병렬로 페이지 매김을 수행할 수 있는 6개의 개별 요청이 있습니다. 결과로 동일한 데이터 세트를 가져오지만 속도가 더 빠릅니다.
상황에 따라 다를 수 있지만 원하는 검색으로 시작해 응답 첫 페이지의 pagination-total-results
응답 헤더를 보면 검색을 분할하는 것이 도움이 되는지 확인할 수 있습니다.
코드 예
앞서 페이지 매김 프로세스와 데이터 분할 방법을 개념적으로 설명했다면 아래는 보다 구체적인 예를 제공할 수 있는 몇 가지 Java 코드를 보여줍니다.
참고: 적절한 예외 처리 및 기타 모범 사례는 다음 코드 예제에 포함되어 있지 않습니다. 항상 그렇듯이 운영 가능한 코드를 작성할 때는 모든 모범 사례를 따라야 합니다.
시작하려면 간단한 RapidClient
클래스를 기본으로 사용하여 Rapid를 호출할 수 있습니다.
public class RapidClient {
// Base URL
private static final String RAPID_BASE_URL = "https://api.ean.com";
// Headers
private static final String GZIP = "gzip";
private static final String AUTHORIZATION_HEADER = "EAN APIKey={0},Signature={1},timestamp={2}";
// HTTP Client
private static final Client CLIENT = ClientBuilder.newClient().register(GZipEncoder.class);
private final String apiKey;
private final String sharedSecret;
public RapidClient(String apikey, String sharedSecret) {
this.apiKey = apikey;
this.sharedSecret = sharedSecret;
}
public Response get(String path, MultivaluedMap<String, String> queryParameters) {
WebTarget webTarget = CLIENT.target(RAPID_BASE_URL).path(path);
// Add all query parameters from the map to the web target
for (Map.Entry<String, List<String>> entry : queryParameters.entrySet()) {
for (String value : entry.getValue()) {
webTarget = webTarget.queryParam(entry.getKey(), value);
}
}
return webTarget.request(MediaType.APPLICATION_JSON_TYPE)
.header(HttpHeaders.ACCEPT_ENCODING, GZIP)
.header(HttpHeaders.AUTHORIZATION, generateAuthHeader())
.get();
}
private String generateAuthHeader() {
final String timeStampInSeconds = String.valueOf(ZonedDateTime.now(ZoneOffset.UTC).toEpochSecond());
final String input = apiKey + sharedSecret + timeStampInSeconds;
final String signature = DigestUtils.sha512Hex(input);
return MessageFormat.format(AUTHORIZATION_HEADER, apiKey, signature, timeStampInSeconds);
}
}
이는 단순히 다음 클래스를 더 쉽게 읽을 수 있게 해주는 상용구 코드입니다.
다음 클래스는 특정 콘텐츠 API 호출을 나타내며 RapidClient
를 사용하여 해당 호출을 수행합니다.
public class PropertyContentCall {
// Path
private static final String PROPERTY_CONTENT_PATH = "v3/properties/content";
// Headers
private static final String LINK = "Link";
private static final String PAGINATION_TOTAL_RESULTS = "Pagination-Total-Results";
// Query parameters keys
private static final String LANGUAGE = "language";
private static final String SUPPLY_SOURCE = "supply_source";
private static final String COUNTRY_CODE = "country_code";
private static final String CATEGORY_ID_EXCLUDE = "category_id_exclude";
private static final String TOKEN = "token";
private static final String INCLUDE = "include";
// Call parameters
private final RapidClient client;
private final String language;
private final String supplySource;
private final List<String> countryCodes;
private final List<String> categoryIdExcludes;
private String token;
public PropertyContentCall(RapidClient client, String language, String supplySource,
List<String> countryCodes, List<String> categoryIdExcludes) {
this.client = client;
this.language = language;
this.supplySource = supplySource;
this.countryCodes = countryCodes;
this.categoryIdExcludes = categoryIdExcludes;
}
public Stream<RapidPropertyContent> stream() {
return Stream.generate(() -> {
synchronized (this) {
// Make the call to Rapid.
final Response response = client.get(PROPERTY_CONTENT_PATH, queryParameters());
// Read the response to return.
final Map<String, RapidPropertyContent> propertyContents = response.readEntity(new GenericType<>() { });
// Store the token for pagination if we got one.
token = getTokenFromLink(response.getHeaderString(LINK));
return propertyContents;
}
})
.takeWhile(MapUtils::isNotEmpty)
.map(Map::values)
.flatMap(Collection::stream);
}
public Integer size() {
// Make the call to Rapid.
final MultivaluedMap<String, String> queryParameters = queryParameters();
queryParameters.putSingle(INCLUDE, "property_ids");
final Response response = client.get(PROPERTY_CONTENT_PATH, queryParameters);
// Read the size to return.
final Integer size = Integer.parseInt(response.getHeaderString(PAGINATION_TOTAL_RESULTS));
// Close the response since we're not reading it.
response.close();
return size;
}
private MultivaluedMap<String, String> queryParameters() {
final MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
if (token != null) {
queryParams.putSingle(TOKEN, token);
} else {
// Add required parameters
queryParams.putSingle(LANGUAGE, language);
queryParams.putSingle(SUPPLY_SOURCE, supplySource);
// Add optional parameters
if (CollectionUtils.isNotEmpty(countryCodes)) {
queryParams.put(COUNTRY_CODE, countryCodes);
}
if (CollectionUtils.isNotEmpty(categoryIdExcludes)) {
queryParams.put(CATEGORY_ID_EXCLUDE, categoryIdExcludes);
}
}
return queryParams;
}
private String getTokenFromLink(String linkHeader) {
if (StringUtils.isEmpty(linkHeader)) {
return null;
}
final int startOfToken = linkHeader.indexOf("=") + 1;
final int endOfToken = linkHeader.indexOf(">");
return linkHeader.substring(startOfToken, endOfToken);
}
}
PropertyContentCall
은 Rapid 콘텐츠 API에 대한 단일 요청을 나타내며 해당 호출을 통해 끝까지 페이징하는 프로세스를 캡슐화합니다.
예:
아래 API 호출을 이에 상응하는 Java 요청과 비교해 보세요.
https://api.ean.com/v3/properties/content?language=en-US&supply_source=expedia&country_code=US
PropertyContentCall request = new PropertyContentCall(myRapidClient, "en-US", "expedia", List.of("US"), null);
- 여기서는 이 예제에 맞게
PropertyContentCall
을 사용했습니다. 호출은country_code
및category_id_exclude
로 구분되지만 사용 사례에 따라 변경될 수 있습니다. 이 예제는 병렬화를 위해 특별히 작성되었기 때문에 Java Parallel Streams를 사용합니다. publicstream()
메서드는RapidPropertyContent
개체의 스트림을 반환하기 위해 존재합니다.RapidPropertyContent
개체는 단순히 Rapid 콘텐츠 API 호출의 단일 숙박 시설을 나타내는 POJO입니다. 여기서 Java Parallel Streams를 사용하기만 한다면 코드를 병렬로 실행하는 방법은 어떤 것이든 좋습니다. stream()
을 호출하는 코드가 스트림에서 다른 숙박 시설을 읽어야 하는 경우, 이 메서드는 해당 숙박 시설을 이미 검색했으면 해당 숙박 시설을 제공하고 또는 결과의 다음 페이지에 대해 Rapid 콘텐츠 API를 호출하고 해당 페이지의 숙박 시설을 반환합니다. 단순히stream()
을 호출하고 끝까지 읽으면 요청을 통해 반환된 모든 숙박 시설에 대한 페이지 매김이 처리됩니다.- 또 다른 public helper 메서드
size()
를 활용하면PropertyContentCall
에 의해 반환될 총 숙박 시설 수를 쉽게 볼 수 있습니다. 이는 호출이 이미 충분히 작은지 또는 병렬화를 위해 더 작은 호출로 더 분할해야 하는지를 결정하는 데 도움이 될 수 있습니다.
위의 빌딩 블록은 응답을 통해 Rapid를 호출하고 페이지 매김을 위한 기반을 제공합니다. 아래 코드는 위의 클래스를 활용하여 호출을 관리 가능한 조각으로 자동 분할하고 모든 작은 호출을 병렬로 페이징하며 출력을 결합해 파일에 기록합니다.
public class ParallelFileMaker {
private static final String APIKEY = System.getenv().get("RAPID_APIKEY");
private static final String SHARED_SECRET = System.getenv().get("RAPID_SHARED_SECRET");
private static final List<String> COUNTRIES = Arrays.asList("AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ",
"AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM",
"BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK",
"CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC",
"EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG",
"GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT",
"HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH",
"KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV",
"LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT",
"MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ",
"OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO",
"RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR",
"SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR",
"TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF",
"WS", "YE", "YT", "ZA", "ZM", "ZW");
private static final List<String> PROPERTY_CATEGORIES = Arrays.asList("0", "1", "2", "3", "4", "5", "6", "7", "8",
"9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26",
"27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44");
private static final int MAX_CALL_SIZE = 20_000;
private static final String LANGUAGE = "en-US";
private static final String SUPPLY_SOURCE = "expedia";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(new JavaTimeModule());
private static final RapidClient RAPID_CLIENT = new RapidClient(APIKEY, SHARED_SECRET);
public void run() throws IOException {
final Map<PropertyContentCall, Integer> allCalls = divideUpCalls();
// Make sure we're making the calls in the most efficient order. This list will be smallest to largest, so
// that when the streams get combined and are reversed, the largest stream will be first.
final List<Stream<RapidPropertyContent>> callsToMake = allCalls.entrySet().stream()
.filter(entry -> entry.getValue() > 0) // filter out any calls that don't have results
.sorted(Map.Entry.comparingByValue()) // sort all the calls with the smallest calls first
.map(Map.Entry::getKey) // just need the call itself now
.map(PropertyContentCall::stream) // get the stream for each call
.toList();
// Combine all the streams into one big stream and actually make the calls and write to the file.
try (Stream<RapidPropertyContent> bigStream = combineStreams(callsToMake);
BufferedWriter outputFileWriter = createFileWriter(Path.of("output.jsonl.gz"))) {
bigStream.parallel()
.forEach(property -> {
try {
// Write to output file
synchronized (outputFileWriter) {
outputFileWriter.append(OBJECT_MAPPER.writeValueAsString(property));
outputFileWriter.newLine();
}
} catch (Exception e) {
// Handle exception
}
});
}
}
/**
* This will split up the calls to be made based on the size of each call's results. It will first split into
* calls per country and, if needed, it will then further split into calls per category for any country that is
* too big on its own.
* <p>
* Currently, since there is no way to request a specific category, this will instead exclude all other
* categories except the one it wants for that particular call. This can be simplified if more search
* capabilities are added in the future.
* <p>
* The size of each call is also kept so that the calls can be further sorted if needed.
*
* @return A map containing all the calls and their respective sizes.
*/
private Map<PropertyContentCall, Integer> divideUpCalls() {
final Map<PropertyContentCall, Integer> allCalls = new HashMap<>();
COUNTRIES.stream().parallel()
.forEach(countryCode -> {
// Check to see if the entire country is small enough to get at once.
final PropertyContentCall countryCall = new PropertyContentCall(RAPID_CLIENT, LANGUAGE,
SUPPLY_SOURCE, List.of(countryCode), null);
final Integer countryCallSize = countryCall.size();
if (countryCallSize < MAX_CALL_SIZE) {
// It's small enough! No need to break this call up further.
allCalls.put(countryCall, countryCallSize);
} else {
// The country is too big, need to break up the call into smaller parts.
PROPERTY_CATEGORIES.stream().parallel()
.forEach(category -> {
// Exclude every category except the current one, so it's as if we're searching
// for only the current category.
final List<String> excludedCategories = new ArrayList<>(PROPERTY_CATEGORIES);
excludedCategories.remove(category);
final PropertyContentCall categoryCall = new PropertyContentCall(RAPID_CLIENT,
LANGUAGE, SUPPLY_SOURCE, List.of(countryCode), excludedCategories);
allCalls.put(categoryCall, categoryCall.size());
});
}
});
return allCalls;
}
/**
* This will combine multiple Streams into a single Stream. Because of how this is reduced, the Streams will end
* up in the reverse order of the list that was passed in.
* <p>
* Note: Because this is concatenating multiple Streams together, each Stream will go on the stack. Thus, if
* there are many Streams then a StackOverflowException can occur when trying to use the combined Stream. Make
* sure the stack size is appropriate for your usage via the `-Xss` JVM parameter.
*
* @param streams A list of the Streams to combine.
* @return The combined Stream that can be treated as one.
*/
private <T> Stream<T> combineStreams(List<Stream<T>> streams) {
return streams.stream()
.filter(Objects::nonNull)
.reduce(Stream::concat)
.orElse(Stream.empty());
}
private BufferedWriter createFileWriter(Path path) throws IOException {
return new BufferedWriter(
new OutputStreamWriter(
new GZIPOutputStream(
Files.newOutputStream(path)),
StandardCharsets.UTF_8));
}
}
위의 코드에는 다양한 내용을 설명하는 인라인 주석이 많이 있지만 다음과 같이 요약할 수 있습니다.
- 사용 사례에 따라 기본 호출을 더 작은 호출로 분할합니다. (이 예에서 기본 호출이란 모든 것을 가져오는 것이며
country_code
와 필요한 경우category_id_exclude
로 분할할 수 있습니다.) - 이 예에서 병렬 스트림을 결합하는 방식에 따라 호출이 보다 효율적으로 실행되도록 정렬됩니다.
- 그런 다음 호출이 병렬로 실행되고 해당 호출에서 반환된 숙박 시설이 파일에 기록됩니다.