Для работы с внешними сервисами по протоколу RESTful API обычно бывает вполне достаточно встроенных средств языка Java или внешних библиотек, используемых в коде приложения там, где это необходимо. Пример - библиотека java.net.http.HttpClient, пользоваться ей очень легко. Для Spring Framework все еще лучше - встроенный RestTemplate позволяет делать почти все. Однако, иногда в разработке может возникнуть ситуация, когда этого недостаточно.

Рассмотрим именно такую ситуацию на примере mlsgrid - коммерческого RestAPI для получения информации об объектах недвижимости. Сервис предоставляет данные из очень большой базы для использования в других сервисах и на коммерческих сайтах в USA. Сервис имеет специфические ограничения, которые диктуются большими объемами передаваемой информации, он построен так, что в нем можно не слишком часто забирать сразу большой объем данных, он не хранит для вас указатели на предыдущие запросы и вообще API очень сильно урезан в возможностях. С другой стороны, на тот момент у меня был заказчик, который заключил договор с этим сервисом и получил коммерческий ключ доступа, и ему было необходимо расширить возможности работы с ним, добавив к нему новые возможности, причем таким способом, чтобы полученный результат можно было удобно переиспользовать. Поэтому мы приняли решение написать внешнюю библиотеку, которую можно опубликовать в корпоративном репозитории и затем подключать как зависимость в любой проект заказчика. Как публиковать библиотеку в собственном репозитории - мы не будем здесь обсуждать, это отдельная большая тема для обсуждения. Мы только посмотрим, каким образом можно сделать такую библиотеку.

Проект собирался в Gradle, приведу здесь файл сборки build.gradle:

plugins {
id "net.ltgt.apt" version "0.21"
id "java-library"
id "maven-publish"
}

group 'com.yamangulov'
version '1.0-SNAPSHOT'

java {
withJavadocJar()
withSourcesJar()
}

sourceCompatibility = 1.8
targetCompatibility = 1.8

ext {
lombokVersion = "1.18.12"
junitJupiterVersion = "5.7.0-M1"
slf4jVersion = "2.0.0-alpha1"
odataVersion = "4.7.1"
awsSdkVersion = "1.11.804"
docsDir = "docs"
buildDir = "s3://repo.chichagodealvaults.org"
def releasesRepoUrl = "${buildDir}/release"
def snapshotsRepoUrl = "${buildDir}/snapshot"
publishUrl = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl
awsAccessKeyId = System.env.AWS_ACCESS_KEY_ID ?: findProperty('AWS_ACCESS_KEY_ID')
awsSecretAccessKey = System.env.AWS_SECRET_ACCESS_KEY ?: findProperty('AWS_SECRET_ACCESS_KEY')
}


publishing {
publications {
mavenJava (MavenPublication) {
groupId = 'com.yamangulov'
artifactId = 'mlsgrid-api-client'
version = '1.6-SNAPSHOT'

            from components.java
        }
    }
    repositories {
        maven {

            url publishUrl

            credentials(AwsCredentials) {
                accessKey = awsAccessKeyId
                secretKey = awsSecretAccessKey
            }

        }
    }
}

repositories {
mavenCentral()
maven {

        url publishUrl

        credentials(AwsCredentials) {
            accessKey = awsAccessKeyId
            secretKey = awsSecretAccessKey
        }

    }
}

dependencies {
compile group: 'org.slf4j', name: 'slf4j-api', version: "${slf4jVersion}"
compile group: 'org.slf4j', name: 'slf4j-simple', version: "${slf4jVersion}"
compileOnly("org.projectlombok:lombok:${lombokVersion}")
annotationProcessor("org.projectlombok:lombok:${lombokVersion}")

    compile group: 'org.apache.olingo', name: 'odata-client-core', version: "${odataVersion}"
    compile group: 'com.amazonaws', name: 'aws-java-sdk', version: "${awsSdkVersion}"

    testCompile group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: "${junitJupiterVersion}"
    testCompile group: 'org.junit.jupiter', name: 'junit-jupiter-params', version: "${junitJupiterVersion}"
    testCompile group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: "${junitJupiterVersion}"
}

task generateDocs(type: Javadoc) {
source = sourceSets.main.allJava
}

logging.captureStandardOutput LogLevel.INFO

Мы видим здесь несколько интересных моментов:

  1. Cервис mlsgrid содержит ссылки на изображения, которые хранятся отдельно от него, заказчику требуется эти изображения выкачать и сохранить в собственное хранилище, в качестве которого был выбран сервис Amazon AWS S3, это сделано для того, чтобы сайт по недвижимости, который будут просматривать посетители, мог сослаться на такое изображение независимо от mlsgrid, который не позволят этого делать очень часто и без ключей доступа. Ключи доступа к mlsapi предполагалось передавать в методах нашей библиотеки, а ключи доступа к AWS S3 - сохранять в системных переменных, откуда они забираются в файле build.gradle.

  2. В библиотеке был использован http клиент odata, на тот момент (два года назад) это было оптимальное решение, очень удобно работавшее с форматом запросов к mlsgrid api. Никто не запрещает вам использовать любой другой клиент, более удобный как для вашего приложения, так и для того конкретного rest api, с которым оно будет работать.

  3. Проект собирается на gradle 6.0.1, с другими версиями могут быть ошибки сборки, будьте внимательны.

  4. Добавлена секция для публикации библиотеки в репозитории, ее не нужно воспроизводить, если вы публикуете свою библиотеку как-то иначе.

Нам понадобится интерфейс для реализации клиентов библиотеки (их может быть несколько разных), соответственно, нужен класс, выполняющий роль фабрики, с перегруженным методом клиента, нужно реализовать сами клиенты с помощью избранных библиотек. В целом проект выглядел вот так:

Мы не будем обсуждать здесь много деталей конкретной реализации клиентов, не это является нашей целью. Наша цель - показать, как именно добавляется дополнительный функционал к бедному функционалу внешнего коммерческого сервиса, который вам нужен. По аналогии вы сможете реализовать собственные фичи вашей собственной библиотеки. И дополнительные фичи, и внешний сервис вместо mlsgrid у вас могут быть вообще совершенно другими, важно понять сам принцип и зачем это нужно.

Итак, вот образец класса:

public class MLSGridFactory {

    //overloaded method for SINGLE factory mode
    public MLSGridClient createClient(String apiUri, String apiKey) {
       return new SingleModeMLSGridClient(apiUri, apiKey);
    }
    //overloaded method for SERVICE factory mode
    public MLSGridClient createClient(String apiUri, String apiKey, String apiServiceKey) {
        return new ServiceModeMLSGridClient(apiUri, apiKey, apiServiceKey);
    }

    //method for fulfill storeState in memento storage for using in SERVICE factory mode
    public void initKeyStore(KeyStore keyStore) {
        try {
            throw new NoSuchMethodException("Method is not implemented");
        } catch (NoSuchMethodException e) {
            log.info(e.getMessage());
        }
    }
}

Здесь я создал перегруженный метод createClient, который может создать клиента нашей библиотеки в двух вариантах. Вы можете добавить сколько угодно реализаций клиента и соответствующее число методов, чтобы можно было 1) создать экземпляр класса, выполняющего роль фабрики, где вам это нужно и 2) создать из него экземпляр клиента нужного вам типа. А вот интерфейс клиента, где можно видеть, какие расширенные методы планировалось реализовать:

public interface MLSGridClient {

    SearchResult searchResult(MLSResource resource, String request);

    SearchResult searchResult(MLSResource resource, String request, int top);

    SearchResult searchResult(URI nextPage);

    void getAndSaveAllImages(String mlsNumber);

    Map<String, String> getAndSaveAllImagesAndReturnMap(String mlsNumber);

    Map<String, List<String>> getMLSLinksFromMLSGrid(List<PropertyTO> propertyTOList);

    void getAndSaveAllImages(String mlsNumber, int limit);

    void initAmazonConnection(String bucketName, String region, String awsAccessKey, String awsSecretKey, MLSGridClient currentGridClient);

    AmazonS3 getAmazonS3();

    void stopTransferManager();
}

А вот пример реализации одного из клиентов:

public class SingleModeMLSGridClient implements MLSGridClient {
    private String apiUri;
    private String apiKey;
    private String bucketName;
    private AmazonS3 amazonS3;
    private MLSGridClient currentGridClient;
    private TransferManager transferManager;

    public SingleModeMLSGridClient(String apiUri, String apiKey) {
        this.apiUri = apiUri;
        this.apiKey = apiKey;
    }

    @Override
    public SearchResult searchResult(MLSResource resource, String request) {
        Client client = new Client(apiUri, apiKey);
        return client.doRequestWithFilter(resource, request);
    }

    @Override
    public SearchResult searchResult(MLSResource resource, String request, int top) {
        Client client = new Client(apiUri, apiKey);
        return client.doRequestWithFilter(resource, request, top);
    }

    @Override
    public SearchResult searchResult(URI nextPage) {
        Client client = new Client(apiUri, apiKey);
        return client.doRequestFromUri(nextPage);
    }

    @Override
    public void initAmazonConnection(String bucketName, String region, String awsAccessKey, String awsSecretKey, MLSGridClient currentGridClient) {
        BasicAWSCredentials credentials = new BasicAWSCredentials(awsAccessKey, awsSecretKey);
        AmazonS3 amazonS3 = AmazonS3ClientBuilder.standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();
        this.bucketName = bucketName;
        this.amazonS3 = amazonS3;
        this.currentGridClient = currentGridClient;
        this.transferManager  = TransferManagerBuilder.standard().withS3Client(amazonS3).build();
    }

    @Override
    public void getAndSaveAllImages(String mlsNumber) {
        SearchResult searchResult = currentGridClient.searchResult(MLSResource.MEDIA, "ResourceRecordID eq '" + mlsNumber + "' and MlgCanView eq true");
        for (PropertyTO media : searchResult.getPropertyTOList()) {
            int order = Integer.parseInt(media.getSingleOption("Order"));
            if (order == 0) {
                TransferMgrUrlCopy.copyFileFromUrl(amazonS3, transferManager, media.getSingleOption("MediaURL"), bucketName, "thumbnail_" + media.getSingleOption("ResourceRecordID") + ".jpg");
            } else {
                TransferMgrUrlCopy.copyFileFromUrl(amazonS3, transferManager, media.getSingleOption("MediaURL"), bucketName, "thumbnail_" + media.getSingleOption("ResourceRecordID") + "_" + media.getSingleOption("Order") +  ".jpg");
            }
        }
    }

    @Override
    public Map<String, String> getAndSaveAllImagesAndReturnMap(String mlsNumber) {
        Map<String, String> mlsLinkToAwsLinkMap = new LinkedHashMap<>();
        SearchResult searchResult = currentGridClient.searchResult(MLSResource.MEDIA, "ResourceRecordID eq '" + mlsNumber + "' and MlgCanView eq true");
        for (PropertyTO media : searchResult.getPropertyTOList()) {
            int order = Integer.parseInt(media.getSingleOption("Order"));
            String mediaURL = media.getSingleOption("MediaURL");
            String awsKey;
            if (order == 0) {
                awsKey = "thumbnail_" + media.getSingleOption("ResourceRecordID") + ".jpg";
                TransferMgrUrlCopy.copyFileFromUrl(amazonS3, transferManager, mediaURL, bucketName, awsKey);
            } else {
                awsKey = "thumbnail_" + media.getSingleOption("ResourceRecordID") + "_" + media.getSingleOption("Order") +  ".jpg";
                TransferMgrUrlCopy.copyFileFromUrl(amazonS3, transferManager, mediaURL, bucketName, awsKey);
            }
            mlsLinkToAwsLinkMap.put(mediaURL, awsKey);
        }
        return mlsLinkToAwsLinkMap;
    }

    private List<String> getMLSLinksFromMLSGrid(String mlsNumber) {
        SearchResult searchResult = currentGridClient.searchResult(MLSResource.MEDIA, "ResourceRecordID eq '" + mlsNumber + "' and MlgCanView eq true");
        List<PropertyTO> propertyTOList = searchResult.getPropertyTOList();
        List<String> mlsLinks = new ArrayList<>();
        for (PropertyTO propertyTO : propertyTOList) {
            String mlsLink = propertyTO.getSingleOption("MediaURL");
            mlsLinks.add(mlsLink);
        }
        return mlsLinks;
    }

    @Override
    public Map<String, List<String>> getMLSLinksFromMLSGrid(List<PropertyTO> propertyTOList) {
        Map<String, List<String>> mlsLinksMap = new LinkedHashMap<>();
        for (PropertyTO propertyTO : propertyTOList) {
            String mlsNumber = propertyTO.getSingleOption("ListingId");
            List<String> mlsLinksForMLSNumber = getMLSLinksFromMLSGrid(mlsNumber);
            mlsLinksMap.put(mlsNumber, mlsLinksForMLSNumber);
        }
        return mlsLinksMap;
    }

    @Override
    public void getAndSaveAllImages(String mlsNumber, int limit) {
        SearchResult searchResult = currentGridClient.searchResult(MLSResource.MEDIA, "ResourceRecordID eq '" + mlsNumber + "' and MlgCanView eq true");
        if (limit > searchResult.getPropertyTOList().size()) {
            log.info("List Media files has less than {} photos. It'll be downloaded all {} files presented in list", limit, searchResult.getPropertyTOList().size());
            limit = searchResult.getPropertyTOList().size();
        }
        for (PropertyTO media : searchResult.getPropertyTOList()) {
            int order = Integer.parseInt(media.getSingleOption("Order"));
            if (order == 0) {
                TransferMgrUrlCopy.copyFileFromUrl(amazonS3, transferManager, media.getSingleOption("MediaURL"), bucketName, "thumbnail_" + media.getSingleOption("ResourceRecordID") + ".jpg");
            } else if (order < limit){
                TransferMgrUrlCopy.copyFileFromUrl(amazonS3, transferManager, media.getSingleOption("MediaURL"), bucketName, "thumbnail_" + media.getSingleOption("ResourceRecordID") + "_" + media.getSingleOption("Order") +  ".jpg");
            }
        }
    }

    @Override
    public void stopTransferManager() {
        transferManager.shutdownNow();
    }
}

Конкретный смысл деталей здесь неважен (хотя имена методов здесь "говорящие" и в принципе понятно, что они делают). Мы видим методы, которые дополняют бедный функционал запросов RestAPI mlsgrid различным поиском по параметрам, постраничной загрузкой данных, загрузкой линков к изображениям и выкачиванием изображений из mlsgrid с сохранением их в AWS S3.

Важен сам принцип построения нашей библиотеки:

  1. мы используем внешний сервис, дополняя его новым функционалом, то есть делаем библиотеку - обертку над mlsgrid;

  2. мы можем опубликовать сервис и переиспользовать его многократно, а не вписывать код в отдельные проекты каждый раз, когда он нам нужен.

Кстати, хочу порекомендовать бесплатный урок по теме "Как сбросить оковы NullPointerException", который пройдет 2 июня на платформе OTUS. Подробнее об уроке.

Комментарии (2)


  1. rmuhamedgaliev
    23.05.2022 20:08
    -4

    А зачем это на Хабре? Вы рассказали как сделали обертку над каким-то апи и все? Своим кодом вы решил конкретно вашу проблему и боль, но причем тут заголовок - "Java библиотека для работы с внешним сервисом по протоколу RESTful API"

    Я понимаю если вы сами реализовали какой нибудь фикс для этого SDK, например ретраер умный или еще чего нибудь.


    1. AYamangulov Автор
      23.05.2022 22:21
      +3

      Хабр богат и разнообразен, его читают разработчики разного уровня подготовки, и для каждого в нем найдется что-то, что полезно для него именно сейчас. Для кого-то будет важным понять, как можно добавить собственный функционал к ограниченным возможностям внешнего API именно с помощью обертки. Опытные разработчики со стажем все это знают и умеют. Не сомневаюсь, что паттерны проектирования отскакивают у них от пальцев при работе. Но многим пригодится, давайте заботиться обо всех, в том числе о джунах. Статья не ставит целью описать полностью и в подробностях весь функционал написанной мной библиотеки-обертки, она показывает сам принцип и для чего такая обертка нужна, о чем я прямо и написал. Надеюсь, что она помогла тем, для кого этот способ представляет нечто новое и незнакомое.