- PVSM.RU - https://www.pvsm.ru -

RESTful API под Android: pattern B

Совсем недавно, на собеседовании в Яндексе, мне довелось обсуждать организацию Rest-взаимодействия в Android-приложениях. В ходе обсуждения всплыл вопрос – почему из трех паттернов, предложенных на Google IO 2010 Virgil Dobjanschi, первый используется существенно чаще двух других. Вопрос меня заинтересовал.

Поскольку тема обсуждения достаточно узкоспециализированная, я с позволения читателей пропущу слова о том, насколько правильная архитектура Rest-взаимодействия важна в Android-приложениях и как часто Android-разработчики сталкиваются с подобными задачами.

Краткое описание паттернов и обзор

(подробнее [1])

Pattern A Pattern B Pattern C
Используется Service API: Activity -> Service -> Content Provider. В данном варианте Activity работает с API Android Servcie. При необходимости послать REST-запрос Activity создает Service, Service асинхронно посылает запросы к REST-серверу и сохраняет результаты в Content Provider (sqlite). Activity получает уведомление о готовности данных и считывает результаты из Content Provider (sqlite). Используется ContentProvider API: Activity -> Content Provider -> Service. В этом случае Activity работает с API Content Provider, который выступает фасадом для сервиса. Данный подход основан на схожести Content Provider API и REST API: GET REST эквивалентен select-запросу к базе данных, POST REST эквивалентен insert, PUT REST ~ update, DELETE REST ~ delete. Результаты Activity так же загружает из sqlite. Используется Content Provider API + SyncAdapter: Activity -> Content Provider -> Sync Adapter. Вариация подхода "B", в котором вместо сервиса используется собственный Sync Adapter [2]. Activity дает команду Content Provider, который переадресовывает ее в Sync Adapter. Sync Adapter вызывается из Sync Manager, но не сразу, а в "удобный" для системы момент. Т.е. возможны задержки в исполнении команд.
RESTful API под Android: pattern B RESTful API под Android: pattern B RESTful API под Android: pattern B

Беглый обзор подтвердил, что Pattern A используется действительно гораздо шире. Dv в своей замечательной статье «REST под Android. Часть 1: паттерны Virgil Dobjanschi» [3] упоминает целый ряд библиотек и примеров реализации Pattern A (есть такой [4] и на хабре), и всего один пример Pattern B – описание в книге «Programming Android, 2nd Edition» [5] by Zigurd Mednieks, Laird Dornin, G. Blake Meike and Masumi Nakamura [1], в главе 13 «A Content Provider as a Facade for a RESTful Web Service», реализация есть на github.com [6]. Других мне найти не удалось.
Чтение оригинала доклада [1] Virgil Dobjanschi только добавило интриги.

Please note that in this particular pattern we broke the Content Provider contract a little bit. … Again, we’re not forcing you to adopt these particular design patterns.

В общем, не хотите – не используйте. Это воодушевляет.
Предлагаю кратко рассмотреть существующую реализацию Pattern B и попытаться понять, в чём же заключаются его особенности.

Приложение FinchVideo

RESTful API под Android: pattern BСразу отмечу, что этот код писался уважаемым G. Blake Meike в 2012 году, и с тех пор существенно не модифицировался, поэтому мы отнесёмся с пониманием к использованию всяких deprecated конструкций типа managedQuery, неиспользованию таких замечательных вещей как Loader, synchronized (HashMap) вместо ConcurrentHashMap и прочего – на архитектуру приложения они никак не влияют.

Итак, начнём с пользовательского интерфейса. В FinchVideoActivity [7] всё вполне прозрачно – к ListView через SimpleCursorAdapter привязывается Cursor, в который и сливаются результаты запросов managedQuery к FinchVideoContentProvider.
Дальше – интереснее.

FinchVideoContentProvider

public class FinchVideoContentProvider extends RESTfulContentProvider {
    public static final String VIDEO = "video";
    public static final String DATABASE_NAME = VIDEO + ".db";
    static int DATABASE_VERSION = 2;

    public static final String VIDEOS_TABLE_NAME = "video";

    private static final String FINCH_VIDEO_FILE_CACHE = "finch_video_file_cache";

    private static final int VIDEOS = 1;
    private static final int VIDEO_ID = 2;
    private static final int THUMB_VIDEO_ID = 3;
    private static final int THUMB_ID = 4;

    private static UriMatcher sUriMatcher;

    // Statically construct a uri matcher that can detect URIs referencing
    // more than 1 video, a single video, or a single thumb nail image.
    static {
        sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        sUriMatcher.addURI(FinchVideo.AUTHORITY,
                FinchVideo.Videos.VIDEO, VIDEOS);
        // use of the hash character indicates matching of an id
        sUriMatcher.addURI(FinchVideo.AUTHORITY,
                FinchVideo.Videos.VIDEO + "/#",
                VIDEO_ID);
        sUriMatcher.addURI(FinchVideo.AUTHORITY,
                FinchVideo.Videos.THUMB + "/#",
                THUMB_VIDEO_ID);
        sUriMatcher.addURI(FinchVideo.AUTHORITY,
                FinchVideo.Videos.THUMB + "/*",
                THUMB_ID);
    }

    /** uri for querying video, expects appending keywords. */
    private static final String QUERY_URI =
            "http://gdata.youtube.com/feeds/api/videos?" +
                    "max-results=15&format=1&q=";

    private DatabaseHelper mOpenHelper;
    private SQLiteDatabase mDb;

    private static class DatabaseHelper extends SQLiteOpenHelper {
        private DatabaseHelper(Context context, String name,
                               SQLiteDatabase.CursorFactory factory)
        {
            super(context, name, factory, DATABASE_VERSION);
        }

        @Override
        public void onCreate(SQLiteDatabase sqLiteDatabase) {
            createTable(sqLiteDatabase);
        }

        private void createTable(SQLiteDatabase sqLiteDatabase) {
            String createvideoTable =
                    "CREATE TABLE " + VIDEOS_TABLE_NAME + " (" +
                            BaseColumns._ID +
                            " INTEGER PRIMARY KEY AUTOINCREMENT, " +
                            FinchVideo.Videos.TITLE + " TEXT, " +
                            FinchVideo.Videos.DESCRIPTION + " TEXT, " +
                            FinchVideo.Videos.THUMB_URI_NAME + " TEXT," +
                            FinchVideo.Videos.THUMB_WIDTH_NAME + " TEXT," +
                            FinchVideo.Videos.THUMB_HEIGHT_NAME + " TEXT," +
                            FinchVideo.Videos.TIMESTAMP + " TEXT, " +
                            FinchVideo.Videos.QUERY_TEXT_NAME + " TEXT, " +
                            FinchVideo.Videos.MEDIA_ID_NAME + " TEXT UNIQUE," +
                            FinchVideo.Videos.THUMB_CONTENT_URI_NAME +
                            " TEXT UNIQUE," +
                            FinchVideo.Videos._DATA + " TEXT UNIQUE" +
                            ");";
            sqLiteDatabase.execSQL(createvideoTable);
        }

        @Override
        public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldv,
                              int newv)
        {
            sqLiteDatabase.execSQL("DROP TABLE IF EXISTS " +
                    VIDEOS_TABLE_NAME + ";");
            createTable(sqLiteDatabase);
        }
    }

    public FinchVideoContentProvider() {
    }

    public FinchVideoContentProvider(Context context) {
    }

    @Override
    public boolean onCreate() {
        FileHandlerFactory fileHandlerFactory =
                new FileHandlerFactory(new File(getContext().getFilesDir(),
                        FINCH_VIDEO_FILE_CACHE));
        setFileHandlerFactory(fileHandlerFactory);

        mOpenHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null);
        mDb = mOpenHelper.getWritableDatabase();

        return true;
    }

    @Override
    public SQLiteDatabase getDatabase() {
        return mDb;
    }

    /**
     * Content provider query method that converts its parameters into a YouTube
     * RESTful search query.
     *
     * @param uri a reference to the query for videos, the query string can
     * contain, "q='key_words'".  The keywords are sent to the google YouTube
     * API where they are used to search the YouTube video database.
     * @param projection
     * @param where not used in this provider.
     * @param whereArgs not used in this provider.
     * @param sortOrder not used in this provider.
     * @return a cursor containing the results of a YouTube search query.
     */
    @Override
    public Cursor query(Uri uri, String[] projection, String where,
                        String[] whereArgs, String sortOrder)
    {
        Cursor queryCursor;

        int match = sUriMatcher.match(uri);
        switch (match) {
            case VIDEOS:
                // the query is passed out of band of other information passed
                // to this method -- its not an argument.
                String queryText = uri.
                        getQueryParameter(FinchVideo.Videos.QUERY_PARAM_NAME);

                if (queryText == null) {
                    // A null cursor is an acceptable argument to the method,
                    // CursorAdapter.changeCursor(Cursor c), which interprets
                    // the value by canceling all adapter state so that the
                    // component for which the cursor is adapting data will
                    // display no content.
                    return null;
                }

                String select = FinchVideo.Videos.QUERY_TEXT_NAME +
                        " = '" +  queryText + "'";

                // quickly return already matching data
                queryCursor =
                        mDb.query(VIDEOS_TABLE_NAME, projection,
                                select,
                                whereArgs,
                                null,
                                null, sortOrder);

                // make the cursor observe the requested query
                queryCursor.setNotificationUri(
                        getContext().getContentResolver(), uri);

                /**
                 * Always try to update results with the latest data from the
                 * network.
                 *
                 * Spawning an asynchronous load task thread, guarantees that
                 * the load has no chance to block any content provider method,
                 * and therefore no chance to block the UI thread.
                 *
                 * While the request loads, we return the cursor with existing
                 * data to the client.
                 *
                 * If the existing cursor is empty, the UI will render no
                 * content until it receives URI notification.
                 *
                 * Content updates that arrive when the asynchronous network
                 * request completes will appear in the already returned cursor,
                 * since that cursor query will match that of
                 * newly arrived items.
                 */
                if (!"".equals(queryText)) {
                    asyncQueryRequest(queryText, QUERY_URI + encode(queryText));
                }
                break;
            case VIDEO_ID:
            case THUMB_VIDEO_ID:
                long videoID = ContentUris.parseId(uri);
                queryCursor =
                        mDb.query(VIDEOS_TABLE_NAME, projection,
                                BaseColumns._ID + " = " + videoID,
                                whereArgs, null, null, null);
                queryCursor.setNotificationUri(
                        getContext().getContentResolver(), uri);
                break;
            case THUMB_ID:
                String uriString = uri.toString();
                int lastSlash = uriString.lastIndexOf("/");
                String mediaID = uriString.substring(lastSlash + 1);

                queryCursor =
                        mDb.query(VIDEOS_TABLE_NAME, projection,
                                FinchVideo.Videos.MEDIA_ID_NAME + " = " +
                                        mediaID,
                                whereArgs, null, null, null);
                queryCursor.setNotificationUri(
                        getContext().getContentResolver(), uri);
                break;

            default:
                throw new IllegalArgumentException("unsupported uri: " +
                        QUERY_URI);
        }

        return queryCursor;
    }

    /**
     * Provides a handler that can parse YouTube gData RSS content.
     *
     * @param requestTag unique tag identifying this request.
     * @return a YouTubeHandler object.
     */
    @Override
    protected ResponseHandler newResponseHandler(String requestTag) {
        return new YouTubeHandler(this, requestTag);
    }

    /**
     * Provides read only access to files that have been downloaded and stored
     * in the provider cache. Specifically, in this provider, clients can
     * access the files of downloaded thumbnail images.
     */
    @Override
    public ParcelFileDescriptor openFile(Uri uri, String mode)
            throws FileNotFoundException
    {
        // only support read only files
        if (!"r".equals(mode.toLowerCase())) {
            throw new FileNotFoundException("Unsupported mode, " + mode + ", for uri: " + uri);
        }

        return openFileHelper(uri, mode);
    }

    @Override
    public String getType(Uri uri) {
        switch (sUriMatcher.match(uri)) {
            case VIDEOS:
                return FinchVideo.Videos.CONTENT_TYPE;

            case VIDEO_ID:
                return FinchVideo.Videos.CONTENT_VIDEO_TYPE;

            case THUMB_ID:
                return FinchVideo.Videos.CONTENT_THUMB_TYPE;

            default:
                throw new IllegalArgumentException("Unknown video type: " +
                        uri);
        }
    }

    @Override
    public Uri insert(Uri uri, ContentValues initialValues) {
        // Validate the requested uri
        if (sUriMatcher.match(uri) != VIDEOS) {
            throw new IllegalArgumentException("Unknown URI " + uri);
        }

        ContentValues values;
        if (initialValues != null) {
            values = new ContentValues(initialValues);
        } else {
            values = new ContentValues();
        }

        SQLiteDatabase db = getDatabase();
        return insert(uri, initialValues, db);
    }

    private void verifyValues(ContentValues values)
    {
        if (!values.containsKey(FinchVideo.Videos.TITLE)) {
            Resources r = Resources.getSystem();
            values.put(FinchVideo.Videos.TITLE,
                    r.getString(android.R.string.untitled));
        }

        if (!values.containsKey(FinchVideo.Videos.DESCRIPTION)) {
            Resources r = Resources.getSystem();
            values.put(FinchVideo.Videos.DESCRIPTION,
                    r.getString(android.R.string.untitled));
        }

        if (!values.containsKey(FinchVideo.Videos.THUMB_URI_NAME)) {
            throw new IllegalArgumentException("Thumb uri not specified: " +
                    values);
        }

        if (!values.containsKey(FinchVideo.Videos.THUMB_WIDTH_NAME)) {
            throw new IllegalArgumentException("Thumb width not specified: " +
                    values);
        }

        if (!values.containsKey(FinchVideo.Videos.THUMB_HEIGHT_NAME)) {
            throw new IllegalArgumentException("Thumb height not specified: " +
                    values);
        }

        // Make sure that the fields are all set
        if (!values.containsKey(FinchVideo.Videos.TIMESTAMP)) {
            Long now = System.currentTimeMillis();
            values.put(FinchVideo.Videos.TIMESTAMP, now);
        }

        if (!values.containsKey(FinchVideo.Videos.QUERY_TEXT_NAME)) {
            throw new IllegalArgumentException("Query Text not specified: " +
                    values);
        }

        if (!values.containsKey(FinchVideo.Videos.MEDIA_ID_NAME)) {
            throw new IllegalArgumentException("Media ID not specified: " +
                    values);
        }
    }

    /**
     * The delegate insert method, which also takes a database parameter. Note
     * that this method is a direct implementation of a content provider method.
     */
    @Override
    public Uri insert(Uri uri, ContentValues values, SQLiteDatabase db) {
        verifyValues(values);

        // Validate the requested uri
        int m = sUriMatcher.match(uri);
        if (m != VIDEOS) {
            throw new IllegalArgumentException("Unknown URI " + uri);
        }

        // insert the values into a new database row
        String mediaID = (String) values.get(FinchVideo.Videos.MEDIA_ID_NAME);

        Long rowID = mediaExists(db, mediaID);
        if (rowID == null) {
            long time = System.currentTimeMillis();
            values.put(FinchVideo.Videos.TIMESTAMP, time);
            long rowId = db.insert(VIDEOS_TABLE_NAME,
                    FinchVideo.Videos.VIDEO, values);
            if (rowId >= 0) {
                Uri insertUri =
                        ContentUris.withAppendedId(
                                FinchVideo.Videos.CONTENT_URI, rowId);
                getContext().getContentResolver().notifyChange(insertUri, null);
                return insertUri;
            }

            throw new IllegalStateException("could not insert " +
                    "content values: " + values);
        }

        return ContentUris.withAppendedId(FinchVideo.Videos.CONTENT_URI, rowID);
    }

    private Long mediaExists(SQLiteDatabase db, String mediaID) {
        Cursor cursor = null;
        Long rowID = null;
        try {
            cursor = db.query(VIDEOS_TABLE_NAME, null,
                    FinchVideo.Videos.MEDIA_ID_NAME + " = '" + mediaID + "'",
                    null, null, null, null);
            if (cursor.moveToFirst()) {
                rowID = cursor.getLong(FinchVideo.ID_COLUMN);
            }
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
        return rowID;
    }

    @Override
    public int delete(Uri uri, String where, String[] whereArgs) {
        int match = sUriMatcher.match(uri);
        int affected;

        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        switch (match) {
            case VIDEOS:
                affected = db.delete(VIDEOS_TABLE_NAME,
                        (!TextUtils.isEmpty(where) ?
                                " AND (" + where + ')' : ""),
                        whereArgs);
                break;
            case VIDEO_ID:
                long videoId = ContentUris.parseId(uri);
                affected = db.delete(VIDEOS_TABLE_NAME,
                        BaseColumns._ID + "=" + videoId
                                + (!TextUtils.isEmpty(where) ?
                                " AND (" + where + ')' : ""),
                        whereArgs);
                getContext().getContentResolver().notifyChange(uri, null);

                break;
            default:
                throw new IllegalArgumentException("unknown video element: " +
                        uri);
        }

        return affected;
    }

    @Override
    public int update(Uri uri, ContentValues values, String where,
                      String[] whereArgs)
    {
        getContext().getContentResolver().notifyChange(uri, null);

        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        int count;
        switch (sUriMatcher.match(uri)) {
            case VIDEOS:
                count = db.update(VIDEOS_TABLE_NAME, values, where, whereArgs);
                break;

            case VIDEO_ID:
                String videoId = uri.getPathSegments().get(1);
                count = db.update(VIDEOS_TABLE_NAME, values,
                        BaseColumns._ID + "=" + videoId
                                + (!TextUtils.isEmpty(where) ?
                                " AND (" + where + ')' : ""),
                        whereArgs);
                break;

            default:
                throw new IllegalArgumentException("Unknown URI " + uri);
        }

        getContext().getContentResolver().notifyChange(uri, null);
        return count;
    }
}

FinchVideoContentProvider [8] кроме реализации базовых для ContentProvider (query, insert и т.д.) операций к SQLiteDatabase наследует от RESTfulContentProvider [9] механизм запуска http-запросов в отдельных потоках

asyncQueryRequest

public void asyncQueryRequest(String queryTag, String queryUri) {
synchronized (mRequestsInProgress) {
UriRequestTask requestTask = getRequestTask(queryTag);
if (requestTask == null) {
requestTask = newQueryTask(queryTag, queryUri);
Thread t = new Thread(requestTask);
// allows other requests to run in parallel.
t.start();
}
}
}

и HashMap<String, UriRequestTask> — mRequestsInProgress (соответственно, набор выполняемых запросов). Обрабатываются результаты запросов в YouTubeHandler implements ResponseHandler [10], который и передаётся в задачу UriRequestTask [11] при её создании.

Сопоставим существующие классы со схемой Pattern B.
RESTful API под Android: pattern B С Activity и Content Provider всё достаточно ясно. Объект Service в явном виде в примере не используется, его функции и частично функции ServiceHelper по структурированию и запуску запросов выполняет FinchVideoContentProvider. Он же выполняет функции Processor, про Rest method написано выше. Такая вот упрощённая реализация.

Выводы

На основе анализа существующей реализации Pattern B и её описания, я сделал для себя следующие выводы

  1. Самый большой плюс Pattern B, как и описывает автор примера в разделе «Summary of Benefits» [1 — стр. 369] – увеличенная производительность запросов, поскольку они в первую очередь осуществляются к локальной БД (Content Provider);
  2. Обратная сторона этого плюса – рассогласование локальной и серверной БД и усложнённая логика получения данных.
    Неудивительно, что автор примера использовал только query (GET) запрос – это самый простой вариант. Не получили новые данные – возьмём старые из кэша. А если реализовывать insert (PUT)? Нужно будет сначала внести изменения в локальную БД, выставить им (изменениям) флаг «несинхронизировано», потом при неудачной попытке GET-запроса – повторять эту попытку, например с экспоненциально возрастающей паузой (как предлагает автор паттерна) … Всё это время пользователь будет видеть добавленные данные, которых нет на сервере. Более того, что их нет на сервере, он тоже узнать не сможет (см. пункт 3);
  3. И неприятный побочный эффект, связанный с ограниченностью взаимодействия Activity с REST (только через механизмы Content Provider) – в GUI мы не можем получить ничего, кроме данных.
    К примеру, мы никогда не узнаем о причинах отсутствия данных. Ошибка в парсинге? Сервер ничего не вернул? Вообще нет сети? Результат один – нет данных. В реализации Pattern A [4] для этой цели мы могли передать из Activity в ServiceHelper RequestListener. C Content Provider этот номер не пройдёт.
    Конечно, мы можем получить данные, например через Broadcast Receiver, и в обход Content Provider, но к Pattern B это уже не будет иметь отношения.

Таким образом, при использовании Pattern B необходимо учитывать вышеуказанные моменты.

Может быть, кто-нибудь использовал этот паттерн в рабочих проектах или знает более удачные примеры реализации? Есть ли вообще смысл реализовать его более качественно (была такая идея), если за 4 года этим никто не озаботился? Буду рад видеть ответы в комментариях.

Автор: mairos

Источник [12]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/android-development/71817

Ссылки в тексте:

[1] подробнее: https://docs.google.com/file/d/0B2dn_3573C3RdlVpU2JBWXdSb3c/edit

[2] собственный Sync Adapter: http://udinic.wordpress.com/2013/07/24/write-your-own-android-sync-adapter/

[3] «REST под Android. Часть 1: паттерны Virgil Dobjanschi»: http://derevyanko.blogspot.ru/2014/01/rest-android-1.html

[4] есть такой: http://habrahabr.ru/post/176729/

[5] «Programming Android, 2nd Edition»: http://it-ebooks.info/book/1988/

[6] на github.com: https://github.com/bmeike/ProgrammingAndroidExamples/tree/master/FinchVideo

[7] FinchVideoActivity: https://github.com/bmeike/ProgrammingAndroidExamples/blob/master/FinchVideo/src/com/oreilly/demo/android/pa/finchvideo/FinchVideoActivity.java

[8] FinchVideoContentProvider: https://github.com/bmeike/ProgrammingAndroidExamples/blob/master/FinchVideo/src/com/oreilly/demo/android/pa/finchvideo/provider/FinchVideoContentProvider.java

[9] RESTfulContentProvider: https://github.com/bmeike/ProgrammingAndroidExamples/blob/master/FinchFramework/src/com/finchframework/finch/rest/RESTfulContentProvider.java

[10] YouTubeHandler implements ResponseHandler: https://github.com/bmeike/ProgrammingAndroidExamples/blob/master/FinchVideo/src/com/oreilly/demo/android/pa/finchvideo/provider/YouTubeHandler.java

[11] UriRequestTask: https://github.com/bmeike/ProgrammingAndroidExamples/blob/master/FinchFramework/src/com/finchframework/finch/rest/UriRequestTask.java

[12] Источник: http://habrahabr.ru/post/240543/