How to sync realm with own back end - a1k89/Blog GitHub Wiki
- iOS application (swift) with
realm(or android) - Django with
PostgreSQL - We want to provide full offline work (now it's work by default) and
synchronizeoffline data through all account devices
- Which algorithm?
- How to resolve:
- We have 2 devices for current user
- Device A work offline
- Device B work offline
- Both devices got online and start synchronization
- How to sync media? (photo/audio/video/files)? iCloud? Base64? Upload to our media server?
- We must to split our work:
- Realm-part (swift)
- Back-end part (python/Django)
- Server: is our single place of truth
- Conflicts: last writer wins
- We don't delete any instance. We set
is_deleteflag only
- Add to all models field
is_synced (bool). - When created:
is_synced=false - When we change model set
is_synced=false - Run service and collect all no-synced models and send it to server
- Get response from server and update our models. Set
is_synced=true. - We must to update all models or create new (if not exist in our database)
- We must provide full background async task
- For full control send to server model-by-model. Send one - update. Send the next one - update
- We create separate model
SyncModel - We write a
servicewhich observe all models - When something happen (update/create) we write on
SyncModel:modelType + identifier - Create some trigger(?) to run synchronization our
SyncModel - For full control: send to server model-by-model
- Run service on every application start
- Create
SyncService - Register models to observe in
SyncService - When something change in model (create/update) we send our model instance to server
immediately - If has error or something else mark our model in
SyncModel(fromPlan B) and synchronizeSyncModelin the future
- Create models 1:1 as realm
- Create
SyncService - Create
SyncSerializer(entry point and first serialization) -
client data->SyncSerializer->SyncService-> getuid, modify_at, model, data-> comparemodify_at-> update/ignore ->200 response
- Create single
SyncModel:owner, created_at, json_data, model_name, uid - Write a
SyncService - Got a data to sync
- Got
owner,model_nameandcreated_at. Gotbody(our data) - Find a row:
owner + model_name + uid - If row exist check
created_atfield and followpoint4 from Plan A - If not exist create new row
- In this we not thinking about relations or another field. We simple create/update rows
- Create
Uploadableprotocol and some helpers:
protocol Uploadable: Codable, Mappable {
var resourseType: String { get }
var uid: String { get }
}
typealias Syncable = Object & Uploadable
typealias SyncableMappable = Object & Uploadable & Mappable
struct UpdateStruct {
let identifiers: [String]
let resourceTypes:[String]
}- Create extension for
Uploadable. Usenotification systemfrom realm.Static funcis a class method to register models and check all updates. Closure of this method return type of model and identifier.
extension Uploadable where Self: Object {
static func registerNotificationObserver(for realm: Realm,
callback: @escaping(UpdateStruct) -> Void) -> NotificationToken{
let objects = realm.objects(self)
let notificationToken = objects.observe { changes in
switch changes {
case .update(let collections, _, let insertions, let modifications):
if collections.isEmpty { return }
var response = insertions.map{collections[$0]}
response += modifications.map{collections[$0]}
let prepare = UpdateStruct(identifiers: response.map{$0.uid},
resourceTypes: response.map{$0.resourseType})
callback(prepare)
break
default:
break
}
}
return notificationToken
}
}- Create
SyncService:
import Foundation
import RealmSwift
import ObjectMapper
import AlamofireObjectMapper
import Alamofire
final class SyncService:NSObject, ApplicationService {
static let shared: SyncService = SyncService()
private override init() {}
var tokens: [NotificationToken] = [NotificationToken]()
var modelTypes:[Syncable.Type] = [
User.self
]
}
extension SyncService {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
}
extension SyncService {
func register() {
let realm = try! Realm()
tokens = modelTypes.compactMap{$0.registerNotificationObserver(for: realm) { updates in
print("updates: ", updates)
}}
}
}Now if we change User model we will see in console type of model and identifier. Good.
- The next step: after got a
Updatesstructure try to send changes to server (through API). If has error we will saveUpdatesstructure in our cacheSyncQueueModel. We save in one string:<userUID>_<modelName>_<instanceUID>
protocol SyncQueueModelProtocol {
var compoundKey: String { get }
var createdAt: Date { get }
}
class SyncQueueModel: Object, SyncQueueModelProtocol {
@objc dynamic var compoundKey:String = ""
@objc dynamic var createdAt: Date = Date()
override static func primaryKey() -> String? {
return "compoundKey"
}
func configure(userUID:String, modelType: String, identifier: String, data:Data){
let key = "\(userUID)_\(modelType)_\(identifier)"
self.compoundKey = key
}
}Rules:
- All models must have:
owner, uid, modify_atfields
Mark: As example use User model
- Create app
data. Addmodels.py - Add to
models.py:
from django.db import models
from django.contrib.auth import get_user_model
class User(get_user_model()):
class Meta:
proxy = True
@property
def owner(self):
return self- Create serializer classes
serializers.py
class UserSerializer(ModelSerializer):
name = serializers.CharField(source='first_name')
jwt_token = serializers.CharField(read_only=True)
modify_at = TimestampField()
class Meta:
model = User
fields = [
'uid',
'name',
'email',
'modify_at',
'jwt_token',]
read_only_fields = (
'uid',
'jwt_token',
)- Create entry point:
serializers.py
class SyncSerializer(serializers.Serializer):
uid = serializers.UUIDField()
model = serializers.CharField()
modify_at = TimestampField()
data = serializers.JSONField()- Create
SyncService:
import pytz
from django.apps import apps
from django.db import transaction
from django.contrib.auth import get_user_model
from rest_framework.exceptions import PermissionDenied
from api.v1.exceptions import ServiceException
User = get_user_model()
class SyncService:
def __init__(self, request, model, uid, modify_at, data):
app_label, model_name = model.split('.')
self.request = request
self.identifier = uid
self.modify_at = modify_at
self.model = apps.get_model(app_label=app_label,
model_name=model_name)
self.data = data
@classmethod
def execute(cls, *args, **kwargs):
instance = cls(*args, **kwargs)
with transaction.atomic():
return instance.sync()
def get_serializer_class(self):
return self.model.get_serializer()
def get_serializer(self, *args, **kwargs):
serializer_class = self.get_serializer_class()
return serializer_class(*args, **kwargs)
def check_object_permissions(self, instance):
if self.request.user != instance.owner:
raise PermissionDenied()
def is_newest(self, instance):
"""
Check incoming and instance datetime. Both must be use utc
"""
if instance is None:
return True
if self.modify_at.tzinfo != pytz.utc:
raise ServiceException('Date is not utc')
return self.modify_at >= instance.modify_at
def get_instance(self):
kwargs = {'uid': self.identifier,
'owner': self.request.user}
if issubclass(self.model, User):
kwargs.pop('owner')
instance = self.model.objects\
.filter(**kwargs)\
.first()
return instance
def sync(self):
instance = self.get_instance()
self.check_object_permissions(instance)
serializer = self.get_serializer(instance=instance,
data=self.data)
if serializer.is_valid():
if self.is_newest(instance):
serializer.save()
return serializer.data
raise ServiceException(serializer.errors)- Create
APIViewview:
api.v1.sync.py:
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from api.v1.serializers import SyncSerializer
from apps.data.services.sync import SyncService
class SyncGenericApiView(APIView):
def post(self, request, *args, **kwargs):
serializer = SyncSerializer(data=request.data)
if serializer.is_valid():
validated_data = serializer.validated_data
validated_data['request'] = self.request
result = SyncService.execute(**validated_data)
return Response(result)
return Response(serializer.errors,
status.HTTP_400_BAD_REQUEST)- Add endpoint url:
urls.py
urlpatterns += [
path('sync/', SyncGenericApiView.as_view())
]And this is all. Now we have single class to synchronize our incoming data to models.