用 Go 微框架 aah 为 android app 写个 REST API 服务

更新

前言

学习和工作中有时会碰到这样的情况:一个有着漂亮 “颜值” 和功能的前端开源项目,因为缺失了后端服务而不能呈现效果,彼时那种 “猴急” 的心情难以言表。那么,是狠下心来果断地放弃,还是厚着脸皮跟开发者讨个后台测试 URL?还是兀自搭个后台继续地折腾?不管结果如何,这总归是一桩憾事。

“何以解忧,唯有...Mock”!

吃够了苦头的开发者,拿起来手中的武器——键盘和鼠标,于是,无数的 “Mock” 应用诞生了!不是啦,当然这是在讲笑话了,Mock 因测试而生。缺失测试后端的问题,搭建一个支持 REST API 的服务就可以了,就能解决你的遗憾。

最简单的解决方案应该是用 Node.js 来实现一个 Mock Server 了!近年来,JS 及 Node.js 的生态之繁荣实在令人惊叹!但在这里,我想介绍另一种实现思路:用 Go aah 框架来搭建 REST API 服务。这个方案基于 aah 框架,仅需编辑 3~4 个文件,即可实现一个完整的 CRUD 应用服务。

需求分析

以一个 GitHub 上存在的 Android app(https://github.com/dvinfosys/Android-RecyclerView-...)为例,它使用 Apache HttpClient 从远程 API 端点获取一个分类列表,然后用 Android RecyclerView 对该分类列表按照升序或降序排序的方式进行展示。开发者没有提供后端服务的测试接口。需要为该 app 搭建一个 REST API 后端服务,目标是实现该 app 正常操作演示 。

1、app 现有功能需求

主要是实现分类列表显示。需要一个 REST API 服务器,至少支持 HTTP GET。为增加趣味性,可设置属性 Count 为访问计数器,每次通过 API 访问条目(Item)时 Count 加 1)。

通过代码,可知 app 现有功能实现细节如下:

1)网络请求

请求 URL:"http://192.168.1.12/StoreAPI/api/Industry/GetIndustry"

请求方式:GET。

2)分类属性

基本属性包括 IndustryID、IndustryName、Nature、Count。

2、app 扩展功能需求

现 app 实现了一个列表,只用到了 GET,。要实现一个较为完整的演示,至少还需要增加 "CUD" 功能:增加1个 industry 分类(CREATE)、更新 industry 分类(UPDATE)、删除分类(DELETE)。

这里就用 aah 框架来实现一下第 1 点中的功能。第 2 点的后端功能在代码中已经实现,可以通过 Postman 进行验证。代码仓库(repo)是:https://github.com/vulcangz/aah-recycleview-backen...。Postman 测试 Collection 在 repo 的 docs\postman 目录下。

前端 app 不是本文关注的重点,"CUD" 功能,若以后有时间再补充完善吧。

先决条件

  • go >= 1.11:这是 aah v0.12.0 版本的要求。
  • Visual Studio Code(可选):Go IDE。
  • Android Studio Arctic Fox | 2020.3.1 Patch 4:这是作者所使用的版本,用于安卓代码编辑,编译、测试 APK。您也可选择自己熟悉的 IDE。
  • 安卓模拟器(可选):在 AS 内置模拟器和夜神上测试通过。

aah 框架简介

aah(https://github.com/go-aah/aah)是一个具有微框架性质的、特性丰富的、OOTB(开箱即用)的框架。可为构建现代 Web、API 和 WebSocket 应用程序提供必要的组件。如果想了解详细特性,可访问官网;或者可访问 https://www.worldlink.com.cn/osdir/aah.html 了解下。

(aah框架近2个月星走势图)

从近2个月的星走势图来看,aah 一直处于不温不火的、比较平稳的状态。与其他流行的 Go 框架如 Gin、Iris、chi、Echo、Fiber 等相比,aah 低调得就好像不存在一样。与定位类似的 Buffalo 相比,其 CLI 功能没有那么多,Buffalo 的 Soda 可以直接在命令行创建 model($ soda g model)、写 Migrations($ soda generate fizz)等,而 aah 没有提供这些。但个人认为,无论从 OOTB 功能、安全性设计及实现,还是从事件处理机制、i18n 国际化支持等方面来看,aah 都做得相当出色,当然,这是个人观点。aah 为开发者提供了很多 hack 的空间,要玩转 aah 这个框架需要开发者具备一定的技术能力,也许这是影响 aah 流行的一个主要因素。

aah 支持生成、构建 Web、API 和 WebSocket 应用。这里用 aah 为上述 app 实现一个 REST API 后端服务器。

REST API 服务实施(STEP BY STEP)

1、安装 aah CLI 客户端工具

1)macOS、Linux、BSD 系统和带有 Cygwin 的 Windows:

# 安装最新版本的 aah CLI
$ curl -s https://aahframework.org/install-cli | bash
# 或者
$ wget -qO- https://aahframework.org/install-cli | bash

2)对于 Windows 用户,建议以源代码方式进行安装

λ git clone -b master --single-branch --depth 1 https://github.com/go-aah/tools.git aah-cli
λ cd aah-cli\aah
λ go install
go: aahframe.work@v0.12.5: missing go.sum entry; to add it:
        go mod download aahframe.work
D:\working\golang\src\github.com\go-aah\tools\aah-cli\aah (master -> origin)
λ go mod tidy
go: finding module for package gopkg.in/go-playground/assert.v1
go: finding module for package google.golang.org/appengine
go: finding module for package google.golang.org/appengine/urlfetch
go: finding module for package golang.org/x/sys/unix
go: found google.golang.org/appengine in google.golang.org/appengine v1.6.7
go: found gopkg.in/go-playground/assert.v1 in gopkg.in/go-playground/assert.v1 v1.2.1
go: found google.golang.org/appengine/urlfetch in google.golang.org/appengine v1.6.7
go: found golang.org/x/sys/unix in golang.org/x/sys v0.0.0-20220114195835-da31bd327af9
λ go install
λ aah -v
aah v0.12.5
cli v0.13.6
go  v1.16.4

2、生成 api 类型的骨架代码

D:\working\vulcangz
λ aah new
---------------------------------------------------------------
          aah framework v0.13.0-edge (cli v0.13.4)
---------------------------------------------------------------
# Report improvements/bugs at https://aahframework.org/issues #
Welcome to interactive way to create your aah application, press ^C to exit :)
Based on your inputs, aah CLI generates the aah application structure for you.
Enter your application import path: aah-recycleview-backend
Enter your application location:
Choose your application type (web, api or websocket), default is 'web': api
Choose your application Auth Scheme (basic, generic), default is 'none':
Would you like to enable CORS? [y/N]:
Downloading aah quick start app templates from https://github.com/go-aah/app-templates.git
Your aah api application was created successfully at 'aah-recycleview-backend'
You shall run your application via the command 'aah run' from application base directory.
Go to https://docs.aahframework.org to learn more and customize your aah application.

生成的骨架代码目录结构如下:

D:.
|   .gitignore
|   aah.project
|   go.mod
|   go.sum
|   aah-recycleview-backend.pid
|
+---app
|   |   aah.go
|   |   init.go
|   |
|   +---controllers
|   |   |   app.go
|   |   |
|   |   \---v1
|   |           value.go
|   |
|   +---generated
|   |       add_controllers.go
|   |
|   \---models
|           greet.go
|           value.go
|
+---build
|   \---bin
|           aah-recycleview-backend.exe
|
\---config
    |   aah.conf
    |   routes.conf
    |   security.conf
    |
    \---env
            dev.conf
            prod.conf

3、进入应用目录,运行项目:

D:\working\vulcangz\aah\aah-recycleview-backend
λ aah run
---------------------------------------------------------------
          aah framework v0.13.0-edge (cli v0.13.4)
---------------------------------------------------------------
# Report improvements/bugs at https://aahframework.org/issues #
Loaded aah project file: D:\working\vulcangz\aah\aah-recycleview-backend\aah.project
Hot-Reload enabled for environment profile: dev
Compile starts for 'aah-recycleview-backend' [aah-recycleview-backend]
Compile successful for 'aah-recycleview-backend' [aah-recycleview-backend]
2022-01-25 20:46:24.434 INFO  aah framework v0.13.0-edge, requires >= go1.11
2022-01-25 20:46:24.435 DEBUG aah-recycleview-backend Domain count: 1
2022-01-25 20:46:24.435 DEBUG aah-recycleview-backend Domain: localhost:8080, routes found: 6
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Base Directory: D:\working\vulcangz\aah\aah-recycleview-backend
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Virtual Base Directory: /app
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Name: aah-recycleview-backend
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Build Version: 0.0.1
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Build Timestamp: 2022-01-25T20:46:19+08:00
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Single Binary Mode: false
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Profile: dev
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App TLS/SSL Enabled: false
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Session Mode: stateless
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Route Domains: localhost:8080
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend App Shutdown Grace Timeout: 60s
2022-01-25 20:46:24.436 DEBUG aah-recycleview-backend Subscribed event callbacks
2022-01-25 20:46:24.436 DEBUG aah-recycleview-backend Event: OnStart (callback=aah-recycleview-backend/app/generated.AddControllers priority=1)
2022-01-25 20:46:24.436 DEBUG aah-recycleview-backend Event: OnStart (callback=main.SubscribeHTTPEvents priority=1)
2022-01-25 20:46:24.436 DEBUG aah-recycleview-backend Publishing event 'OnStart' in synchronous mode
2022-01-25 20:46:24.436 INFO  aah-recycleview-backend Add 2 Controllers from 'app/controllers/**'
2022-01-25 20:46:24.436 DEBUG aah-recycleview-backend Adding aah-recycleview-backend/app/controllers.AppController
2022-01-25 20:46:24.436 DEBUG aah-recycleview-backend Adding aah-recycleview-backend/app/controllers/v1.ValueController
2022-01-25 20:46:24.468 INFO  aah-recycleview-backend aah go server running on :8080

如果 aah run 出现错误,可运行 go mod tidy 解决依赖问题。

D:\working\vulcangz\aah-recycleview-backend
λ go mod tidy

4、在浏览器访问:http://localhost:8080

(aah 默认样板 web 访问信息)

5、创建实体模型(model)和控制器(controller)

为简化示例,数据存储采用 “IN-MEMORY” store 存储方式,具体可参考官方示例项目:https://github.com/go-aah/examples/tree/v0.12.x/re...

1)在 app\models 目录下创建 industy.go,键入代码代码:

package models
import (
    "errors"
    "sync"
    "time"
)
var (
    industryStore  = make(map[int64]*Industry)
    lastIndustryID int64
    idMx           = &sync.Mutex{}
    industryMx     = &sync.RWMutex{}
)
type Industry struct {
    IndustryID   int64     `json:"IndustryID,omitempty"`
    IndustryName string    `json:"IndustryName,omitempty"`
    Nature       string    `json:"Nature,omitempty"`
    Count        int64     `json:"Count,omitempty"`
    CreatedAt    time.Time `json:"-"`
    UpdatedAt    time.Time `json:"-"`
}
// IndustryAlias is used for time format to ISO 8061
type IndustryAlias Industry
// AllIndustries returns all the industries.
func AllIndustries() []interface{} {
    industryMx.RLock()
    defer industryMx.RUnlock()
    industries := make([]interface{}, 0)
    for _, industry := range industryStore {
        industries = append(industries, ToIndustryAlias(industry))
    }
    return industries
}
// CreateIndustry method creates the new industry entry.
func CreateIndustry(industry *Industry) int64 {
    industryMx.Lock()
    defer industryMx.Unlock()
    id := createIndustryID()
    industry.IndustryID = id
    industry.CreatedAt = time.Now()
    industry.UpdatedAt = industry.CreatedAt
    industryStore[id] = industry
    return id
}
// GetIndustry method return the industry for given ID.
func GetIndustry(id int64) (*Industry, error) {
    industryMx.RLock()
    defer industryMx.RUnlock()
    if _, found := industryStore[id]; !found {
        return nil, errors.New("industry not found")
    }
    industryStore[id].Count++
    return industryStore[id], nil
}
// UpdateIndustry method updates the given info with industry store.
func UpdateIndustry(industry *Industry) error {
    industryMx.Lock()
    defer industryMx.Unlock()
    if _, found := industryStore[industry.IndustryID]; !found {
        return errors.New("industry not found")
    }
    industryStore[industry.IndustryID].IndustryName = industry.IndustryName
    industryStore[industry.IndustryID].Nature = industry.Nature
    industryStore[industry.IndustryID].Count = industry.Count
    industryStore[industry.IndustryID].UpdatedAt = time.Now()
    return nil
}
// DeleteIndustry method deletes the industry for given ID.
func DeleteIndustry(id int64) error {
    industryMx.Lock()
    defer industryMx.Unlock()
    if _, found := industryStore[id]; !found {
        return errors.New("industry not found")
    }
    delete(industryStore, id)
    return nil
}
// ToIndustryAlias method formats the time to RFC3339 using alias.
func ToIndustryAlias(industry *Industry) interface{} {
    return &struct {
        *IndustryAlias
        CreatedAt string `json:"created_at"`
        UpdatedAt string `json:"updated_at"`
    }{
        IndustryAlias: (*IndustryAlias)(industry),
        CreatedAt:     industry.CreatedAt.Format(time.RFC3339),
        UpdatedAt:     industry.UpdatedAt.Format(time.RFC3339),
    }
}
func createIndustryID() int64 {
    idMx.Lock()
    defer idMx.Unlock()
    lastIndustryID++
    return lastIndustryID
}
func init() {
    industryStore[1] = &Industry{IndustryID: 1, IndustryName: "Electronics", Nature: "1", Count: 4}
    industryStore[2] = &Industry{IndustryID: 2, IndustryName: "Mobile & Tablets", Nature: "1", Count: 3}
    // and so on...
    industryStore[20] = &Industry{IndustryID: 20, IndustryName: "Cultery Crockery", Nature: "1", Count: 3}
    lastIndustryID = 20
}

2)在 app\controllers\v1 目录下创建 industy.go, 键入代码如下:

package v1
import (
    "fmt"
    "aah-recycleview-backend/app/models"
    aah "aahframe.work"
)
// IndustryController implements the industry APi endpoints.
type IndustryController struct {
    *aah.Context
}
// List method returns all the industries available.
func (c *IndustryController) List() {
    industries := models.AllIndustries()
    c.Log().Infof("%v industries found", len(industries))
    c.Reply().JSON(aah.Data{
        "industries": industries,
    })
}
// Create method to create a industry via JSON.
func (c *IndustryController) Create(industry *models.Industry) {
    c.Log().Infof("Industry Info: %+v", *industry)
    id := models.CreateIndustry(industry)
    newResourceURL := fmt.Sprintf("%s:%v", c.Req.Scheme, c.RouteURL("retrieve_industry", id))
    c.Reply().Created().
        Header("Location", newResourceURL).
        JSON(aah.Data{
            "id": id,
        })
}
// Retrieve method retunrs single industry details for given industry ID.
func (c *IndustryController) Retrieve(id int64) {
    c.Log().Infof("Retrieving industry, ID: %v", id)
    industry, err := models.GetIndustry(id)
    if err != nil {
        c.Log().Errorf("Industry ID %v, %v", id, err)
        c.Reply().NotFound().JSON(aah.Data{
            "error": err.Error(),
        })
        return
    }
    c.Reply().JSON(models.ToIndustryAlias(industry))
}
// Update method updates the industry with given content.
func (c *IndustryController) Update(id int64, industry *models.Industry) {
    c.Log().Infof("Updating industry: %v", id)
    if industry.IndustryID == 0 {
        industry.IndustryID = id
    }
    if err := models.UpdateIndustry(industry); err != nil {
        c.Log().Errorf("Industry ID %v, %v", id, err)
        c.Reply().NotFound().JSON(aah.Data{
            "error": err.Error(),
        })
        return
    }
    c.Reply().NoContent()
}
// Delete method deletes the industry of given industry ID.
func (c *IndustryController) Delete(id int64) {
    c.Log().Infof("Deleting industry, ID: %v", id)
    if err := models.DeleteIndustry(id); err != nil {
        c.Log().Errorf("Industry ID %v, %v", id, err)
        c.Reply().NotFound().JSON(aah.Data{
            "error": err.Error(),
        })
        return
    }
    c.Reply().NoContent()
}

6、配置路由

1)配置服务器监听端口为 80

打开 config\aah.conf 文件,在 “server {” 块内添加:

port = "80"

2)配置 industry 路由

打开 config\routes.conf,在 “api_v1” 块内的 “routes” 子块内 “all_values” 子块结束处的下方(L63 行之后),添加 industry 相关路由如下:

64          industry {
65            path = "/industry"
66            controller = "IndustryController"
67            action = "List"
68
69            routes {
70               create_industry {
71                  method = "POST"
72               }
73               retrieve_industry {
74                path = "/:id"
75                action = "Retrieve"
76
77                routes {
78                  update_industry {
79                    method = "PUT"
80                  }
81                  delete_industry {
82                    method = "DELETE"
83                  }
84                }
85              }
86            }
87          } # end - industry

这里的 L73~85:retrieve_industry 区块,74~75 行表示该区块中各路由首先需要从 path 中 "Retrieve"(取出)industry id,也就是说:HTTP request URL 中的 PATH 部分必须包含有 "id",这样后续的 "update" 或 "delete" 等操作才能正常往下进行 。

到这里代码和配置的部分就完成了。aah 支持热重载(Hot-Reload),代码和配置文件更改时,aah 会自动停止服务器,构建它并使用更新的代码库启动服务器。

这时候在浏览器访问 http://localhost/v1/industry 可以列出全部 industries 条目的 json 数据;访问 http://localhost/v1/industry/1 显示第1条 industry 的 json 数据,依此类推。

7、Android app 的调试

1)克隆 Android app repo

git clone git@github.com:vulcangz/Android-RecyclerView-Sort-Ascending-Descending.git

2)导入到 Android Studio 并编译、运行

然后在 Android Studio 选择 “File”——"New"——"Import Project" 导入即可,需要修改的只是 1.1 中所提到的 “请求 URL”。在这里我把它改为了 "http://192.168.100.16/v1/industry" 这样的形式,请把它改为自己的服务器 IP。

按照规范要求:URL 中的 “industry” 应为复数 "industries" 形式,这里为了与原 app 中的网络请求 URL 对应保持了单数形式。希望不要误导大家。

测试

后端 API 服务的功能测试可以结合 POSTMAN 和浏览器来完成。我在 repo 中的 docs\postman 目录中提供了 POSTMAN 测试集文件,读者可导入到 POSTMAN 进行测试。您需要自行修改一下后端服务端口为 80,测试集中设置的是 8080。

App 的测试可以通过 Android Studio 和 安卓模拟器来完成。

总结

这一篇教程中基于 aah 框架实现了一个 REST API 形式的 CRUD 应用服务,采取 “IN-MEMORY” store 存储而不是数据库存储方式,简化了程序结构和数据处理的复杂度,真正地让你在几分钟之内就可以搭建好一个后端服务。

当然,aah 框架所内置的功能远不止于此。aah 在安全性部分的设计颇具特色,它借鉴了 Apache Shiro 安全库,“Subject”、"Principal"的概念容易让人迷惑,但这些是安全领域的专业术语,如果您了解 Apache Shiro,接受起来应该不难。

“原生多租户(域和子域)” 、“虚拟文件系统(VFS)”、以及使用简单的 “i18n 国际化” 都是让我印象深刻的特性。

与 Echo、Gin 等其他框架不同,aah 把路由这一部分由常规的代码实现改为由配置来实现,根据配置来自动生成对应的处理程序——我们可以看一下 app\generated 目录下生成的文件 add_controllers.go,它实现的就是类似于路由的功能。

如有兴趣可以看一下 app\init.go 文件。aah 框架在其请求生命周期中提供了
“OnRequest”、“OnPreReply”、“OnHeaderReply”、“OnPostReply”、“OnPreAuth” 和 “OnPostAuth” 等事件扩展点,这赋予给开发者 hack 空间。

参考

关注开源技术发展,发掘实用开源项目。秀项目,秀 code!这就是 WorldLink 资源网。关注公号 “Worldlink资源网”,可及时收到文章推送。技术文章是会修改和更新的,也欢迎您有空时过来看看。


喜歡:
4
去到頂部