使用 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 签出源代码,并完成本指南介绍的步骤。
请按照以下步骤开始
-
下载并解压源代码,或如果你已经安装了 Git:
git clone https://github.com/grails-guides/building-an-android-client-powered-by-a-grails-backend.git
如需了解 Grails 部分
-
cd
到grails-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 功能。
如果你 cd 到 grails-guides/building-an-android-client-powered-by-a-grails-backend/complete ,你可以直接跳到已完成的 Grails 示例 |
如需了解 Android 部分
-
cd
到grails-guides/building-an-android-client-powered-by-a-grails-backend/initial-android
-
转到下一部分
或者,你可以在 Android Studio 的新建项目向导中创建 Android 应用,如下面的屏幕截图所示。
如果你 cd 到 grails-guides/building-an-android-client-powered-by-a-grails-backend/complete-android-v1 ,你可以直接跳到 Android 示例的已完成版本 1 |
如果你 cd 到 grails-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 中呈现。
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 正文。我们将修改上一步生成的域类来存储该信息。
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 域类中定义的约束。尤其是 title
和 body
属性的非空值性和长度。
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 功能。
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 映射 配置来实现此目的。在映射闭包中添加下一行
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
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 应用程序公开的公告的提取和呈现所涉及的类。
5.2 网络代码
我们有一个类,其中初始化了多个常量
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 中
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
<uses-permission android:name="android.permission.INTERNET"/>
OkHttp
我们将使用最流行的 Android http 客户端之一,OkHttp
要添加 OkHttp 作为依赖项,请编辑文件 app/build.gradle并在依赖项块中添加下一行
implementation 'com.squareup.okhttp3:okhttp:4.2.2'
Gson
Gson是一个 Java 库,可用于将 Java 对象转换为其 JSON 表示形式。它还可用于将 JSON 字符串转换为等效的 Java 对象。
要添加 Gson 作为依赖项,请编辑文件 app/build.gradle并在依赖项块中添加下一行
implementation 'com.google.code.gson:gson:2.8.6'
我们将封装 OkHttp 请求的实例化到一个类中,以确保 Accept-Version Http 标头始终使用在Constants.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();
}
}
下一个类获取并返回公告列表。
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 中
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);
}
}
}
一旦获取到公告列表,我们将向实现委托的类发送响应
package intranet.client.android.delegates;
import java.util.List;
import intranet.client.network.model.Announcement;
public interface RetrieveAnnouncementsDelegate {
void onAnnouncementsFetched(List<Announcement> announcements);
}
接收公告的委托实现类是初始活动
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 使用在
<?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。
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);
}
}
}
}
package intranet.client.android.delegates;
import intranet.client.network.model.Announcement;
public interface AnnouncementAdapterDelegate {
void onAnnouncementTapped(Announcement announcement);
}
<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 发送
您需要向清单添加第二个活动。
<activity android:name=".android.activities.AnnouncementActivity" />
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);
}
}
<?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
)。
6.1 Grails V2 变更
我们准备使用创建控制器来处理 API 的版本 2。我们将使用带有投影的条件查询,以仅获取公告的 id 和标题。
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()]
}
}
我们将在服务中封装查询
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>
}
}
并对其进行测试
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
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
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 版本
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";
}
详情活动使用异步任务来获取完整的公告。
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);
}
}
}
一旦获得公告后,我们将把响应与实现委托的类沟通
package intranet.client.android.delegates;
import intranet.client.network.model.Announcement;
public interface RetrieveAnnouncementDelegate {
void onAnnouncementFetched(Announcement announcement);
}
公告活动实现委托并呈现公告。
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 应用程序。