- TypeScript Design Patterns
- Vilic Vane
- 894字
- 2021-07-14 10:23:18
Implementing the basics
Before we start to write actual code, we need to define what this synchronizing strategy will be like. To keep the implementation from unnecessary distractions, the client will communicate with the server directly through function calls instead of using HTTP requests or Sockets. Also, we'll use in-memory storage, namely variables, to store data on both client and server sides.
Because we are not separating the client and server into two actual applications, and we are not actually using backend technologies, it does not require much Node.js experience to follow this chapter.
However, please keep in mind that even though we are omitting network and database requests, we hope the core logic of the final implementation could be applied to a real environment without being modified too much. So, when it comes to performance concerns, we still need to assume limited network resources, especially for data passing through the server and client, although the implementation is going to be synchronous instead of asynchronous. This is not supposed to happen in practice, but involving asynchronous operations will introduce much more code, as well as many more situations that need to be taken into consideration. But we will have some useful patterns on asynchronous programming in the coming chapters, and it would definitely help if you try to implement an asynchronous version of the synchronizing logic in this chapter.
A client, if without modifying what's been synchronized, stores a copy of all the data available on the server, and what we need to do is to provide a set of APIs that enable the client to keep its copy of data synchronized.
So, it is really simple at the beginning: comparing the last-modified timestamp. If the timestamp on the client is older than what's on the server, then update the copy of data along with new timestamp.
Creating the code base
Firstly, let's create server.ts
and client.ts
files containing the Server
class and Client
class respectively:
export class Server { // ... } export class Client { // ... }
I prefer to create an index.ts
file as the package entry, which handles what to export internally. In this case, let's export everything:
export * from './server'; export * from './client';
To import the Server
and Client
classes from a test file (assuming src/test/test.ts
), we can use the following codeto s:
import { Server, Client } from '../';
Defining the initial structure of the data to be synchronized
Since we need to compare the timestamps from the client and server, we need to have a timestamp
property on the data structure. I would like to have the data to synchronize as a string, so let's add a DataStore
interface with a timestamp
property to the server.ts
file:
export interface DataStore { timestamp: number; data: string; }
Getting data by comparing timestamps
Currently, the synchronizing strategy is one-way, from the server to the client. So what we need to do is simple: we compare the timestamps; if the server has the newer one, it responds with data and the server-side timestamp; otherwise, it responds with undefined
:
class Server { store: DataStore = { timestamp: 0, data: '' }; getData(clientTimestamp: number): DataStore { if (clientTimestamp < this.store.timestamp) { return this.store; } else { return undefined; } } }
Now we have provided a simple API for the client, and it's time to implement the client:
import { Server, DataStore } from './'; export class Client { store: DataStore = { timestamp: 0, data: undefined }; constructor( public server: Server ) { } }
Tip
Prefixing a constructor
parameter with access modifiers (including public
, private
, and protected
) will create a property with the same name and corresponding accessibility. It will also assign the value automatically when the constructor is called.
Now we need to add a synchronize
method to the Client
class that does the job:
synchronize(): void { let updatedStore = this.server.getData(this.store.timestamp); if (updatedStore) { this.store = updatedStore; } }
That's easily done. However, are you already feeling somewhat awkward with what we've written?
Two-way synchronizing
Usually, when we talk about synchronization, we get updates from the server and push changes to the server as well. Now we are going to do the second part, pushing the changes if the client has newer data.
But first, we need to give the client the ability to update its data by adding an update
method to the Client
class:
update(data: string): void { this.store.data = data; this.store.timestamp = Date.now(); }
And we need the server to have the ability to receive data from the client as well. So we rename the getData
method of the Server
class as synchronize
and make it satisfy the new job:
synchronize(clientDataStore: DataStore): DataStore { if (clientDataStore.timestamp > this.store.timestamp) { this.store = clientDataStore; return undefined; } else if (clientDataStore.timestamp < this.store.timestamp) { return this.store; } else { return undefined; } }
Now we have the basic implementation of our synchronizing service. Later, we'll keep adding new things and make it capable of dealing with a variety of scenarios.
Things that went wrong while implementing the basics
Currently, what we've written is just too simple to be wrong. But there are still some semantic issues.
Passing a data store from the server to the client does not make sense
We used DataStore
as the return type of the synchronize
method on Server
. But what we were actually passing through is not a data store, but information that involves data and its timestamp. The information object just happened to have the same properties as a data store at this point in time.
Also, it will be misleading to people who will later read your code (including yourself in the future). Most of the time, we are trying to eliminate redundancies. But that does not have to mean everything that looks the same. So let's make it two interfaces:
interface DataStore { timestamp: number; data: string; } interface DataSyncingInfo { timestamp: number; data: string; }
I would even prefer to create another instance, instead of directly returning this.store
:
return { timestamp: this.store.timestamp, data: this.store.data };
However, if two pieces of code with different semantic meanings are doing the same thing from the perspective of code itself, you may consider extracting that part as a utility.
Making the relationships clear
Now we have two separated interfaces, DataStore
and DataSyncingInfo
, in server.ts
. Obviously, DataSyncingInfo
should be a shared interface between the server and the client, while DataStore
happens to be the same on both sides, but it's not actually shared.
So what we are going to do is to create a separate shared.d.ts
(it could also be shared.ts
if it contains more than typings
) that exports DataSyncingInfo
and add another DataStore
to client.ts
.
Note
Do not follow this blindly. Sometimes it is designed for the server and the client to have exactly the same stores. If that's the situation, the interface should be shared.
- 微服務設計(第2版)
- 黑客攻防從入門到精通(實戰秘笈版)
- Implementing Modern DevOps
- Python量化投資指南:基礎、數據與實戰
- MATLAB圖像處理超級學習手冊
- Mastering Kotlin
- Unity Virtual Reality Projects
- Java程序設計與實踐教程(第2版)
- 大學計算機基礎實驗指導
- Java系統化項目開發教程
- Babylon.js Essentials
- Extending Unity with Editor Scripting
- OpenCV with Python Blueprints
- Learning Android Application Testing
- 進入IT企業必讀的324個Java面試題