AppFlowy's backend defines all the events and generates the event's foreign function interface that supports Dart and TS event call.
Events are emitted in the frontend and are processed in the backend. Each event has its own handler in the backend.This mechanism uses a Protobuf-RPC like protocol under the hood to serialize requests and responses, all arguments and return data must be serializable to Protobuf.
This article introduces how AppFlowy uses protobuf buffer to exchange the data between the frontend and backend. The pattern as shown below:
Different frontend uses the corresponding FFI interface to communicate with the backend. For example:
- Dart
class UserEventSignIn {
SignInPayloadPB request;
UserEventSignIn(this.request);
Future<Either<UserProfilePB, FlowyError>> send() {
final request = FFIRequest.create()
..event = UserEvent.SignIn.toString()
..payload = requestToBytes(this.request);
return Dispatch.asyncRequest(request)
.then((bytesResult) => bytesResult.fold(
(okBytes) => left(UserProfilePB.fromBuffer(okBytes)),
(errBytes) => right(FlowyError.fromBuffer(errBytes)),
));
}
}
- TS
export async function UserEventSignIn(payload: pb.SignInPayloadPB): Promise<Result<pb.UserProfilePB, pb.FlowyError>> {
let args = {
request: {
ty: pb.UserEvent[pb.UserEvent.SignIn],
payload: Array.from(payload.serializeBinary()),
},
};
let result: { code: number; payload: Uint8Array } = await invoke("invoke_request", args);
if (result.code == 0) {
let object = pb.UserProfilePB.deserializeBinary(result.payload);
return Ok(object);
} else {
let error = pb.FlowyError.deserializeBinary(result.payload);
return Err(error);
}
}
So, just calling the corresponding function and then let the backend handle it. The result will be returned asynchronously.
Let's introduce the generating process step by step.
We define the Event
and the Protobuf data struct
in Rust, for example, the DocumentEvent
defined in event_map.rs and ExportDataPB
defined in entities.rs.
// event_map.rs
#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)]
#[event_err = "FlowyError"]
pub enum DocumentEvent {
#[event(input = "OpenDocumentContextPB", output = "DocumentSnapshotPB")]
GetDocument = 0,
#[event(input = "EditPayloadPB")]
ApplyEdit = 1,
#[event(input = "ExportPayloadPB", output = "ExportDataPB")]
ExportDocument = 2,
}
The annotation, #[event(input = Input struct, output = Output struct)]
is used to generate the event FFI function.
Input struct
mean the function receive the input parameter's type.Output struct
mean the function's return value's type
I think you noticed that there is aPB
keyword appended to every struct. We use the PB
keyword to identify this struct is in protobuf format.
// rust-lib/flowy-document/src/entities.rs
#[derive(Default, ProtoBuf)]
pub struct ExportDataPB {
// The annotation, index = 1, match the syntax that defines proto file.
#[pb(index = 1)]
pub data: String,
#[pb(index = 2)]
pub export_type: ExportType,
}
The procedural macro, ProtoBuf
, is used to mark this struct is going to generate the protobuf struct.
We use the syn to collect the AST information that will be used to generate the
proto file
. If you interest in how to collect the information in details, you should check out the Procedural Macros.
We use toml to control which files should be included when doing the code generation. It supports specify a single file or a folder.
proto_input = ["src/event_map.rs", "src/entities.rs"]
event_files = ["src/event_map.rs"]
proto_input
The proto_input receives path or file. The code gen
process will parse the proto_input in order to generate the struct/enum.
event_files
The event_files receives file that define the event. The code gen
process will parse the file in order to generate the corresponding language event class. The event class name consists of the Enum name and the Enum value defined in event_map.rs.
Build Scripts is the perfect way to do the code generation. Let's check out some pseudocode. We use features flag to control generate process. If the dart feature is on then the Dart event FFI functions will be generated.
// build.rs
fn main() {
let crate_name = env!("CARGO_PKG_NAME");
flowy_codegen::protobuf_file::gen(crate_name);
#[cfg(feature = "dart")]
flowy_codegen::dart_event::gen(crate_name);
#[cfg(feature = "ts")]
flowy_codegen::ts_event::gen(crate_name);
}
The code gen
process is embedded in the AppFlowy build process. But you can run the build process manually. Just go to the corresponding crate directory(For example, frontend/flowy-text-block), and run:
cargo build --features=dart
or if you want to check the verbose output.
cargo build -vv --features=dart
The build scripts will be run before the crate gets compiled. Thanks to the cargo toolchain, we use cargo:rerun-if-changed=PATH
to enable the build.rs will only run if the files were changed.
The rerun-if-changed instruction tells Cargo to re-run the build script if the file at the given path has changed. Currently, Cargo only uses the filesystem last-modified timestamp to determine if the file has changed. It compares against an internal cached timestamp of when the build script last ran.
After running the build.rs, it generates files in Dart/TS and Rust protobuf files using the same proto files.
Dart (with dart feature on):
dart_event.dart
The file is located in packages/appflowy_backend/lib/dispatch/dart_event/flowy-document
.
TS (with ts feature on):
event.ts
The file is located in appflowy_tauri/src/services/backend/events
.
Let's see how the message passing from the frontend to the backend. Let use dart for demonstration (It's the same in TS).
-
Repository constructs the
DocumentEventExportDocument
class, and callsend()
function. -
Frontend's FFI serializes the event and the
ExportPayloadPB
to bytes. -
The bytes were sent to Backend.
-
Backend's FFI deserializes the bytes into the corresponding
event
andExportPayloadPB
. -
The dispatcher sends the
ExportPayloadPB
to the crate that registers as the event handler. -
ExportPayloadPB
will try to parse intoExportParams
. It will return an error if there are illegal fields in it.For example: the
view_id
field in theExportPayloadPB
should not be empty. -
Crate's
export_handler
function gets called with the event and data. -
At the end,
export_handler
will return 'ExportDataPB', which will be post to the frontend.