1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Merge pull request #30941 from toptal/introduce-custom-serializers-to-activejob-arguments

Introduce custom serializers to ActiveJob arguments
This commit is contained in:
Rafael França 2018-02-14 14:47:46 -05:00 committed by GitHub
commit fa9e791e01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 454 additions and 14 deletions

View file

@ -1,3 +1,6 @@
* Add support to define custom argument serializers.
*Evgenii Pecherkin*, *Rafael Mendonça França*
Please check [5-2-stable](https://github.com/rails/rails/blob/5-2-stable/activejob/CHANGELOG.md) for previous changes.

View file

@ -33,6 +33,7 @@ module ActiveJob
autoload :Base
autoload :QueueAdapters
autoload :Serializers
autoload :ConfiguredJob
autoload :TestCase
autoload :TestHelper

View file

@ -44,13 +44,24 @@ module ActiveJob
end
private
# :nodoc:
GLOBALID_KEY = "_aj_globalid".freeze
# :nodoc:
SYMBOL_KEYS_KEY = "_aj_symbol_keys".freeze
# :nodoc:
WITH_INDIFFERENT_ACCESS_KEY = "_aj_hash_with_indifferent_access".freeze
private_constant :GLOBALID_KEY, :SYMBOL_KEYS_KEY, :WITH_INDIFFERENT_ACCESS_KEY
# :nodoc:
OBJECT_SERIALIZER_KEY = "_aj_serialized"
# :nodoc:
RESERVED_KEYS = [
GLOBALID_KEY, GLOBALID_KEY.to_sym,
SYMBOL_KEYS_KEY, SYMBOL_KEYS_KEY.to_sym,
OBJECT_SERIALIZER_KEY, OBJECT_SERIALIZER_KEY.to_sym,
WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym,
]
private_constant :RESERVED_KEYS
def serialize_argument(argument)
case argument
@ -70,7 +81,7 @@ module ActiveJob
result[SYMBOL_KEYS_KEY] = symbol_keys
result
else
raise SerializationError.new("Unsupported argument type: #{argument.class.name}")
Serializers.serialize(argument)
end
end
@ -85,6 +96,8 @@ module ActiveJob
when Hash
if serialized_global_id?(argument)
deserialize_global_id argument
elsif custom_serialized?(argument)
Serializers.deserialize(argument)
else
deserialize_hash(argument)
end
@ -101,6 +114,10 @@ module ActiveJob
GlobalID::Locator.locate hash[GLOBALID_KEY]
end
def custom_serialized?(hash)
hash.key?(OBJECT_SERIALIZER_KEY)
end
def serialize_hash(argument)
argument.each_with_object({}) do |(key, value), hash|
hash[serialize_hash_key(key)] = serialize_argument(value)
@ -117,14 +134,6 @@ module ActiveJob
result
end
# :nodoc:
RESERVED_KEYS = [
GLOBALID_KEY, GLOBALID_KEY.to_sym,
SYMBOL_KEYS_KEY, SYMBOL_KEYS_KEY.to_sym,
WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym,
]
private_constant :RESERVED_KEYS
def serialize_hash_key(key)
case key
when *RESERVED_KEYS

View file

@ -59,6 +59,7 @@ module ActiveJob #:nodoc:
# * SerializationError - Error class for serialization errors.
class Base
include Core
include Serializers
include QueueAdapter
include QueueName
include QueuePriority

View file

@ -7,11 +7,17 @@ module ActiveJob
# = Active Job Railtie
class Railtie < Rails::Railtie # :nodoc:
config.active_job = ActiveSupport::OrderedOptions.new
config.active_job.custom_serializers = []
initializer "active_job.logger" do
ActiveSupport.on_load(:active_job) { self.logger = ::Rails.logger }
end
initializer "active_job.custom_serializers" do |app|
custom_serializers = app.config.active_job.delete(:custom_serializers)
ActiveJob::Serializers.add_serializers custom_serializers
end
initializer "active_job.set_configs" do |app|
options = app.config.active_job
options.queue_adapter ||= :async

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true
require "set"
module ActiveJob
# The <tt>ActiveJob::Serializers</tt> module is used to store a list of known serializers
# and to add new ones. It also has helpers to serialize/deserialize objects
module Serializers
extend ActiveSupport::Autoload
extend ActiveSupport::Concern
autoload :ObjectSerializer
autoload :SymbolSerializer
autoload :DurationSerializer
autoload :DateSerializer
autoload :TimeSerializer
autoload :DateTimeSerializer
mattr_accessor :_additional_serializers
self._additional_serializers = Set.new
class << self
# Returns serialized representative of the passed object.
# Will look up through all known serializers.
# Raises `ActiveJob::SerializationError` if it can't find a proper serializer.
def serialize(argument)
serializer = serializers.detect { |s| s.serialize?(argument) }
raise SerializationError.new("Unsupported argument type: #{argument.class.name}") unless serializer
serializer.serialize(argument)
end
# Returns deserialized object.
# Will look up through all known serializers.
# If no serializers found will raise `ArgumentError`
def deserialize(argument)
serializer_name = argument[Arguments::OBJECT_SERIALIZER_KEY]
raise ArgumentError, "Serializer name is not present in the argument: #{argument.inspect}" unless serializer_name
serializer = serializer_name.safe_constantize
raise ArgumentError, "Serializer #{serializer_name} is not know" unless serializer
serializer.deserialize(argument)
end
# Returns list of known serializers
def serializers
self._additional_serializers
end
# Adds a new serializer to a list of known serializers
def add_serializers(*new_serializers)
self._additional_serializers += new_serializers
end
end
add_serializers SymbolSerializer,
DurationSerializer,
DateTimeSerializer,
DateSerializer,
TimeSerializer
end
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module ActiveJob
module Serializers
class DateSerializer < ObjectSerializer # :nodoc:
def serialize(date)
super("value" => date.iso8601)
end
def deserialize(hash)
Date.iso8601(hash["value"])
end
private
def klass
Date
end
end
end
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module ActiveJob
module Serializers
class DateTimeSerializer < ObjectSerializer # :nodoc:
def serialize(time)
super("value" => time.iso8601)
end
def deserialize(hash)
DateTime.iso8601(hash["value"])
end
private
def klass
DateTime
end
end
end
end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
module ActiveJob
module Serializers
class DurationSerializer < ObjectSerializer # :nodoc:
def serialize(duration)
super("value" => duration.value, "parts" => Arguments.serialize(duration.parts))
end
def deserialize(hash)
value = hash["value"]
parts = Arguments.deserialize(hash["parts"])
klass.new(value, parts)
end
private
def klass
ActiveSupport::Duration
end
end
end
end

View file

@ -0,0 +1,54 @@
# frozen_string_literal: true
module ActiveJob
module Serializers
# Base class for serializing and deserializing custom times.
#
# Example
#
# class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
# def serialize(money)
# super("cents" => money.cents, "currency" => money.currency)
# end
#
# def deserialize(hash)
# Money.new(hash["cents"], hash["currency"])
# end
#
# private
#
# def klass
# Money
# end
# end
class ObjectSerializer
include Singleton
class << self
delegate :serialize?, :serialize, :deserialize, to: :instance
end
# Determines if an argument should be serialized by a serializer.
def serialize?(argument)
argument.is_a?(klass)
end
# Serializes an argument to a JSON primitive type.
def serialize(hash)
{ Arguments::OBJECT_SERIALIZER_KEY => self.class.name }.merge!(hash)
end
# Deserilizes an argument form a JSON primiteve type.
def deserialize(_argument)
raise NotImplementedError
end
protected
# The class of the object that will be serialized.
def klass
raise NotImplementedError
end
end
end
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module ActiveJob
module Serializers
class SymbolSerializer < ObjectSerializer # :nodoc:
def serialize(argument)
super("value" => argument.to_s)
end
def deserialize(argument)
argument["value"].to_sym
end
private
def klass
Symbol
end
end
end
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module ActiveJob
module Serializers
class TimeSerializer < ObjectSerializer # :nodoc:
def serialize(time)
super("value" => time.iso8601)
end
def deserialize(hash)
Time.iso8601(hash["value"])
end
private
def klass
Time
end
end
end
end

View file

@ -12,7 +12,10 @@ class ArgumentSerializationTest < ActiveSupport::TestCase
end
[ nil, 1, 1.0, 1_000_000_000_000_000_000_000,
"a", true, false, BigDecimal(5),
"a", true, false, BigDecimal.new(5),
:a, 1.day, Date.new(2001, 2, 3), Time.new(2002, 10, 31, 2, 2, 2, "+02:00"),
DateTime.new(2001, 2, 3, 4, 5, 6, "+03:00"),
ActiveSupport::TimeWithZone.new(Time.utc(1999, 12, 31, 23, 59, 59), ActiveSupport::TimeZone["UTC"]),
[ 1, "a" ],
{ "a" => 1 }
].each do |arg|
@ -21,7 +24,7 @@ class ArgumentSerializationTest < ActiveSupport::TestCase
end
end
[ :a, Object.new, self, Person.find("5").to_gid ].each do |arg|
[ Object.new, self, Person.find("5").to_gid ].each do |arg|
test "does not serialize #{arg.class}" do
assert_raises ActiveJob::SerializationError do
ActiveJob::Arguments.serialize [ arg ]
@ -46,6 +49,49 @@ class ArgumentSerializationTest < ActiveSupport::TestCase
assert_arguments_roundtrip([a: 1, "b" => 2])
end
test "serialize a hash" do
symbol_key = { a: 1 }
string_key = { "a" => 1 }
indifferent_access = { a: 1 }.with_indifferent_access
assert_equal(
{ "a" => 1, "_aj_symbol_keys" => ["a"] },
ActiveJob::Arguments.serialize([symbol_key]).first
)
assert_equal(
{ "a" => 1, "_aj_symbol_keys" => [] },
ActiveJob::Arguments.serialize([string_key]).first
)
assert_equal(
{ "a" => 1, "_aj_hash_with_indifferent_access" => true },
ActiveJob::Arguments.serialize([indifferent_access]).first
)
end
test "deserialize a hash" do
symbol_key = { "a" => 1, "_aj_symbol_keys" => ["a"] }
string_key = { "a" => 1, "_aj_symbol_keys" => [] }
another_string_key = { "a" => 1 }
indifferent_access = { "a" => 1, "_aj_hash_with_indifferent_access" => true }
assert_equal(
{ a: 1 },
ActiveJob::Arguments.deserialize([symbol_key]).first
)
assert_equal(
{ "a" => 1 },
ActiveJob::Arguments.deserialize([string_key]).first
)
assert_equal(
{ "a" => 1 },
ActiveJob::Arguments.deserialize([another_string_key]).first
)
assert_equal(
{ "a" => 1 },
ActiveJob::Arguments.deserialize([indifferent_access]).first
)
end
test "should maintain hash with indifferent access" do
symbol_key = { a: 1 }
string_key = { "a" => 1 }

View file

@ -0,0 +1,98 @@
# frozen_string_literal: true
require "helper"
require "active_job/serializers"
class SerializersTest < ActiveSupport::TestCase
class DummyValueObject
attr_accessor :value
def initialize(value)
@value = value
end
def ==(other)
self.value == other.value
end
end
class DummySerializer < ActiveJob::Serializers::ObjectSerializer
def serialize(object)
super({ "value" => object.value })
end
def deserialize(hash)
DummyValueObject.new(hash["value"])
end
private
def klass
DummyValueObject
end
end
setup do
@value_object = DummyValueObject.new 123
@original_serializers = ActiveJob::Serializers.serializers
end
teardown do
ActiveJob::Serializers._additional_serializers = @original_serializers
end
test "can't serialize unknown object" do
assert_raises ActiveJob::SerializationError do
ActiveJob::Serializers.serialize @value_object
end
end
test "will serialize objects with serializers registered" do
ActiveJob::Serializers.add_serializers DummySerializer
assert_equal(
{ "_aj_serialized" => "SerializersTest::DummySerializer", "value" => 123 },
ActiveJob::Serializers.serialize(@value_object)
)
end
test "won't deserialize unknown hash" do
hash = { "_dummy_serializer" => 123, "_aj_symbol_keys" => [] }
error = assert_raises(ArgumentError) do
ActiveJob::Serializers.deserialize(hash)
end
assert_equal(
'Serializer name is not present in the argument: {"_dummy_serializer"=>123, "_aj_symbol_keys"=>[]}',
error.message
)
end
test "won't deserialize unknown serializer" do
hash = { "_aj_serialized" => "DoNotExist", "value" => 123 }
error = assert_raises(ArgumentError) do
ActiveJob::Serializers.deserialize(hash)
end
assert_equal(
"Serializer DoNotExist is not know",
error.message
)
end
test "will deserialize know serialized objects" do
ActiveJob::Serializers.add_serializers DummySerializer
hash = { "_aj_serialized" => "SerializersTest::DummySerializer", "value" => 123 }
assert_equal DummyValueObject.new(123), ActiveJob::Serializers.deserialize(hash)
end
test "adds new serializer" do
ActiveJob::Serializers.add_serializers DummySerializer
assert ActiveJob::Serializers.serializers.include?(DummySerializer)
end
test "can't add serializer with the same key twice" do
ActiveJob::Serializers.add_serializers DummySerializer
assert_no_difference(-> { ActiveJob::Serializers.serializers.size }) do
ActiveJob::Serializers.add_serializers DummySerializer
end
end
end

View file

@ -339,8 +339,23 @@ UserMailer.welcome(@user).deliver_later # Email will be localized to Esperanto.
```
GlobalID
--------
Supported types for arguments
----------------------------
ActiveJob supports the following types of arguments by default:
- Basic types (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`)
- `Symbol
- `ActiveSupport::Duration`
- `Date`
- `Time`
- `DateTime`
- `ActiveSupport::TimeWithZone`
- `Hash`. Keys should be of `String` or `Symbol` type
- `ActiveSupport::HashWithIndifferentAccess`
- `Array`
### GlobalID
Active Job supports GlobalID for parameters. This makes it possible to pass live
Active Record objects to your job instead of class/id pairs, which you then have
@ -368,6 +383,41 @@ end
This works with any class that mixes in `GlobalID::Identification`, which
by default has been mixed into Active Record classes.
### Serializers
You can extend list of supported types for arguments. You just need to define your own serializer.
```ruby
class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
# Check if this object should be serialized using this serializer.
def serialize?(argument)
argument.is_a? Money
end
# Convert an object to a simpler representative using supported object types.
# The recommended representative is a Hash with a specific key. Keys can be of basic types only.
# You should call `super` to add the custom serializer type to the hash
def serialize(object)
super(
"cents" => object.cents,
"currency" => object.currency
)
end
# Convert serialized value into a proper object
def deserialize(hash)
Money.new hash["cents"], hash["currency"]
end
end
end
```
And now you just need to add this serializer to a list:
```ruby
Rails.application.config.active_job.custom_serializers << MySpecialSerializer
```
Exceptions
----------

View file

@ -741,6 +741,8 @@ There are a few configuration options available in Active Support:
* `config.active_job.logger` accepts a logger conforming to the interface of Log4r or the default Ruby Logger class, which is then used to log information from Active Job. You can retrieve this logger by calling `logger` on either an Active Job class or an Active Job instance. Set to `nil` to disable logging.
* `config.active_job.custom_serializers` allows to set custom argument serializers. Defaults to `[]`.
### Configuring Action Cable
* `config.action_cable.url` accepts a string for the URL for where