host-your-flutter-project-as-a-rest-api
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseHost your Flutter Project as a REST API
将你的Flutter项目部署为REST API
After you build your flutter project you may want to reuse the models and business logic from your lib folder. I will show you how to go about setting up the project to have iOS, Android, Web, Windows, MacOS, Linux and a REST API interface with one project. The REST API can also be deploy to Google Cloud Run for Dart everywhere.
One Codebase for Client and Sever.
This will allow you to expose your Dart models as a REST API and run your business logic from your lib folder while the application runs the models as they are. Here is the final project.
当你构建完Flutter项目后,可能会想要复用lib文件夹中的模型和业务逻辑。本文将展示如何设置项目,使其同时支持iOS、Android、Web、Windows、MacOS、Linux平台以及REST API接口,实现一套代码适配多端。该REST API还可部署到Google Cloud Run,让Dart运行在任意环境。
客户端与服务端共享单一代码库
这种方式可以将你的Dart模型以REST API的形式暴露出来,同时在服务端运行lib文件夹中的业务逻辑,而客户端则直接使用这些模型。点击这里查看最终项目代码。
Setting Up
项目搭建
Why one project?
为什么要使用单一项目?
It may not be obvious but when building complex applications you will at some point have a server and an application that calls that server. Firebase is an excellent option for doing this and I use it in almost all my projects. Firebase Functions are really powerful but you are limited by Javascript or Typescript. What if you could use the same packages that you are using in the Flutter project, or better yet what if they both used the same?
When you have a server project and a client project that communicate over a rest api or client sdk like Firebase then you will run into the problem that the server has models of objects stored and the client has models of the objects that are stored. This can lead to a serious mismatch when it changed without you knowing. GraphQL helps a lot with this since you define the model that you recieve. This approach allows your business logic to be always up to date for both the client and server.
在构建复杂应用时,你迟早会遇到需要服务端和客户端交互的场景。Firebase是一个很好的选择,我几乎在所有项目中都会用到它。Firebase Functions功能非常强大,但它只能使用Javascript或Typescript。如果能在Flutter项目中使用相同的包,甚至让客户端和服务端共享同一套代码,岂不是更好?
当你分别维护服务端和客户端项目,通过REST API或Firebase这类SDK进行通信时,会遇到一个问题:服务端有一套数据模型,客户端也有一套对应的模型,当模型发生变更时,很容易出现两端模型不匹配的情况。GraphQL在这方面有很大帮助,因为它可以定义接收的模型结构。而本文的方法则能确保客户端和服务端的业务逻辑始终保持同步。
Client Setup
客户端搭建
The first step is to just build your application. The only difference that we will make is keeping the UI and business logic separate. When starting out with Flutter it can be very easy to throw all the logic into the screen and calling setState when the data changes. Even the application when creating a new Flutter project does this. That's why choosing a state management solution is so important.
To make things clean and concise we will make 2 folders in our lib folder.
-
ui for all Flutter Widgets and Screens
-
src for all business logic, classes, models and utility functions
This will leave us with main.dart being only the entry point into our client application.
import 'package:flutter/material.dart';
import 'plugins/desktop/desktop.dart';
import 'ui/home/screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: HomeScreen(),
);
}
}Let’s Start by making a tab bar for the 2 screens. Create a file in the folder ui/home/screen.dart and add the following:
import 'package:flutter/material.dart';
import '../counter/screen.dart';
import '../todo/screen.dart';
class HomeScreen extends StatefulWidget {
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: <Widget>[
CounterScreen(),
TodosScreen(),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (val) {
if (mounted)
setState(() {
_currentIndex = val;
});
},
type: BottomNavigationBarType.fixed,
items: [
BottomNavigationBarItem(
icon: Icon(Icons.add),
title: Text('Counter'),
),
BottomNavigationBarItem(
icon: Icon(Icons.list),
title: Text('Todos'),
),
],
),
);
}
}This is just a basic screen and should look very normal.
第一步是构建你的应用程序。我们唯一要做的改动是将UI和业务逻辑分离。刚开始使用Flutter时,很容易把所有逻辑都写在页面中,通过调用setState来更新数据。甚至Flutter默认创建的新项目也是这种写法。这就是为什么选择合适的状态管理方案如此重要。
为了让项目结构清晰简洁,我们在lib文件夹中创建两个子文件夹:
-
ui:存放所有Flutter Widget和页面
-
src:存放所有业务逻辑、类、模型和工具函数
这样main.dart就只是客户端应用的入口文件。
import 'package:flutter/material.dart';
import 'plugins/desktop/desktop.dart';
import 'ui/home/screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: HomeScreen(),
);
}
}首先我们创建一个包含两个页面的标签栏。在ui/home/screen.dart文件中添加以下代码:
import 'package:flutter/material.dart';
import '../counter/screen.dart';
import '../todo/screen.dart';
class HomeScreen extends StatefulWidget {
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: <Widget>[
CounterScreen(),
TodosScreen(),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (val) {
if (mounted)
setState(() {
_currentIndex = val;
});
},
type: BottomNavigationBarType.fixed,
items: [
BottomNavigationBarItem(
icon: Icon(Icons.add),
title: Text('Counter'),
),
BottomNavigationBarItem(
icon: Icon(Icons.list),
title: Text('Todos'),
),
],
),
);
}
}这是一个基础的页面,看起来和普通的Flutter页面没什么区别。
Counter Example
计数器示例
Now create a file ui/counter/screen.dart and add the following:
import 'package:flutter/material.dart';
import 'package:shared_dart/src/models/counter.dart';
class CounterScreen extends StatefulWidget {
@override
_CounterScreenState createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
CounterModel _counterModel = CounterModel();
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counterModel.add();
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// Here we take the value from the MyCounterPage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text('Counter Screen'),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Invoke "debug painting" (press "p" in the console, choose the
// "Toggle Debug Paint" action from the Flutter Inspector in Android
// Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
// to see the wireframe for each widget.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'${_counterModel.count}',
style: Theme.of(context).textTheme.display1,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}This is the default counter app you get when you create a Flutter application but with one change, it uses to hold the logic.
CounterModelCreate the counter model at src/models/counter.dart and add the following:
class CounterModel {
CounterModel();
int _count = 0;
int get count => _count;
void add() => _count++;
void subtract() => _count--;
void set(int val) => _count = val;
}As you can see it is really easy to expose only what we want to while still having complete flexibility. You could use provider here if you choose, or even bloc and/or streams.
现在创建ui/counter/screen.dart文件并添加以下代码:
import 'package:flutter/material.dart';
import 'package:shared_dart/src/models/counter.dart';
class CounterScreen extends StatefulWidget {
@override
_CounterScreenState createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
CounterModel _counterModel = CounterModel();
void _incrementCounter() {
setState(() {
// 调用setState会通知Flutter框架状态发生了变化,导致build方法重新执行
// 这样界面就能反映更新后的值。如果我们修改_counter但不调用setState(),
// build方法不会重新执行,界面也不会有任何变化。
_counterModel.add();
});
}
@override
Widget build(BuildContext context) {
// 每次调用setState时都会重新执行这个方法,比如上面的_incrementCounter方法
// Flutter框架已经过优化,重新执行build方法的速度很快,所以你可以重建所有需要更新的部分
// 而不需要单独修改widget实例。
return Scaffold(
appBar: AppBar(
// 这里我们使用App.build方法创建的MyCounterPage对象的值来设置appbar标题
title: Text('Counter Screen'),
),
body: Center(
// Center是一个布局widget,它将子widget放置在父widget的中间
child: Column(
// Column也是一个布局widget,它将子widget垂直排列。默认情况下,它会根据子widget的宽度调整自身宽度,
// 并尽可能占据父widget的高度。
//
// 启用"调试绘制"(在控制台按"p",在Android Studio的Flutter Inspector中选择"Toggle Debug Paint",
// 或在Visual Studio Code中执行"Toggle Debug Paint"命令)可以查看每个widget的线框。
//
// Column有很多属性可以控制自身的大小和子widget的位置。这里我们使用mainAxisAlignment让子widget垂直居中;
// 因为Column是垂直排列的,所以主轴是垂直方向(交叉轴是水平方向)。
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'你已点击按钮的次数:',
),
Text(
'${_counterModel.count}',
style: Theme.of(context).textTheme.display1,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // 这个尾随逗号让build方法的自动格式化效果更好
);
}
}这是创建Flutter应用时默认的计数器应用,只是做了一处修改:使用来处理逻辑。
CounterModel在src/models/counter.dart创建计数器模型并添加以下代码:
class CounterModel {
CounterModel();
int _count = 0;
int get count => _count;
void add() => _count++;
void subtract() => _count--;
void set(int val) => _count = val;
}如你所见,这种方式可以轻松地暴露我们想要的内容,同时保持完全的灵活性。你也可以在这里使用provider,甚至bloc和/或streams。
Todo Example
待办事项示例
Lets create a file at ui/todos/screen.dart and add the following:
import 'package:flutter/material.dart';
import '../../src/classes/todo.dart';
import '../../src/models/todos.dart';
class TodosScreen extends StatefulWidget {
@override
_TodosScreenState createState() => _TodosScreenState();
}
class _TodosScreenState extends State<TodosScreen> {
final _model = TodosModel();
List<ToDo> _todos;
@override
void initState() {
_model.getList().then((val) {
if (mounted)
setState(() {
_todos = val;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Todos Screen'),
),
body: Builder(
builder: (_) {
if (_todos != null) {
return ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final _item = _todos[index];
return ListTile(
title: Text(_item.title),
subtitle: Text(_item.completed ? 'Completed' : 'Pending'),
);
},
);
}
return Center(
child: CircularProgressIndicator(),
);
},
),
);
}
}You will see that we have the logic in TodosModel and uses the class ToDo for toJson and fromJson.
Create a file at the location src/classes/todo.dart and add the following:
// To parse this JSON data, do
//
// final toDo = toDoFromJson(jsonString);
import 'dart:convert';
List<ToDo> toDoFromJson(String str) => List<ToDo>.from(json.decode(str).map((x) => ToDo.fromJson(x)));
String toDoToJson(List<ToDo> data) => json.encode(List<dynamic>.from(data.map((x) => x.toJson())));
class ToDo {
int userId;
int id;
String title;
bool completed;
ToDo({
this.userId,
this.id,
this.title,
this.completed,
});
factory ToDo.fromJson(Map<String, dynamic> json) => ToDo(
userId: json["userId"],
id: json["id"],
title: json["title"],
completed: json["completed"],
);
Map<String, dynamic> toJson() => {
"userId": userId,
"id": id,
"title": title,
"completed": completed,
};
}and create the model src/models/todo.dart and add the following:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_dart/src/classes/todo.dart' as t;
class TodosModel {
final kTodosUrl = '[https://jsonplaceholder.typicode.com/todos'](https://jsonplaceholder.typicode.com/todos');
Future<List<t.ToDo>> getList() async {
final _response = await http.get(kTodosUrl);
if (_response != null) {
final _todos = t.toDoFromJson(_response.body);
if (_todos != null) {
return _todos;
}
}
return [];
}
Future<t.ToDo> getItem(int id) async {
final _response = await http.get('$kTodosUrl/$id');
if (_response != null) {
final _todo = t.ToDo.fromJson(json.decode(_response.body));
if (_todo != null) {
return _todo;
}
}
return null;
}
}Here we just get dummy data from a url that emits json and convert them to our classes. This is an example I want to show with networking. There is only one place that fetches the data.
在ui/todos/screen.dart创建文件并添加以下代码:
import 'package:flutter/material.dart';
import '../../src/classes/todo.dart';
import '../../src/models/todos.dart';
class TodosScreen extends StatefulWidget {
@override
_TodosScreenState createState() => _TodosScreenState();
}
class _TodosScreenState extends State<TodosScreen> {
final _model = TodosModel();
List<ToDo> _todos;
@override
void initState() {
_model.getList().then((val) {
if (mounted)
setState(() {
_todos = val;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Todos Screen'),
),
body: Builder(
builder: (_) {
if (_todos != null) {
return ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final _item = _todos[index];
return ListTile(
title: Text(_item.title),
subtitle: Text(_item.completed ? '已完成' : '待处理'),
);
},
);
}
return Center(
child: CircularProgressIndicator(),
);
},
),
);
}
}你会看到逻辑都在TodosModel中,使用ToDo类来实现toJson和fromJson方法。
在src/classes/todo.dart创建文件并添加以下代码:
// 要解析此JSON数据,请执行以下操作
//
// final toDo = toDoFromJson(jsonString);
import 'dart:convert';
List<ToDo> toDoFromJson(String str) => List<ToDo>.from(json.decode(str).map((x) => ToDo.fromJson(x)));
String toDoToJson(List<ToDo> data) => json.encode(List<dynamic>.from(data.map((x) => x.toJson())));
class ToDo {
int userId;
int id;
String title;
bool completed;
ToDo({
this.userId,
this.id,
this.title,
this.completed,
});
factory ToDo.fromJson(Map<String, dynamic> json) => ToDo(
userId: json["userId"],
id: json["id"],
title: json["title"],
completed: json["completed"],
);
Map<String, dynamic> toJson() => {
"userId": userId,
"id": id,
"title": title,
"completed": completed,
};
}然后在src/models/todo.dart创建模型并添加以下代码:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_dart/src/classes/todo.dart' as t;
class TodosModel {
final kTodosUrl = 'https://jsonplaceholder.typicode.com/todos';
Future<List<t.ToDo>> getList() async {
final _response = await http.get(kTodosUrl);
if (_response != null) {
final _todos = t.toDoFromJson(_response.body);
if (_todos != null) {
return _todos;
}
}
return [];
}
Future<t.ToDo> getItem(int id) async {
final _response = await http.get('$kTodosUrl/$id');
if (_response != null) {
final _todo = t.ToDo.fromJson(json.decode(_response.body));
if (_todo != null) {
return _todo;
}
}
return null;
}
}这里我们只是从一个返回JSON数据的接口获取模拟数据,并将它们转换为我们的类。这是一个展示网络请求的示例,所有的数据获取逻辑都集中在一个地方。
Run the Project (Web)
运行项目(Web端)
As you can see when you run your project on chrome you will get the same application that you got on mobile. Even the networking is working in the web. You can call the model and retrieve the list just like you would expect.
如你所见,在Chrome中运行项目时,你会得到和移动端一样的应用,甚至网络请求也能正常工作。你可以像预期的那样调用模型并获取列表数据。
Server Setup
服务端搭建
Now time for the magic..
In the root of the project folder create a file Dockerfile and add the following:
undefined现在到了最神奇的部分..
在项目根目录创建Dockerfile文件并添加以下内容:
undefinedUse Google's official Dart image.
使用Google官方的Dart镜像
FROM google/dart-runtime
Create another file at the root called service.yaml and add the following:
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: PROJECT_NAME
namespace: default
spec:
template:
spec:
containers:
- image: docker.io/YOUR_DOCKER_NAME/PROJECT_NAME
env:
- name: TARGET
value: "PROJECT_NAME v1"
Replace PROJECT\_NAME with your project name, mine is shared-dart for this example.
You will also need to replace YOUR\_DOCKER\_NAME with your docker username so the container can be deployed correctly.
Update your pubspec.yaml with the following:
name: shared_dart
description: A new Flutter project.
publish_to: none
version: 1.0.0+1
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
shelf: ^0.7.3
cupertino_icons: ^0.1.2
http: ^0.12.0+2
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
The important package here is shelf as it allows us to run a http server with dart.
Create a folder in the root of the project called bin then add a file server.dart and replace it with the following:
import 'dart:io';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as io;
import 'src/routing.dart';
void main() {
final handler = const shelf.Pipeline()
.addMiddleware(shelf.logRequests())
.addHandler(RouteUtils.handler);
final port = int.tryParse(Platform.environment['PORT'] ?? '8080');
final address = InternetAddress.anyIPv4;
io.serve(handler, address, port).then((server) {
server.autoCompress = true;
print('Serving at http://${server.address.host}:${server.port}');
});
}
This will tell the container what port to listen for and how to handle the requests.
Create a folder src in the bin folder and add a file routing.dart and replace the contents with the following:
import 'dart:async';
import 'package:shelf/shelf.dart' as shelf;
import 'controllers/index.dart';
import 'result.dart';
class RouteUtils {
static FutureOr<shelf.Response> handler(shelf.Request request) {
var component = request.url.pathSegments.first;
var handler = _handlers(request)[component];
if (handler == null) return shelf.Response.notFound(null);
return handler;
}
static Map<String, FutureOr<shelf.Response>> _handlers(
shelf.Request request) {
return {
'info': ServerResponse('Info', body: {
"version": 'v1.0.0',
"status": "ok",
}).ok(),
'counter': CounterController().result(request),
'todos': TodoController().result(request),
};
}
}
There is still nothing imported from our main project but you will start to see some similarities. Here we specify controllers for todos and counter url paths.
'counter': CounterController().result(request),
'todos': TodoController().result(request),
that means any url with the following:[https://mydomain.com/todos](https://mydomain.com/todos) , [https://mydomain.com/todos](https://mydomain.com/todos)/1
will get routed to the TodoController to handle the request.
> This is also the first time I found out about FutureOr. It allows you to return a sync or async function.
And important part about build a REST API is having a consistent response body, so here we can create a wrapper that adds fields we always want to return, like the status of the call, a message and the body.
Create a file at src/result.dart and add the following:
import 'dart:convert';
import 'package:shelf/shelf.dart' as shelf;
class ServerResponse {
final String message;
final dynamic body;
final StatusType type;
ServerResponse(
this.message, {
this.type = StatusType.success,
this.body,
});
Map<String, dynamic> toJson() {
return {
"status": type.toString().replaceAll('StatusType.', ''),
"message": message,
"body": body ?? '',
};
}
String toJsonString() {
return json.encode(toJson());
}
shelf.Response ok() {
return shelf.Response.ok(
toJsonString(),
headers: {
'Content-Type': 'application/json',
},
);
}
}
enum StatusType { success, error }
abstract class ResponseImpl {
Future<shelf.Response> result(shelf.Request request);
}
This will always return json and the fields that we want to show. You could also include your paging meta data here.
Create a file in at the location src/controllers/counter.dart and add the following:
import 'package:shared_dart/src/models/counter.dart';
import 'package:shelf/shelf.dart' as shelf;
import '../result.dart';
class CounterController implements ResponseImpl {
const CounterController();
@override
Future<shelf.Response> result(shelf.Request request) async {
final _model = CounterModel();
final _params = request.url.queryParameters;
if (_params != null) {
final _val = int.tryParse(_params['count'] ?? '0');
_model.set(_val);
} else {
_model.add();
}
return ServerResponse('Info', body: {
"counter": _model.count,
}).ok();
}
}
You will see the import to the lib folder of the root project. Since it shares the pubspec.yaml all the packages can be shared. You can import the CounterModel that we created earlier.
Create a file in at the location src/controllers/todos.dart and add the following:
import 'package:shared_dart/src/models/todos.dart';
import 'package:shelf/src/request.dart';
import 'package:shelf/src/response.dart';
import '../result.dart';
class TodoController implements ResponseImpl {
@override
Future<Response> result(Request request) async {
final _model = TodosModel();
if (request.url.pathSegments.length > 1) {
final _id = int.tryParse(request.url.pathSegments[1] ?? '1');
final _todo = await _model.getItem(_id);
return ServerResponse('Todo Item', body: _todo).ok();
}
final _todos = await _model.getList();
return ServerResponse(
'List Todos',
body: _todos.map((t) => t.toJson()).toList(),
).ok();
}
}
Just like before we are importing the TodosModel model from the lib folder.
For convenience add a file at the location src/controllers/index.dart and add the following:
export 'counter.dart';
export 'todo.dart';
This will make it easier to import all the controllers.FROM google/dart-runtime
在根目录创建另一个文件service.yaml并添加以下内容:
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: PROJECT_NAME
namespace: default
spec:
template:
spec:
containers:
- image: docker.io/YOUR_DOCKER_NAME/PROJECT_NAME
env:
- name: TARGET
value: "PROJECT_NAME v1"
将PROJECT_NAME替换为你的项目名称,本文示例中我的项目名称是shared-dart。
你还需要将YOUR_DOCKER_NAME替换为你的Docker用户名,这样容器才能正确部署。
更新pubspec.yaml文件,添加以下内容:
name: shared_dart
description: A new Flutter project.
publish_to: none
version: 1.0.0+1
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
shelf: ^0.7.3
cupertino_icons: ^0.1.2
http: ^0.12.0+2
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
这里最重要的包是shelf,它允许我们用Dart运行HTTP服务。
在项目根目录创建bin文件夹,然后添加server.dart文件并替换为以下内容:
import 'dart:io';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as io;
import 'src/routing.dart';
void main() {
final handler = const shelf.Pipeline()
.addMiddleware(shelf.logRequests())
.addHandler(RouteUtils.handler);
final port = int.tryParse(Platform.environment['PORT'] ?? '8080');
final address = InternetAddress.anyIPv4;
io.serve(handler, address, port).then((server) {
server.autoCompress = true;
print('Serving at http://${server.address.host}:${server.port}');
});
}
这会告诉容器要监听哪个端口以及如何处理请求。
在bin文件夹中创建src文件夹,添加routing.dart文件并替换内容为以下代码:
import 'dart:async';
import 'package:shelf/shelf.dart' as shelf;
import 'controllers/index.dart';
import 'result.dart';
class RouteUtils {
static FutureOr<shelf.Response> handler(shelf.Request request) {
var component = request.url.pathSegments.first;
var handler = _handlers(request)[component];
if (handler == null) return shelf.Response.notFound(null);
return handler;
}
static Map<String, FutureOr<shelf.Response>> _handlers(
shelf.Request request) {
return {
'info': ServerResponse('Info', body: {
"version": 'v1.0.0',
"status": "ok",
}).ok(),
'counter': CounterController().result(request),
'todos': TodoController().result(request),
};
}
}
现在还没有从主项目中导入任何内容,但你会发现一些相似之处。这里我们为todos和counter路径指定了控制器。
'counter': CounterController().result(request),
'todos': TodoController().result(request),
这意味着任何类似https://mydomain.com/todos、https://mydomain.com/todos/1的请求都会被路由到TodoController处理。
> 这也是我第一次了解到FutureOr,它允许你返回同步或异步函数。
构建REST API的一个重要部分是拥有一致的响应体,所以我们可以创建一个包装类,添加我们总是想要返回的字段,比如请求状态、消息和响应体。
在src/result.dart创建文件并添加以下内容:
import 'dart:convert';
import 'package:shelf/shelf.dart' as shelf;
class ServerResponse {
final String message;
final dynamic body;
final StatusType type;
ServerResponse(
this.message, {
this.type = StatusType.success,
this.body,
});
Map<String, dynamic> toJson() {
return {
"status": type.toString().replaceAll('StatusType.', ''),
"message": message,
"body": body ?? '',
};
}
String toJsonString() {
return json.encode(toJson());
}
shelf.Response ok() {
return shelf.Response.ok(
toJsonString(),
headers: {
'Content-Type': 'application/json',
},
);
}
}
enum StatusType { success, error }
abstract class ResponseImpl {
Future<shelf.Response> result(shelf.Request request);
}
这会始终返回JSON格式的数据以及我们想要展示的字段。你也可以在这里添加分页元数据。
在src/controllers/counter.dart创建文件并添加以下内容:
import 'package:shared_dart/src/models/counter.dart';
import 'package:shelf/shelf.dart' as shelf;
import '../result.dart';
class CounterController implements ResponseImpl {
const CounterController();
@override
Future<shelf.Response> result(shelf.Request request) async {
final _model = CounterModel();
final _params = request.url.queryParameters;
if (_params != null) {
final _val = int.tryParse(_params['count'] ?? '0');
_model.set(_val);
} else {
_model.add();
}
return ServerResponse('Info', body: {
"counter": _model.count,
}).ok();
}
}
你会看到这里导入了根项目lib文件夹中的内容。因为共享同一个pubspec.yaml,所以所有包都可以共享。你可以导入我们之前创建的CounterModel。
在src/controllers/todos.dart创建文件并添加以下内容:
import 'package:shared_dart/src/models/todos.dart';
import 'package:shelf/src/request.dart';
import 'package:shelf/src/response.dart';
import '../result.dart';
class TodoController implements ResponseImpl {
@override
Future<Response> result(Request request) async {
final _model = TodosModel();
if (request.url.pathSegments.length > 1) {
final _id = int.tryParse(request.url.pathSegments[1] ?? '1');
final _todo = await _model.getItem(_id);
return ServerResponse('Todo Item', body: _todo).ok();
}
final _todos = await _model.getList();
return ServerResponse(
'List Todos',
body: _todos.map((t) => t.toJson()).toList(),
).ok();
}
}
和之前一样,我们从lib文件夹中导入了TodosModel。
为了方便使用,在src/controllers/index.dart创建文件并添加以下内容:
export 'counter.dart';
export 'todo.dart';
这样可以更轻松地导入所有控制器。Run the Project (Server)
运行项目(服务端)
If you are using VSCode then you will need to update your launch.json with the following:
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: [https://go.microsoft.com/fwlink/?linkid=830387](https://go.microsoft.com/fwlink/?linkid=830387)
"version": "0.2.0",
"configurations": [
{
"name": "Client",
"request": "launch",
"type": "dart",
"program": "lib/main.dart"
},
{
"name": "Server",
"request": "launch",
"type": "dart",
"program": "bin/server.dart"
}
]
}Now when you hit run with Server selected you will see the output:
You can navigate to this in a browser but you can also work with this in Postman.
Just by adding to the url todos and todos/1 it will return different responses.
For the counter model we can use query parameters too!
Just by adding ?count=22 it will update the model with the input.
Keep in mind this is running your Dart code from you lib folder in your Flutter project without needing the Flutter widgets!
As a side benefit we can also run this project on Desktop. Check out the final project for the desktop folders needed from Flutter Desktop Embedding.
如果你使用VSCode,需要更新launch.json文件,添加以下内容:
{
// 使用IntelliSense了解可能的属性
// 悬停查看现有属性的描述
// 更多信息请访问:[https://go.microsoft.com/fwlink/?linkid=830387](https://go.microsoft.com/fwlink/?linkid=830387)
"version": "0.2.0",
"configurations": [
{
"name": "Client",
"request": "launch",
"type": "dart",
"program": "lib/main.dart"
},
{
"name": "Server",
"request": "launch",
"type": "dart",
"program": "bin/server.dart"
}
]
}现在当你选择Server并点击运行时,会看到以下输出:
你可以在浏览器中访问这个地址,也可以使用Postman来测试。
只需在URL中添加todos和todos/1,就会返回不同的响应。
对于计数器模型,我们还可以使用查询参数!
只需添加?count=22,就会用输入的值更新模型。
请记住,这是直接运行Flutter项目lib文件夹中的Dart代码,不需要Flutter widgets!
还有一个额外的好处是我们也可以在桌面端运行这个项目。查看最终项目中来自Flutter Desktop Embedding的桌面文件夹。
Conclusion
总结
Now if you wanted to deploy the container to Cloud Run you could with the following command:
gcloud builds submit — tag gcr.io/YOUR_GOOGLE_PROJECT_ID/PROJECT_NAME .
Replace PROJECT_NAME with your project name, mine is shared-dart for this example.
You will also need to replace YOUR_GOOGLE_PROJECT_ID with your Google Cloud Project ID. You can create one here.
Again the final project source code is here. Let me know your thoughts!