显示导航

使用 Grails 后端构建 Android 客户端

本指南演示如何将 Grails 用作 Android 应用的后端

作者: 塞尔吉奥·德尔·阿莫

Grails 版本 4.0.1

1 Grails 培训

Grails 培训 - 由创建和积极维护 Grails 框架的人员开发和交付!

2 开始

在本指南中,你将构建一个 Grails 应用,用作公司内联网后端。它公开了公司公告的 JSON API。

此外,你将构建一个 Android 应用,这将成为内联网客户端,使用后端提供的 JSON API。

本指南探讨了对不同 API 版本的支持。

2.1 你需要

若要完成本指南,你需要具备以下条件

  • 一点空闲时间

  • 一个像样的文本编辑器或 IDE

  • 安装了 JDK 1.8 或更高版本,并适当地配置了 JAVA_HOME

2.2 如何完成指南

若要完成本指南,你需要从 Github 签出源代码,并完成本指南介绍的步骤。

请按照以下步骤开始

如需了解 Grails 部分

  • cdgrails-guides/building-an-android-client-powered-by-a-grails-backend/initial

或者,如果你已经安装了 Grails,则可以在终端窗口中使用以下命令创建新应用

$ grails create-app intranet.backend.grails-app --profile rest-api
$ cd grails-app

create-app 命令完成后,Grails 会创建一个 grails-app 目录,其中包含配置为创建 REST 应用的应用(由于使用了参数 profile=rest-api),并配置为使用具有 H2 数据库的 hibernate 功能。

如果你 cdgrails-guides/building-an-android-client-powered-by-a-grails-backend/complete,你可以直接跳到已完成的 Grails 示例

如需了解 Android 部分

  • cdgrails-guides/building-an-android-client-powered-by-a-grails-backend/initial-android

  • 转到下一部分

或者,你可以在 Android Studio 的新建项目向导中创建 Android 应用,如下面的屏幕截图所示。

android studio newproject1
android studio newproject2
android studio newproject3
android studio newproject4
如果你 cdgrails-guides/building-an-android-client-powered-by-a-grails-backend/complete-android-v1,你可以直接跳到 Android 示例的已完成版本 1
如果你 cdgrails-guides/building-an-android-client-powered-by-a-grails-backend/complete-android-v2,你可以直接跳到 Android 示例的已完成版本 2

3 概述

下图说明了 Android 应用版本 1 的行为。Android 应用由两项活动组成。

  • 当 Android 应用的初始屏幕加载时,它会请求公告列表。

  • Grails 应用会发送一个 JSON 有效载荷,其中包括公告列表。对于每项公告,都包括一个唯一标识符、一个标题和一个 HTML 正文。

  • Android 应用会在 ListView 中呈现 JSON 有效载荷。

  • 用户点击公告的标题,应用会转换到一个详细屏幕。初始屏幕会将公告的标识符、标题和 HTML 正文发送到详细屏幕。后者会在 WebView 中呈现。

overview

4 编写 Grails 应用

现在你可以开始编写 Grails 应用。

4.1 创建域类 - 持久实体

我们需要创建持久实体来存储公司公告。Grails 使用 Grails 域类来处理持久性

域类满足模型视图控制器 (MVC) 模式中的 M,表示映射到底层数据库表的持久实体。在 Grails 中,域是位于 grails-app/domain 目录中的类。

Grails 使用 create-domain-class 命令简化了域类的创建。

 ./grailsw create-domain-class Announcement
| Resolving Dependencies. Please wait...
CONFIGURE SUCCESSFUL
Total time: 4.53 secs
| Created grails-app/grails/company/intranet/Announcement.groovy
| Created src/test/groovy/grails/company/intranet/AnnouncementSpec.groovy

为了简单起见,我们假定公司公告仅包含一个标题和一个HTML 正文。我们将修改上一步生成的域类来存储该信息。

grails-app/domain/intranet/backend/Announcement.groovy
package intranet.backend

class Announcement {

    String title
    String body

    static constraints = {
        title size: 0..255
        body nullable: true
    }

    static mapping = {
        body type: 'text'  (1)
    }
}
1 它使我们能够在正文中存储长度超过 255 个字符的字符串。

4.2 域类单元测试

Grails 使测试变得更轻松,从低级单元测试到高级功能测试。

我们将测试在 constraints 属性中公布的 Announcements 域类中定义的约束。尤其是 titlebody 属性的非空值性和长度。

src/test/groovy/intranet/backend/AnnouncementSpec.groovy
package intranet.backend

import grails.testing.gorm.DomainUnitTest
import spock.lang.Specification

        expect:
        new Announcement(body: null).validate(['body'])
    }

    void "test title can not be null"() {
        expect:
        !new Announcement(title: null).validate(['title'])
    }

    void "test body can have a more than 255 characters"() {

        when: 'for a string of 256 characters'
        String str = ''
        256.times { str += 'a' }

        then: 'body validation passes'
        new Announcement(body: str).validate(['body'])
    }

    void "test title can have a maximum of 255 characters"() {

        when: 'for a string of 256 characters'
        String str = ''
        256.times { str += 'a' }

        then: 'title validation fails'
        !new Announcement(title: str).validate(['title'])

        when: 'for a string of 256 characters'
        str = ''
        255.times { str += 'a' }

        then: 'title validation passes'
        new Announcement(title: str).validate(['title'])
    }
}

我们可以使用 test_app 命令运行每个测试,包括我们刚刚创建的测试

 ./grailsw test-app
 | Resolving Dependencies. Please wait...
 CONFIGURE SUCCESSFUL
 Total time: 2.534 secs
 :complete:compileJava UP-TO-DATE
 :complete:compileGroovy
 :complete:buildProperties
 :complete:processResources
 :complete:classes
 :complete:compileTestJava UP-TO-DATE
 :complete:compileTestGroovy
 :complete:processTestResources UP-TO-DATE
 :complete:testClasses
 :complete:test
 :complete:compileIntegrationTestJava UP-TO-DATE
 :complete:compileIntegrationTestGroovy UP-TO-DATE
 :complete:processIntegrationTestResources UP-TO-DATE
 :complete:integrationTestClasses UP-TO-DATE
 :complete:integrationTest UP-TO-DATE
 :complete:mergeTestReports

 BUILD SUCCESSFUL

 | Tests PASSED

4.3 版本管理

从一开始就考虑 API 版本管理十分重要,尤其是在您创建供手机应用程序使用的 API 时。用户将运行不同版本的应用程序,您需要对 API 进行版本管理以创建高级功能,同时还要继续支持旧版本。

Grails 允许使用多种方式对 REST 资源进行版本管理

  • 使用 URI

  • 使用 Accept-Version 标头

  • 使用超媒体/MIME 类型

在本指南中,我们将使用 Accept-Version HTTP 标头对 API 进行版本管理。

运行版本 1.0 的设备将在 Accept Version 标头中传入 1.0 时调用公告端点。

$ curl -i -H "Accept-Version: 1.0" -X GET http://localhost:8080/announcements

运行版本 2.0 的设备将在 Accept Version 标头中传入 2.0 时调用公告端点。

$ curl -i -H "Accept-Version: 2.0" -X GET http://localhost:8080/announcements

4.4 创建一个控制器

我们为之前创建的域类创建一个控制器。我们的控制器继承 RestfulController。这将为我们提供使用不同的 HTTP 方法列出、创建、更新和删除 Announcement 资源的 RESTful 功能。

grails-app/controllers/intranet/backend/v1/AnnouncementController.groovy
package intranet.backend.v1

import grails.rest.RestfulController
import intranet.backend.Announcement

class AnnouncementController extends RestfulController<Announcement> {
    static namespace = 'v1' (1)
    static responseFormats = ['json'] (2)

    AnnouncementController() {
        super(Announcement)
    }
}
1 此控制器将处理我们 API 的 v1
2 我们只想响应 JSON 有效负载

URL 映射

我们希望我们的端点在 /announcements 中进行侦听,而不是 /announcement。此外,我们希望之前控制器(我们为此声明了一个 v1 名称空间)处理在 Accept-Version Http 标头设置为 1.0 时发出的的请求。

Grails 允许使用强大的 URL 映射 配置来实现此目的。在映射闭包中添加下一行

/grails-app/controllers/intranet/backend/UrlMappings.groovy
        get "/announcements"(version:'1.0', controller: 'announcement', namespace:'v1')

4.5 加载测试数据

应用程序启动时,我们将使用多个公告填充数据库。

为了实现此目的,我们编辑 grails-app/init/grails/company/intranet/BootStrap.groovy

package grails.company.intranet

class BootStrap {

    def init = { servletContext ->
        announcements().each { it.save() }
    }
    def destroy = {
    }

    static List<Announcement> announcements() {
        [
                new Announcement(title: 'Grails Quickcast #1: Grails Interceptors'),
                new Announcement(title: 'Grails Quickcast #2: JSON Views')
        ]
    }
}
前面代码片段中的公告不包含body 内容,旨在保持代码样本精简。签出grails-app/init/intranet/backend/BootStrap.groovy以查看完整代码。

4.6 功能测试

功能测试涉及针对正在运行的应用程序发出 HTTP 请求并验证由此产生的行为。

我们使用Rest Client Builder Grails 插件,其依赖项在我们使用rest-api 配置文件创建应用程序时添加。

/home/runner/work/building-an-android-client-powered-by-a-grails-backend/building-an-android-client-powered-by-a-grails-backend/complete

/src/integration-test/groovy/intranet/backend/AnnouncementControllerSpec.groovy
package intranet.backend

import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import grails.web.http.HttpHeaders
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification


@Integration
class AnnouncementControllerSpec extends Specification {

    @Shared
    @AutoCleanup
    HttpClient client

    @OnceBefore
    void init() {
        String baseUrl = "http://localhost:$serverPort"
        this.client  = HttpClient.create(baseUrl.toURL())
    }

    def "test body is present in announcements json payload of Api 1.0"() {
        given:
        HttpRequest request = HttpRequest.GET("/announcements/").header("Accept-Version", "1.0")

        when: 'Requesting announcements for version 1.0'
        HttpResponse<List<Map>> resp = client.toBlocking().exchange(request, List) (1)

        then: 'the request was successful'
        resp.status == HttpStatus.OK (3)

        and: 'the response is a JSON Payload'
        and: 'json payload contains the complete announcement'
1 serverPort 会自动注入。它包含在功能测试期间 Grails 应用程序运行的随机端口
2 将 API 版本作为 Http 标头传递
3 验证响应代码为 200;正常
4 Body 在 JSON 有效负载中

Grails 命令test-app运行单元、集成和功能测试。

4.7 运行应用程序

要运行应用程序,请使用 ./gradlew bootRun 命令,该命令将在端口 8080 上启动应用程序。

5 编写安卓应用程序

5.1 获取公告

下图说明了 Grails 应用程序公开的公告的提取和呈现所涉及的类。

android announcements overview

5.2 网络代码

我们有一个类,其中初始化了多个常量

/app/src/main/java/intranet/client/network/Constants.java
package intranet.client.network;

public class Constants {
    static final String GRAILS_APP_URL = "http://192.168.1.42:8080/"; (1)
    static final String ANNOUNCEMENTS_PATH = "announcements"; (2)
    static final String ACCEPT_VERSION = "1.0"; (3)
}
1 Grails 应用程序服务器 URL。
2 我们在 Grails 应用程序的UrlMappings.groovy中配置的路径
3 API 的版本
您可能需要更改 IP 地址以匹配本地计算机。

模型

服务器发送的公告将呈现到此 POJO 中

/app/src/main/java/intranet/client/network/model/Announcement.java
package intranet.client.network.model;

public class Announcement {
    private Long id;
    private String title;
    private String body;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }
}

网络依赖项

您需要将 INTERNET 权限添加到app/src/main/AndroidManifest.xml

/app/src/main/AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET"/>

OkHttp

我们将使用最流行的 Android http 客户端之一,OkHttp

要添加 OkHttp 作为依赖项,请编辑文件 app/build.gradle并在依赖项块中添加下一行

/app/build.gradle
    implementation 'com.squareup.okhttp3:okhttp:4.2.2'

Gson

Gson是一个 Java 库,可用于将 Java 对象转换为其 JSON 表示形式。它还可用于将 JSON 字符串转换为等效的 Java 对象。

要添加 Gson 作为依赖项,请编辑文件 app/build.gradle并在依赖项块中添加下一行

/app/build.gradle
    implementation 'com.google.code.gson:gson:2.8.6'

我们将封装 OkHttp 请求的实例化到一个类中,以确保 Accept-Version Http 标头始终使用在Constants.java类中定义的值进行设置

/app/src/main/java/intranet/client/network/NetworkTask.java
package intranet.client.network;

import okhttp3.Request;

class NetworkTask {
    static Request requestWithUrl(String url) {
        return new Request.Builder()
                .url(url)
                .header("Accept-Version", Constants.ACCEPT_VERSION)
                .build();
    }
}

下一个类获取并返回公告列表。

/app/src/main/java/intranet/client/network/AnnouncementsFetcher.java
package intranet.client.network;

import android.util.Log;

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;

import java.io.IOException;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;

import intranet.client.network.model.Announcement;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class AnnouncementsFetcher {

    private final static String TAG = AnnouncementsFetcher.class.getSimpleName();

    private OkHttpClient client = new OkHttpClient();
    private final Gson gson = new Gson();

    public List<Announcement> fetchAnnouncements() {
        Type listType = new TypeToken<List<Announcement>>() {}.getType();
        try {
            String url = Constants.GRAILS_APP_URL + Constants.ANNOUNCEMENTS_PATH;
            String jsonString = fetchAnnouncementsJsonString(url);
            return gson.fromJson(jsonString, listType);

        } catch (IOException e) {
            Log.e(TAG, e.toString());
            return new ArrayList<>();
        }
    }

    private String fetchAnnouncementsJsonString(String url) throws IOException {
        Request request = NetworkTask.requestWithUrl(url);
        Response response = client.newCall(request).execute();
        return response.body().string();
    }
}

为避免在用户界面线程中运行联网代码,我们需要将联网代码封装在 AsyncTask

/app/src/main/java/intranet/client/android/asynctasks/RetrieveAnnouncementsTask.java
package intranet.client.android.asynctasks;

import android.os.AsyncTask;

import java.util.List;

import intranet.client.android.delegates.RetrieveAnnouncementsDelegate;
import intranet.client.network.AnnouncementsFetcher;
import intranet.client.network.model.Announcement;

public class RetrieveAnnouncementsTask extends AsyncTask<Void, Void, List<Announcement>> {

    private AnnouncementsFetcher fetcher = new AnnouncementsFetcher();
    private RetrieveAnnouncementsDelegate delegate;

    public RetrieveAnnouncementsTask(RetrieveAnnouncementsDelegate delegate) {
        this.delegate = delegate;
    }

    @Override
    protected List<Announcement> doInBackground(Void... voids) {
        return fetcher.fetchAnnouncements();
    }

    protected void onPostExecute(List<Announcement> announcements) {
        if ( delegate != null ) {
            delegate.onAnnouncementsFetched(announcements);
        }
    }
}

一旦获取到公告列表,我们将向实现委托的类发送响应

/app/src/main/java/intranet/client/android/delegates/RetrieveAnnouncementsDelegate.java
package intranet.client.android.delegates;

import java.util.List;

import intranet.client.network.model.Announcement;

public interface RetrieveAnnouncementsDelegate {
    void onAnnouncementsFetched(List<Announcement> announcements);
}

接收公告的委托实现类是初始活动

/app/src/main/java/intranet/client/android/activities/MainActivity.java
package intranet.client.android.activities;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.widget.ListView;

import java.util.ArrayList;
import java.util.List;

import intranet.client.R;
import intranet.client.android.adapters.AnnouncementAdapter;
import intranet.client.android.asynctasks.RetrieveAnnouncementsTask;
import intranet.client.android.delegates.AnnouncementAdapterDelegate;
import intranet.client.android.delegates.RetrieveAnnouncementsDelegate;
import intranet.client.network.model.Announcement;

public class MainActivity extends Activity
        implements RetrieveAnnouncementsDelegate, AnnouncementAdapterDelegate {

    public static final String EXTRA_ID = "id";
    public static final String EXTRA_TITLE = "title";
    public static final String EXTRA_BODY = "body";
    private AnnouncementAdapter adapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        ListView announcementsListView = (ListView) findViewById(R.id.announcementsListView);
        adapter = new AnnouncementAdapter(this, new ArrayList<Announcement>(), this);
        announcementsListView.setAdapter(adapter);

        new RetrieveAnnouncementsTask(this).execute(); (1)
    }

    @Override
    public void onAnnouncementsFetched(List<Announcement> announcements) {  (2)
        adapter.clear();
        adapter.addAll(announcements);
    }

    @Override
    public void onAnnouncementTapped(Announcement announcement) {
        segueToAnnouncementActivity(announcement);
    }

    private void segueToAnnouncementActivity(Announcement announcement) {
        Intent i = new Intent(this, AnnouncementActivity.class);
        i.putExtra(EXTRA_ID, announcement.getId());
        i.putExtra(EXTRA_TITLE, announcement.getTitle());
        i.putExtra(EXTRA_BODY, announcement.getBody());
        startActivity(i);
    }
}
1 触发异步任务以异步获取公告。
2 在获取到公告列表后刷新用户界面

MainActivity 使用在

/app/src/main/res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="grails.company.client.intranet.client.MainActivity">

    <ListView
        android:id="@+id/announcementsListView"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    </ListView>

</RelativeLayout>

中定义的 ListView。

/app/src/main/java/intranet/client/android/adapters/AnnouncementAdapter.java
package intranet.client.android.adapters;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;

import java.util.List;

import androidx.annotation.NonNull;
import intranet.client.R;
import intranet.client.android.delegates.AnnouncementAdapterDelegate;
import intranet.client.network.model.Announcement;

public class AnnouncementAdapter extends ArrayAdapter<Announcement> {
    private AnnouncementAdapterDelegate delegate;

    public AnnouncementAdapter(Context context, List<Announcement> announcements, AnnouncementAdapterDelegate delegate) {
        super(context, 0, announcements);
        this.delegate = delegate;
    }

    @NonNull
    public View getView(int position, View convertView, @NonNull ViewGroup parent) {
        if (convertView == null) {
            convertView = LayoutInflater.from(getContext()).inflate(R.layout.item_announcement, parent, false);
        }
        TextView tvTitle = (TextView) convertView.findViewById(R.id.tvTitle);
        Announcement announcement = getItem(position);
        if ( announcement != null ) {
            tvTitle.setText(announcement.getTitle());
            tvTitle.setOnClickListener(new AnnouncementClickListener(announcement));
        }
        return convertView;
    }

    private class AnnouncementClickListener implements View.OnClickListener {
        private Announcement announcement;

        AnnouncementClickListener(Announcement announcement) {
            this.announcement = announcement;
        }

        @Override
        public void onClick(View view) {
            if ( delegate != null ) {
                delegate.onAnnouncementTapped(announcement);
            }
        }
    }
}
/app/src/main/java/intranet/client/android/delegates/AnnouncementAdapterDelegate.java
package intranet.client.android.delegates;

import intranet.client.network.model.Announcement;

public interface AnnouncementAdapterDelegate {
    void onAnnouncementTapped(Announcement announcement);
}
/app/src/main/res/layout/item_announcement.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <TextView
        android:id="@+id/tvTitle"
        android:paddingTop="@dimen/activity_vertical_margin"
        android:paddingBottom="@dimen/activity_vertical_margin"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
</LinearLayout>

5.3 详情活动

当用户点击公告时,该公告将使用 Android Intent Extras 发送

extras

您需要向清单添加第二个活动。

/app/src/main/AndroidManifest.xml
        <activity android:name=".android.activities.AnnouncementActivity" />
/app/src/main/java/intranet/client/android/activities/AnnouncementActivity.java
package intranet.client.android.activities;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.webkit.WebView;
import android.widget.TextView;

import intranet.client.R;

public class AnnouncementActivity extends Activity {

    private TextView tvTitle;
    private WebView wvBody;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_announcement);

        tvTitle = (TextView) findViewById(R.id.tvTitle);
        wvBody = (WebView) findViewById(R.id.wvBody);
        populateUi();
    }

    private void populateUi() {
        Intent intent = getIntent();
        final String title = intent.getStringExtra(MainActivity.EXTRA_TITLE);
        final String body = intent.getStringExtra(MainActivity.EXTRA_BODY);
        populateUiWithTitleAndBody(title, body);
    }

    private void populateUiWithTitleAndBody(final String title, final String body) {
        tvTitle.setText(title);
        final String mime = "text/html";
        final String encoding = "utf-8";
        wvBody.loadDataWithBaseURL(null, body, mime, encoding, null);
    }
}
/app/src/main/res/layout/activity_announcement.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="grails.company.client.intranet.client.MainActivity">

    <TextView
        android:id="@+id/tvTitle"
        tools:text="Announcement Title"
        android:textSize="20dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <WebView
        android:id="@+id/wvBody"
        android:layout_below="@+id/tvTitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</RelativeLayout>

6 API 版本 2.0

API 的第一个版本存在的问题在于,我们将每个公告正文都包含在用于显示列表的有效载荷中。公告正文可能是一大段 HTML。用户可能只想查看一些公告。如果我们在初始请求中不发送公告正文,那么这将节省带宽并提高应用程序的速度。相反,一旦用户点击公告,我们将向 API 请求完整的公告(包括body)。

version2overview

6.1 Grails V2 变更

我们准备使用创建控制器来处理 API 的版本 2。我们将使用带有投影的条件查询,以仅获取公告的 id 和标题。

grails-app/controllers/intranet/backend/v2/AnnouncementController.groovy
package intranet.backend.v2

import grails.rest.RestfulController
import intranet.backend.Announcement

class AnnouncementController extends RestfulController<Announcement> {
    static namespace = 'v2'
    static responseFormats = ['json']

    def announcementService

    AnnouncementController() {
        super(Announcement)
    }

    def index(Integer max) {
        params.max = Math.min(max ?: 10, 100)
        def announcements = announcementService.findAllIdAndTitleProjections(params)
        respond announcements, model: [("${resourceName}Count".toString()): countResources()]
    }
}

我们将在服务中封装查询

grails-app/services/intranet/backend/AnnouncementService.groovy
package intranet.backend

import grails.gorm.transactions.Transactional

@Transactional(readOnly = true)
class AnnouncementService {

    List<Map> findAllIdAndTitleProjections(Map params) {
        def c = Announcement.createCriteria()
        def announcements = c.list(params) {
            projections {
                property('id')
                property('title')
            }
        }.collect { [id: it[0], title: it[1]] } as List<Map>
    }
}

并对其进行测试

/src/test/groovy/intranet/backend/AnnouncementServiceSpec.groovy
package intranet.backend

import grails.test.hibernate.HibernateSpec
import grails.testing.services.ServiceUnitTest

class AnnouncementServiceSpec extends HibernateSpec implements ServiceUnitTest<AnnouncementService> {

    def "test criteria query with projection returns a list of maps"() {

        when: 'Save some announcements'
        [new Announcement(title: 'Grails Quickcast #1: Grails Interceptors'),
        new Announcement(title: 'Grails Quickcast #2: JSON Views'),
        new Announcement(title: 'Grails Quickcast #3: Multi-Project Builds'),
        new Announcement(title: 'Grails Quickcast #4: Angular Scaffolding'),
        new Announcement(title: 'Retrieving Runtime Config Values In Grails 3'),
        new Announcement(title: 'Developing Grails 3 Applications With IntelliJ IDEA')].each {
            it.save()
        }

        then: 'announcements are saved'
        Announcement.count() == 6

        when: 'fetching the projection'
        def resp = service.findAllIdAndTitleProjections([:])

        then: 'there are six maps in the response'
        resp
        resp.size() == 6

        and: 'the maps contain only id and title'
        resp.each {
            it.keySet() == ['title', 'id'] as Set<String>
         }

        and: 'non empty values'
        resp.each {
            assert it.title
            assert it.id
        }

    }
}

URL 映射

我们需要将 Accept-Header 的版本2.0映射到命名空间v2

/grails-app/controllers/intranet/backend/UrlMappings.groovy
        get "/announcements"(version:'2.0', controller: 'announcement', namespace:'v2')
        get "/announcements/$id(.$format)?"(version:'2.0', controller: 'announcement', action: 'show', namespace:'v2')

6.2 Api 2.0 功能测试

我们要测试 Api 版本 2.0 在收到针对announcements 端点的 GET 请求时不包含 body 属性。下一个功能测试验证了该行为。

/home/runner/work/building-an-android-client-powered-by-a-grails-backend/building-an-android-client-powered-by-a-grails-backend/complete

/src/integration-test/groovy/intranet/backend/AnnouncementControllerSpec.groovy
package intranet.backend

import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import grails.web.http.HttpHeaders
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification


@Integration
        resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() == 'application/json;charset=UTF-8'

        and: 'json payload contains an array of annoucements with id, title and body'
        resp.body().each {
            assert it.id
            assert it.title
            assert it.body (4)
        }
    }

    def "test body is NOT present in announcements json payload of Api 2.0"() {
        given:
        HttpRequest request = HttpRequest.GET("/announcements/").header("Accept-Version", "2.0")

        when: 'Requesting announcements for version 2.0'
        HttpResponse<List<Map>> resp = client.toBlocking().exchange(request, List)

        then: 'the request was successful'
        resp.status == HttpStatus.OK (3)

        and: 'the response is a JSON Payload'
        resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() == 'application/json;charset=UTF-8'
        and: 'json payload contains the complete announcement'
1 Body 不存在于 JSON 负载中

Grails 命令test-app运行单元、集成和功能测试。

6.3 Android V2 变更

首先,我们需要在 Constants.java 中更改 API 版本

/app/src/main/java/intranet/client/network/Constants.java
package intranet.client.network;

public class Constants {
    static final String GRAILS_APP_URL = "http://192.168.1.42:8080/";
    static final String ANNOUNCEMENTS_PATH = "announcements";
    static final String ACCEPT_VERSION = "2.0";
}

详情活动使用异步任务来获取完整的公告。


/app/src/main/java/intranet/client/android/asynctasks/RetrieveAnnouncementTask.java
package intranet.client.android.asynctasks;

import android.os.AsyncTask;

import intranet.client.android.delegates.RetrieveAnnouncementDelegate;
import intranet.client.network.AnnouncementFetcher;
import intranet.client.network.model.Announcement;

public class RetrieveAnnouncementTask extends AsyncTask<Long, Void, Announcement> {
    private static final String TAG = RetrieveAnnouncementTask.class.getSimpleName();
    AnnouncementFetcher fetcher = new AnnouncementFetcher();
    private RetrieveAnnouncementDelegate delegate;

    public RetrieveAnnouncementTask(RetrieveAnnouncementDelegate delegate) {
        this.delegate = delegate;
    }

    @Override
    protected Announcement doInBackground(Long... ids) {
        if ( ids != null && ids.length >= 1) {
            Long announcementId = ids[0];
            return fetcher.fetchAnnouncement(announcementId);
        }

        return null;
    }

    protected void onPostExecute(Announcement announcement) {
        if ( delegate != null ) {
            delegate.onAnnouncementFetched(announcement);
        }
    }
}

一旦获得公告后,我们将把响应与实现委托的类沟通

/app/src/main/java/intranet/client/android/delegates/RetrieveAnnouncementDelegate.java
package intranet.client.android.delegates;

import intranet.client.network.model.Announcement;

public interface RetrieveAnnouncementDelegate {
    void onAnnouncementFetched(Announcement announcement);
}

公告活动实现委托并呈现公告。

/app/src/main/java/intranet/client/android/activities/AnnouncementActivity.java
package intranet.client.android.activities;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.webkit.WebView;
import android.widget.TextView;

import intranet.client.R;
import intranet.client.android.asynctasks.RetrieveAnnouncementTask;
import intranet.client.android.delegates.RetrieveAnnouncementDelegate;
import intranet.client.network.model.Announcement;

public class AnnouncementActivity extends Activity implements RetrieveAnnouncementDelegate {

    private TextView tvTitle;
    private WebView wvBody;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_announcement);

        tvTitle = (TextView) findViewById(R.id.tvTitle);
        wvBody = (WebView) findViewById(R.id.wvBody);

        Intent intent = getIntent();
        final Long announcementId = intent.getLongExtra(MainActivity.EXTRA_ID, 0L);
        new RetrieveAnnouncementTask(this).execute(announcementId);
    }

    private void populateUiWithTitleAndBody(final String title, final String body) {
        tvTitle.setText(title);
        final String mime = "text/html";
        final String encoding = "utf-8";
        wvBody.loadDataWithBaseURL(null, body, mime, encoding, null);
    }

    @Override
    public void onAnnouncementFetched(Announcement announcement) {
        populateUiWithTitleAndBody(announcement.getTitle(), announcement.getBody());
    }
}

7 结论

感谢 Grails 的 API 版本控制的便捷性,现在我们可以支持两个运行不同版本的 Android 应用程序。

8 您需要 Grails 的帮助吗?

Object Computing, Inc. (OCI) 赞助创建了本指南。可提供各种咨询和支持服务。

OCI 是 Grails 的家

与团队见面