分享一下最近刚做的POS项目。
当然是公司需要,所以自己主动提出降本好招数,成本高我们自己造!
1、背景
公司业务是类似于瑞幸咖啡,所以门店的初期开店成本很高,但是三方的Pos一体机确实挺贵的,2万一套,还每年需要3000的服务费,我们用pad自己实现,加上打印机,扫码枪等外设成本也才3000左右,也就是一个门店可以节约1.7万左右,1000家就是1700万,甚至于迭代成熟了,我们可以卖pos机给三方用,实现盈利,对接成本也低,体验也不错。
2、设计
基于解耦的思路,把打印机对接封装成了一个黑盒的module。上层业务模块依赖于约定好的接口文档,第一版文档比较简单。考虑到海外一些国家的流量问题,设计通信数据结构的原则模仿protobuffer对重复的key进行了压缩,value采用数组对应,优点是数据量越大,压缩越明显,缺点有很明显排查问题不直观。同时,对于不同的标志位,采用位操作表示,Java中Int有32个2进制位可以表示32种状态。
{
"data": [
{
//订单单号
orderId:String,
//格式参考protbuf 目的是减少http包大小,type和data,config的长度要一致
//小票数据
//数据类型 1单字符串
type:[1,2,3,4,5,6......]
//type对应的数据
data:["字符串","分割符字符串-","","".......]
//type对应的配置 采用位运算
//config默认值传0表示无配置
config:[1,1,1,1,1,1......]
//杯贴数据
//数据类型 1单字符串
cupType:[1,2,3,4,5,6......]
//type对应的数据
cupData:["字符串","分割符字符串-","","".......]
//type对应的配置 采用位运算
//config默认值传0表示无配置
cupConfig:[1,1,1,1,1,1......]
}
]
"code": 0,
"msg": "success",
"success": true
}
至于我的协议内容怎么制定的就不展示了,不是重点。
然后是sdk的设计,首先考虑打印机连接的灵活性,需要动态配置的一些方向,采用抽象工厂对打印机对象进行封装,
1.1 抽象打印机
abstract class BasePrinter(var connector: BaseConnector,var device: Any? = null)
这里打印机的连接方式可能分为USB,蓝牙,以太网,共享热点等方式。
目前主要考虑USB,蓝牙,以太网三种支持扩展。海外网络基础建设比较复杂,不像国内原材料丰富,基建完善。所以实际场景可能是以太网为主,蓝牙辅助的场景居多。
因为以太网连接更加稳定,而蓝牙主要cover的场景是断网兜底备用。
abstract class BasePrinter(var connector: BaseConnector,var device: Any? = null)
/**USB连接*/
abstract class USBConnector : BaseConnector()
/**wifi连接 */
abstract class WIFIConnector : BaseConnector()
/**蓝牙连接*/
abstract class BLUEToothConnector : BaseConnector()
2.1 打印机生产厂商
向下继续扩展,根据厂家的不同定义不同的工厂类用于创建连接器以及打印机实例。
这里因为只对接了2家打印机,所以对2家打印厂家的不同功能进行实现。
这里以其中一家举例
为什么要把连接器设计的这么灵活?
因为不同的连接方式的连接过程区别很大,各家三方打印机sdk也有自己的设计风格,各家有各家的sdk连接代码也不一样,然后连接方式不一样的话实现流程区别就更大,比如蓝牙涉及到一系列的权限检查,以及蓝牙开关的检查,以及设备绑定动作,以太网则直需要只需要检查ip就可以了。
到这里其实我们已经隔离了各家厂商的打印机初始化以及连接方式的差异化。做到了随意修改,插拔。
1.3 打印行为
打印机有不同的通讯一些这里主要是基于主流的小票采用 ESC协议 和杯贴采用的 TSPL协议 进行的实现。当然目前的设计后续需要扩展实现协议方式也比较简单。
上面依次是反白,TSPL特有的初始化打印区域,结束TSPL打印,打开钱箱,打印二维码,打印图片,打印空行等。
这里主要对主流的操作方式进行了抽象,虽然是第一版,但是也cover了大部分打印机场景,后续需要扩展基本是基于这里扩展了。这也是我们定义服务端打印行为的基础。
这样设计的好处是,如果后续需要调整打印机的排版,客户端不需要发版,非常灵活
实现
上面主要是我们对厂商的变化进行了抽象,方便后续扩展,上层我们我们主要的是打印机连接的实现,异步查找,连接的过程采用订阅者模式去监听查找和连接结果。目前主要实现了以太网和蓝牙2种连接场景。
连接配置这个类采用建造者模式编写,对打印机名字(INameGenerator)以及IP可进行动态配置。
也可使用默认配置方式
public class ConnectConfig {
private INameGenerator nameGenerate;
private List<PrinterConfig> pendingToConnects;
public INameGenerator getNameGenerate() {
return nameGenerate;
}
public List<PrinterConfig> getPendingToConnects() {
return pendingToConnects;
}
public static final class ConnectConfigBuilder {
private INameGenerator nameGenerate;
private List<PrinterConfig> pendingToConnects;
private ConnectConfigBuilder() {}
public static ConnectConfigBuilder builder() {
return new ConnectConfigBuilder();
}
public ConnectConfigBuilder defaultConfig(INameGenerator nameGenerate){
withNameGenerate(nameGenerate);
withPrinter(ConnectConfig.PrinterConfigBuilder.builder()
.withExtra("WiFi,10.1.2.199,9100")
.withConnectWay(ConnectWay.WIFI)
.withType(PrinterType.Tag)
.build());
withPrinter(ConnectConfig.PrinterConfigBuilder.builder()
.withConnectWay(ConnectWay.BLUETooth)
.withType(PrinterType.Ticket)
.build());
StringBuilder sb = new StringBuilder(20);
sb.append("配置打印机连接方式:\n");
for (PrinterConfig pendingToConnect : pendingToConnects) {
sb.append(">>Type:").append(pendingToConnect.type).append(">>way:").append(pendingToConnect.connectWay)
.append(">>extra:").append(pendingToConnect.extra)
.append("\n");
}
PrintLog.getInstance().log(sb.toString());
return this;
}
public ConnectConfigBuilder withNameGenerate(INameGenerator nameGenerate) {
this.nameGenerate = nameGenerate;
return this;
}
public ConnectConfigBuilder withPrinter(PrinterConfig printerConfig) {
if(pendingToConnects == null){
pendingToConnects = new ArrayList<>();
}
pendingToConnects.add(printerConfig);
return this;
}
public ConnectConfig build() {
ConnectConfig connectConfig = new ConnectConfig();
connectConfig.pendingToConnects = this.pendingToConnects;
connectConfig.nameGenerate = this.nameGenerate;
return connectConfig;
}
}
public static class PrinterConfig{
private PrinterType type;
private ConnectWay connectWay;
private String extra;
public PrinterType getType() {
return type;
}
public ConnectWay getConnectWay() {
return connectWay;
}
public String getExtra() {
return extra;
}
}
public static final class PrinterConfigBuilder {
private PrinterType type;
private ConnectWay connectWay;
private String extra;
public static PrinterConfigBuilder builder() {
return new PrinterConfigBuilder();
}
public PrinterConfigBuilder withConnectWay(ConnectWay way) {
this.connectWay = way;
return this;
}
public PrinterConfigBuilder withType(PrinterType type) {
this.type = type;
return this;
}
public PrinterConfigBuilder withExtra(String extra){
this.extra = extra;
return this;
}
public PrinterConfig build() {
PrinterConfig connectConfig = new PrinterConfig();
connectConfig.connectWay = this.connectWay;
connectConfig.type = this.type;
connectConfig.extra = this.extra;
return connectConfig;
}
}
}
外部调用打印机初始化这个对象就可以了。主要方式是
PrintManager
1、release()释放资源
2、startConnect()根据ConnectConfig连接打印机
3、command()和commandAsy()一个是同步调用,一个是采用异步调用的方式。
object Command{
//打开钱箱
//> 0 表示打开钱箱
const val Open_drawer = "Open_drawer"
const val origin_ticket_data = "ticket_data"
const val origin_tag_data = "tag_data"
const val Fetch_print_info = "fetch_print_info"
const val test = "test"
}
其他的就是一些辅助工具了,利用Kotlin的扩展函数机制,可以优雅的实现程序入口链式调用。不过这个链式调用只适用于纯kotlin项目,Java还是需要通过对象去getInstance()。
object Print
/**保存失败日志 主要保存打印失败的数据,设置超过N天自动清理数据的逻辑等*/
val Print.record : PrintRecord
get() = PrintRecord.getInstance()
/**保存日志 主要用来打印操作日志 设置超过N天的数据自动清理数据等*/
val Print.log : PrintLog
get() = PrintLog.getInstance()
下层封装基本就是这样了,上层在加上一个任务队列
//打印机全部订单并流转所有订单状态为已接单
const val PRINT_ALL_AND_PROCESS
//打印机全部订单
const val PRINT_ALL
//打印日结小票
const val PRINT_DAILY
//打印订单并流转订单状态为待接单
const val PRINT_ORDER_AND_PROCESS
//打印订单
const val PRINT_ORDER
//本地类型 打印机失败重试任务
const val PRINT_FAILURE
//添加打印任务
fun addPrint(type:String? = null,
orderId:String?= null,
cashierId:String? = null,
selectedDate:String? = null,
byUser :Boolean = false) : Boolean
在初始化打印机之后,需要进行打印任务的通过addPrint进行任务添加,打印机队列是一个异步阻塞队列,在异步线程中等待新的任务。
添加打印任务可以通过支付成功的时机,以及收到推送,或者轮询等方式。
内部还有打印错误,接口错误等重试机制。
总结
上面基本就是自研POS SDK的设计了。
调用SDK方只需要配置好ConnnectConfig调用初始化对象PrintManager的startConnect()就可以实现打印机连接。之后通过PrintQueue异步等待实时触发打印任务。服务端通过使用我提供的数据文档,可实现对打印数据随意组装,从而做到打印机SDK与业务解耦。