Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add Utf8JsonTransformer #2239

Merged
merged 18 commits into from
Jun 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dio/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ See the [Migration Guide][] for the complete breaking changes list.**
- Add constructor for `DioExceptionType.badCertificate`.
- Create type alias `DioMediaType` for `http_parser`'s `MediaType`.
- Fix the type conversion regression when using `MultipartFile.fromBytes`.
- Add FusedTransformer for improved performance when decoding JSON.
- Improves `InterceptorState.toString()`.

## 5.4.3+1
Expand Down
1 change: 1 addition & 0 deletions dio/lib/dio.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ export 'src/redirect_record.dart';
export 'src/response.dart';
export 'src/transformer.dart';
export 'src/transformers/background_transformer.dart';
export 'src/transformers/fused_transformer.dart';
export 'src/transformers/sync_transformer.dart';
30 changes: 30 additions & 0 deletions dio/lib/src/transformer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import 'adapter.dart';
import 'options.dart';
import 'utils.dart';

/// The callback definition for decoding a JSON string.
typedef JsonDecodeCallback = FutureOr<dynamic> Function(String);

/// The callback definition for encoding a JSON object.
typedef JsonEncodeCallback = FutureOr<String> Function(Object);

/// [Transformer] allows changes to the request/response data before
/// it is sent/received to/from the server.
///
Expand Down Expand Up @@ -73,4 +79,28 @@ abstract class Transformer {
mediaType.mimeType == 'text/json' ||
mediaType.subtype.endsWith('+json');
}

static FutureOr<String> defaultTransformRequest(
RequestOptions options,
JsonEncodeCallback jsonEncodeCallback,
) {
final Object data = options.data ?? '';
if (data is! String && Transformer.isJsonMimeType(options.contentType)) {
return jsonEncodeCallback(data);
} else if (data is Map) {
if (data is Map<String, dynamic>) {
return Transformer.urlEncodeMap(data, options.listFormat);
}
debugLog(
'The data is a type of `Map` (${data.runtimeType}), '
'but the transformer can only encode `Map<String, dynamic>`.\n'
'If you are writing maps using `{}`, '
'consider writing `<String, dynamic>{}`.',
StackTrace.current,
);
return data.toString();
} else {
return data.toString();
}
}
}
185 changes: 185 additions & 0 deletions dio/lib/src/transformers/fused_transformer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import 'dart:convert';
import 'dart:typed_data';

import '../adapter.dart';
import '../compute/compute.dart';
import '../headers.dart';
import '../options.dart';
import '../transformer.dart';

/// A [Transformer] that has a fast path for decoding UTF8-encoded JSON.
/// If the response is utf8-encoded JSON and no custom decoder is specified in the [RequestOptions], this transformer
/// is significantly faster than the default [SyncTransformer] and the [BackgroundTransformer].
/// This improvement is achieved by using a fused [Utf8Decoder] and [JsonDecoder] to decode the response,
/// which is faster than decoding the utf8-encoded JSON in two separate steps, since
/// Dart uses a special fast decoder for this case.
/// See https://github.com/dart-lang/sdk/blob/5b2ea0c7a227d91c691d2ff8cbbeb5f7f86afdb9/sdk/lib/_internal/vm/lib/convert_patch.dart#L40
///
/// By default, this transformer will transform responses in the main isolate,
/// but a custom threshold can be set to switch to an isolate for large responses by passing
/// [contentLengthIsolateThreshold].
class FusedTransformer extends Transformer {
FusedTransformer({this.contentLengthIsolateThreshold = -1});

/// Always decode the response in the same isolate
factory FusedTransformer.sync() =>
FusedTransformer(contentLengthIsolateThreshold: -1);

// whether to switch decoding to an isolate for large responses
// set to -1 to disable, 0 to always use isolate
final int contentLengthIsolateThreshold;

static final _utf8JsonDecoder = const Utf8Decoder().fuse(const JsonDecoder());

@override
Future<String> transformRequest(RequestOptions options) async {
return Transformer.defaultTransformRequest(options, jsonEncode);
}

@override
Future<dynamic> transformResponse(
RequestOptions options,
ResponseBody responseBody,
) async {
final responseType = options.responseType;
// Do not handle the body for streams.
if (responseType == ResponseType.stream) {
return responseBody;
}

// Return the finalized bytes if the response type is bytes.
if (responseType == ResponseType.bytes) {
return _consolidateStream(responseBody.stream);
}

final isJsonContent = Transformer.isJsonMimeType(
responseBody.headers[Headers.contentTypeHeader]?.first,
);

final customResponseDecoder = options.responseDecoder;

// No custom decoder was specified for the response,
// and the response is json -> use the fast path decoder
if (isJsonContent && customResponseDecoder == null) {
return _fastUtf8JsonDecode(responseBody);
}
final responseBytes = await _consolidateStream(responseBody.stream);

// A custom response decoder overrides the default behavior
final String? decodedResponse;

if (customResponseDecoder != null) {
final decodeResponse = customResponseDecoder(
responseBytes,
options,
responseBody..stream = const Stream.empty(),
);

if (decodeResponse is Future) {
decodedResponse = await decodeResponse;
} else {
decodedResponse = decodeResponse;
}
} else {
decodedResponse = null;
}

if (isJsonContent && decodedResponse != null) {
// slow path decoder, since there was a custom decoder specified
return jsonDecode(decodedResponse);
} else if (decodedResponse != null) {
return decodedResponse;
} else {
// If the response is not JSON and no custom decoder was specified,
// assume it is an utf8 string
return utf8.decode(
responseBytes,
allowMalformed: true,
);
}
}

Future<Object?> _fastUtf8JsonDecode(ResponseBody responseBody) async {
final contentLengthHeader =
responseBody.headers[Headers.contentLengthHeader];

final hasContentLengthHeader =
contentLengthHeader != null && contentLengthHeader.isNotEmpty;

// The content length of the response, either from the content-length header
// of the response or the length of the eagerly decoded response bytes
final int contentLength;

// The eagerly decoded response bytes
// which is set if the content length is not specified and
// null otherwise (we'll feed the stream directly to the decoder in that case)
Uint8List? responseBytes;

// If the content length is not specified, we need to consolidate the stream
// and count the bytes to determine if we should use an isolate
// otherwise we use the content length header
if (!hasContentLengthHeader) {
responseBytes = await _consolidateStream(responseBody.stream);
contentLength = responseBytes.length;
} else {
contentLength = int.parse(contentLengthHeader.first);
}

// The decoding in done on an isolate if
// - contentLengthIsolateThreshold is not -1
// - the content length, calculated from either
// the content-length header if present or the eagerly decoded response bytes,
// is greater than or equal to contentLengthIsolateThreshold
final shouldUseIsolate = !(contentLengthIsolateThreshold < 0) &&
contentLength >= contentLengthIsolateThreshold;
if (shouldUseIsolate) {
// we can't send the stream to the isolate, so we need to decode the response bytes first
return compute(
_decodeUtf8ToJson,
responseBytes ?? await _consolidateStream(responseBody.stream),
);
} else {
if (!hasContentLengthHeader || contentLength == 0) {
// This path is for backwards compatibility.
// If content-type indicates a json response,
// but the body is empty, null is returned.
// _utf8JsonDecoder.bind(responseBody.stream) would throw if the body is empty.
// So we need to check if the body is empty and return null in that case
responseBytes ??= await _consolidateStream(responseBody.stream);
if (responseBytes.isEmpty) {
return null;
}
return _utf8JsonDecoder.convert(responseBytes);
} else {
assert(responseBytes == null);
// The content length is specified and we can feed the stream directly to the decoder,
// without eagerly decoding the response bytes first.
final decodedStream = _utf8JsonDecoder.bind(responseBody.stream);
final decoded = await decodedStream.toList();
if (decoded.isEmpty) {
return null;
}
assert(decoded.length == 1);
return decoded.first;
}
}
}

static Future<Object?> _decodeUtf8ToJson(Uint8List data) async {
if (data.isEmpty) {
return null;
}
return _utf8JsonDecoder.convert(data);
}
}

/// Consolidates a stream of [Uint8List] into a single [Uint8List]
Future<Uint8List> _consolidateStream(Stream<Uint8List> stream) async {
final builder = BytesBuilder(copy: false);

await for (final chunk in stream) {
builder.add(chunk);
}

return builder.takeBytes();
}
26 changes: 1 addition & 25 deletions dio/lib/src/transformers/sync_transformer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,10 @@ import '../adapter.dart';
import '../headers.dart';
import '../options.dart';
import '../transformer.dart';
import '../utils.dart';

@Deprecated('Use BackgroundTransformer instead')
typedef DefaultTransformer = SyncTransformer;

/// The callback definition for decoding a JSON string.
typedef JsonDecodeCallback = FutureOr<dynamic> Function(String);

/// The callback definition for encoding a JSON object.
typedef JsonEncodeCallback = FutureOr<String> Function(Object);

/// If you want to custom the transformation of request/response data,
/// you can provide a [Transformer] by your self, and replace
/// the transformer by setting the [Dio.transformer].
Expand All @@ -31,24 +24,7 @@ class SyncTransformer extends Transformer {

@override
Future<String> transformRequest(RequestOptions options) async {
final Object data = options.data ?? '';
if (data is! String && Transformer.isJsonMimeType(options.contentType)) {
return jsonEncodeCallback(data);
} else if (data is Map) {
if (data is Map<String, dynamic>) {
return Transformer.urlEncodeMap(data, options.listFormat);
}
debugLog(
'The data is a type of `Map` (${data.runtimeType}), '
'but the transformer can only encode `Map<String, dynamic>`.\n'
'If you are writing maps using `{}`, '
'consider writing `<String, dynamic>{}`.',
StackTrace.current,
);
return data.toString();
} else {
return data.toString();
}
return Transformer.defaultTransformRequest(options, jsonEncodeCallback);
}

@override
Expand Down
Loading