Browse Source

优化通道同步添加对SN的判断,精简代码

pull/437/head
648540858 2 years ago
parent
commit
0dc1807f62
  1. 10
      src/main/java/com/genersoft/iot/vmp/gb28181/bean/CatalogData.java
  2. 3
      src/main/java/com/genersoft/iot/vmp/gb28181/event/online/OnlineEventListener.java
  3. 58
      src/main/java/com/genersoft/iot/vmp/gb28181/session/CatalogDataCatch.java
  4. 2
      src/main/java/com/genersoft/iot/vmp/gb28181/transmit/cmd/ISIPCommander.java
  5. 4
      src/main/java/com/genersoft/iot/vmp/gb28181/transmit/cmd/impl/SIPCommander.java
  6. 49
      src/main/java/com/genersoft/iot/vmp/gb28181/transmit/event/request/impl/message/response/cmd/CatalogResponseMessageHandler.java
  7. 13
      src/main/java/com/genersoft/iot/vmp/service/IDeviceService.java
  8. 16
      src/main/java/com/genersoft/iot/vmp/service/impl/DeviceServiceImpl.java
  9. 13
      src/main/java/com/genersoft/iot/vmp/storager/impl/VideoManagerStorageImpl.java
  10. 8
      src/main/java/com/genersoft/iot/vmp/vmanager/gb28181/device/DeviceQuery.java
  11. 23
      web_src/src/components/dialog/SyncChannelProgress.vue

10
src/main/java/com/genersoft/iot/vmp/gb28181/bean/CatalogData.java

@ -4,6 +4,7 @@ import java.util.Date;
import java.util.List; import java.util.List;
public class CatalogData { public class CatalogData {
private int sn; // 命令序列号
private int total; private int total;
private List<DeviceChannel> channelList; private List<DeviceChannel> channelList;
private Date lastTime; private Date lastTime;
@ -15,6 +16,15 @@ public class CatalogData {
} }
private CatalogDataStatus status; private CatalogDataStatus status;
public int getSn() {
return sn;
}
public void setSn(int sn) {
this.sn = sn;
}
public int getTotal() { public int getTotal() {
return total; return total;
} }

3
src/main/java/com/genersoft/iot/vmp/gb28181/event/online/OnlineEventListener.java

@ -54,6 +54,7 @@ public class OnlineEventListener implements ApplicationListener<OnlineEvent> {
@Autowired @Autowired
private SIPCommander cmder; private SIPCommander cmder;
private SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@Override @Override
@ -76,7 +77,7 @@ public class OnlineEventListener implements ApplicationListener<OnlineEvent> {
if (deviceInStore == null) { //第一次上线 if (deviceInStore == null) { //第一次上线
logger.info("[{}] 首次注册,查询设备信息以及通道信息", device.getDeviceId()); logger.info("[{}] 首次注册,查询设备信息以及通道信息", device.getDeviceId());
cmder.deviceInfoQuery(device); cmder.deviceInfoQuery(device);
cmder.catalogQuery(device, null); deviceService.sync(device);
} }
break; break;
// 设备主动发送心跳触发的在线事件 // 设备主动发送心跳触发的在线事件

58
src/main/java/com/genersoft/iot/vmp/gb28181/session/CatalogDataCatch.java

@ -26,28 +26,35 @@ public class CatalogDataCatch {
@Autowired @Autowired
private IVideoManagerStorage storager; private IVideoManagerStorage storager;
public void addReady(String key) { public void addReady(Device device, int sn ) {
CatalogData catalogData = data.get(key); CatalogData catalogData = data.get(device.getDeviceId());
if (catalogData == null || catalogData.getStatus().equals(CatalogData.CatalogDataStatus.end)) { if (catalogData == null || catalogData.getStatus().equals(CatalogData.CatalogDataStatus.end)) {
catalogData = new CatalogData(); catalogData = new CatalogData();
catalogData.setChannelList(new ArrayList<>()); catalogData.setChannelList(new ArrayList<>());
catalogData.setDevice(device);
catalogData.setSn(sn);
catalogData.setStatus(CatalogData.CatalogDataStatus.ready); catalogData.setStatus(CatalogData.CatalogDataStatus.ready);
catalogData.setLastTime(new Date(System.currentTimeMillis())); catalogData.setLastTime(new Date(System.currentTimeMillis()));
data.put(key, catalogData); data.put(device.getDeviceId(), catalogData);
} }
} }
public void put(String key, int total, Device device, List<DeviceChannel> deviceChannelList) { public void put(String deviceId, int sn, int total, Device device, List<DeviceChannel> deviceChannelList) {
CatalogData catalogData = data.get(key); CatalogData catalogData = data.get(deviceId);
if (catalogData == null) { if (catalogData == null) {
catalogData = new CatalogData(); catalogData = new CatalogData();
catalogData.setSn(sn);
catalogData.setTotal(total); catalogData.setTotal(total);
catalogData.setDevice(device); catalogData.setDevice(device);
catalogData.setChannelList(new ArrayList<>()); catalogData.setChannelList(new ArrayList<>());
catalogData.setStatus(CatalogData.CatalogDataStatus.runIng); catalogData.setStatus(CatalogData.CatalogDataStatus.runIng);
catalogData.setLastTime(new Date(System.currentTimeMillis())); catalogData.setLastTime(new Date(System.currentTimeMillis()));
data.put(key, catalogData); data.put(deviceId, catalogData);
}else { }else {
// 同一个设备的通道同步请求只考虑一个,其他的直接忽略
if (catalogData.getSn() != sn) {
return;
}
catalogData.setTotal(total); catalogData.setTotal(total);
catalogData.setDevice(device); catalogData.setDevice(device);
catalogData.setStatus(CatalogData.CatalogDataStatus.runIng); catalogData.setStatus(CatalogData.CatalogDataStatus.runIng);
@ -56,20 +63,20 @@ public class CatalogDataCatch {
} }
} }
public List<DeviceChannel> get(String key) { public List<DeviceChannel> get(String deviceId) {
CatalogData catalogData = data.get(key); CatalogData catalogData = data.get(deviceId);
if (catalogData == null) return null; if (catalogData == null) return null;
return catalogData.getChannelList(); return catalogData.getChannelList();
} }
public int getTotal(String key) { public int getTotal(String deviceId) {
CatalogData catalogData = data.get(key); CatalogData catalogData = data.get(deviceId);
if (catalogData == null) return 0; if (catalogData == null) return 0;
return catalogData.getTotal(); return catalogData.getTotal();
} }
public SyncStatus getSyncStatus(String key) { public SyncStatus getSyncStatus(String deviceId) {
CatalogData catalogData = data.get(key); CatalogData catalogData = data.get(deviceId);
if (catalogData == null) return null; if (catalogData == null) return null;
SyncStatus syncStatus = new SyncStatus(); SyncStatus syncStatus = new SyncStatus();
syncStatus.setCurrent(catalogData.getChannelList().size()); syncStatus.setCurrent(catalogData.getChannelList().size());
@ -78,10 +85,6 @@ public class CatalogDataCatch {
return syncStatus; return syncStatus;
} }
public void del(String key) {
data.remove(key);
}
@Scheduled(fixedRate = 5 * 1000) //每5秒执行一次, 发现数据5秒未更新则移除数据并认为数据接收超时 @Scheduled(fixedRate = 5 * 1000) //每5秒执行一次, 发现数据5秒未更新则移除数据并认为数据接收超时
private void timerTask(){ private void timerTask(){
Set<String> keys = data.keySet(); Set<String> keys = data.keySet();
@ -92,23 +95,30 @@ public class CatalogDataCatch {
Calendar calendarBefore30S = Calendar.getInstance(); Calendar calendarBefore30S = Calendar.getInstance();
calendarBefore30S.setTime(new Date()); calendarBefore30S.setTime(new Date());
calendarBefore30S.set(Calendar.SECOND, calendarBefore30S.get(Calendar.SECOND) - 30); calendarBefore30S.set(Calendar.SECOND, calendarBefore30S.get(Calendar.SECOND) - 30);
for (String key : keys) { for (String deviceId : keys) {
CatalogData catalogData = data.get(key); CatalogData catalogData = data.get(deviceId);
if (catalogData.getLastTime().before(calendarBefore5S.getTime())) { // 超过五秒收不到消息任务超时, 只更新这一部分数据 if ( catalogData.getLastTime().before(calendarBefore5S.getTime())) { // 超过五秒收不到消息任务超时, 只更新这一部分数据
if (catalogData.getStatus().equals(CatalogData.CatalogDataStatus.runIng)) {
storager.resetChannels(catalogData.getDevice().getDeviceId(), catalogData.getChannelList()); storager.resetChannels(catalogData.getDevice().getDeviceId(), catalogData.getChannelList());
if (catalogData.getTotal() != catalogData.getChannelList().size()) {
String errorMsg = "更新成功,共" + catalogData.getTotal() + "条,已更新" + catalogData.getChannelList().size() + "条"; String errorMsg = "更新成功,共" + catalogData.getTotal() + "条,已更新" + catalogData.getChannelList().size() + "条";
catalogData.setStatus(CatalogData.CatalogDataStatus.end);
catalogData.setErrorMsg(errorMsg); catalogData.setErrorMsg(errorMsg);
} }
if (catalogData.getLastTime().before(calendarBefore30S.getTime())) { // 超过三十秒,如果标记为end则删除 }else if (catalogData.getStatus().equals(CatalogData.CatalogDataStatus.ready)) {
data.remove(key); String errorMsg = "同步失败,等待回复超时";
catalogData.setErrorMsg(errorMsg);
}
catalogData.setStatus(CatalogData.CatalogDataStatus.end);
}
if (catalogData.getStatus().equals(CatalogData.CatalogDataStatus.end) && catalogData.getLastTime().before(calendarBefore30S.getTime())) { // 超过三十秒,如果标记为end则删除
data.remove(deviceId);
} }
} }
} }
public void setChannelSyncEnd(String key, String errorMsg) { public void setChannelSyncEnd(String deviceId, String errorMsg) {
CatalogData catalogData = data.get(key); CatalogData catalogData = data.get(deviceId);
if (catalogData == null)return; if (catalogData == null)return;
catalogData.setStatus(CatalogData.CatalogDataStatus.end); catalogData.setStatus(CatalogData.CatalogDataStatus.end);
catalogData.setErrorMsg(errorMsg); catalogData.setErrorMsg(errorMsg);

2
src/main/java/com/genersoft/iot/vmp/gb28181/transmit/cmd/ISIPCommander.java

@ -250,7 +250,7 @@ public interface ISIPCommander {
* *
* @param device 视频设备 * @param device 视频设备
*/ */
boolean catalogQuery(Device device, SipSubscribe.Event errorEvent); boolean catalogQuery(Device device, int sn, SipSubscribe.Event errorEvent);
/** /**
* 查询录像信息 * 查询录像信息

4
src/main/java/com/genersoft/iot/vmp/gb28181/transmit/cmd/impl/SIPCommander.java

@ -1208,14 +1208,14 @@ public class SIPCommander implements ISIPCommander {
* @param device 视频设备 * @param device 视频设备
*/ */
@Override @Override
public boolean catalogQuery(Device device, SipSubscribe.Event errorEvent) { public boolean catalogQuery(Device device, int sn, SipSubscribe.Event errorEvent) {
try { try {
StringBuffer catalogXml = new StringBuffer(200); StringBuffer catalogXml = new StringBuffer(200);
String charset = device.getCharset(); String charset = device.getCharset();
catalogXml.append("<?xml version=\"1.0\" encoding=\"" + charset + "\"?>\r\n"); catalogXml.append("<?xml version=\"1.0\" encoding=\"" + charset + "\"?>\r\n");
catalogXml.append("<Query>\r\n"); catalogXml.append("<Query>\r\n");
catalogXml.append("<CmdType>Catalog</CmdType>\r\n"); catalogXml.append("<CmdType>Catalog</CmdType>\r\n");
catalogXml.append("<SN>" + (int)((Math.random()*9+1)*100000) + "</SN>\r\n"); catalogXml.append("<SN>" + sn + "</SN>\r\n");
catalogXml.append("<DeviceID>" + device.getDeviceId() + "</DeviceID>\r\n"); catalogXml.append("<DeviceID>" + device.getDeviceId() + "</DeviceID>\r\n");
catalogXml.append("</Query>\r\n"); catalogXml.append("</Query>\r\n");

49
src/main/java/com/genersoft/iot/vmp/gb28181/transmit/event/request/impl/message/response/cmd/CatalogResponseMessageHandler.java

@ -86,23 +86,17 @@ public class CatalogResponseMessageHandler extends SIPRequestProcessorParent imp
rootElement = getRootElement(evt, device.getCharset()); rootElement = getRootElement(evt, device.getCharset());
Element deviceListElement = rootElement.element("DeviceList"); Element deviceListElement = rootElement.element("DeviceList");
Element sumNumElement = rootElement.element("SumNum"); Element sumNumElement = rootElement.element("SumNum");
if (sumNumElement == null || deviceListElement == null) { Element snElement = rootElement.element("SN");
if (snElement == null || sumNumElement == null || deviceListElement == null) {
responseAck(evt, Response.BAD_REQUEST, "xml error"); responseAck(evt, Response.BAD_REQUEST, "xml error");
return; return;
} }
int sumNum = Integer.parseInt(sumNumElement.getText()); int sumNum = Integer.parseInt(sumNumElement.getText());
if (sumNum == 0) { if (sumNum == 0) {
// 数据已经完整接收 // 数据已经完整接收
storager.cleanChannelsForDevice(device.getDeviceId()); storager.cleanChannelsForDevice(device.getDeviceId());
RequestMessage msg = new RequestMessage(); catalogDataCatch.setChannelSyncEnd(device.getDeviceId(), null);
msg.setKey(key);
WVPResult<Object> result = new WVPResult<>();
result.setCode(0);
result.setData(device);
msg.setData(result);
result.setMsg("更新成功,共0条");
deferredResultHolder.invokeAllResult(msg);
catalogDataCatch.del(key);
}else { }else {
Iterator<Element> deviceListIterator = deviceListElement.elementIterator(); Iterator<Element> deviceListIterator = deviceListElement.elementIterator();
if (deviceListIterator != null) { if (deviceListIterator != null) {
@ -123,24 +117,18 @@ public class CatalogResponseMessageHandler extends SIPRequestProcessorParent imp
channelList.add(deviceChannel); channelList.add(deviceChannel);
} }
int sn = Integer.parseInt(snElement.getText());
logger.info("收到来自设备【{}】的通道: {}个,{}/{}", device.getDeviceId(), channelList.size(), catalogDataCatch.get(key) == null ? 0 :catalogDataCatch.get(key).size(), sumNum); logger.info("收到来自设备【{}】的通道: {}个,{}/{}", device.getDeviceId(), channelList.size(), catalogDataCatch.get(key) == null ? 0 :catalogDataCatch.get(key).size(), sumNum);
catalogDataCatch.put(key, sumNum, device, channelList); catalogDataCatch.put(device.getDeviceId(), sn, sumNum, device, channelList);
if (catalogDataCatch.get(key).size() == sumNum) { if (catalogDataCatch.get(device.getDeviceId()).size() == sumNum) {
// 数据已经完整接收 // 数据已经完整接收
boolean resetChannelsResult = storager.resetChannels(device.getDeviceId(), catalogDataCatch.get(key)); boolean resetChannelsResult = storager.resetChannels(device.getDeviceId(), catalogDataCatch.get(device.getDeviceId()));
RequestMessage msg = new RequestMessage(); if (!resetChannelsResult) {
msg.setKey(key); String errorMsg = "接收成功,写入失败,共" + sumNum + "条,已接收" + catalogDataCatch.get(device.getDeviceId()).size() + "条";
WVPResult<Object> result = new WVPResult<>(); catalogDataCatch.setChannelSyncEnd(device.getDeviceId(), errorMsg);
result.setCode(0);
result.setData(device);
if (resetChannelsResult || sumNum ==0) {
result.setMsg("更新成功,共" + sumNum + "条,已更新" + catalogDataCatch.get(key).size() + "条");
}else { }else {
result.setMsg("接收成功,写入失败,共" + sumNum + "条,已接收" + catalogDataCatch.get(key).size() + "条"); catalogDataCatch.setChannelSyncEnd(device.getDeviceId(), null);
} }
msg.setData(result);
deferredResultHolder.invokeAllResult(msg);
catalogDataCatch.del(key);
} }
} }
// 回复200 OK // 回复200 OK
@ -228,21 +216,18 @@ public class CatalogResponseMessageHandler extends SIPRequestProcessorParent imp
} }
public SyncStatus getChannelSyncProgress(String deviceId) { public SyncStatus getChannelSyncProgress(String deviceId) {
String key = DeferredResultHolder.CALLBACK_CMD_CATALOG + deviceId; if (catalogDataCatch.get(deviceId) == null) {
if (catalogDataCatch.get(key) == null) {
return null; return null;
}else { }else {
return catalogDataCatch.getSyncStatus(key); return catalogDataCatch.getSyncStatus(deviceId);
} }
} }
public void setChannelSyncReady(String deviceId) { public void setChannelSyncReady(Device device, int sn) {
String key = DeferredResultHolder.CALLBACK_CMD_CATALOG + deviceId; catalogDataCatch.addReady(device, sn);
catalogDataCatch.addReady(key);
} }
public void setChannelSyncEnd(String deviceId, String errorMsg) { public void setChannelSyncEnd(String deviceId, String errorMsg) {
String key = DeferredResultHolder.CALLBACK_CMD_CATALOG + deviceId; catalogDataCatch.setChannelSyncEnd(deviceId, errorMsg);
catalogDataCatch.setChannelSyncEnd(key, errorMsg);
} }
} }

13
src/main/java/com/genersoft/iot/vmp/service/IDeviceService.java

@ -44,15 +44,8 @@ public interface IDeviceService {
SyncStatus getChannelSyncStatus(String deviceId); SyncStatus getChannelSyncStatus(String deviceId);
/** /**
* 设置通道同步状态 * 通道同步
* @param deviceId 设备ID * @param device
*/
void setChannelSyncReady(String deviceId);
/**
* 设置同步结束
* @param deviceId 设备ID
* @param errorMsg 错误信息
*/ */
void setChannelSyncEnd(String deviceId, String errorMsg); void sync(Device device);
} }

16
src/main/java/com/genersoft/iot/vmp/service/impl/DeviceServiceImpl.java

@ -100,12 +100,16 @@ public class DeviceServiceImpl implements IDeviceService {
} }
@Override @Override
public void setChannelSyncReady(String deviceId) { public void sync(Device device) {
catalogResponseMessageHandler.setChannelSyncReady(deviceId); if (catalogResponseMessageHandler.getChannelSyncProgress(device.getDeviceId()) != null) {
logger.info("开启同步时发现同步已经存在");
return;
} }
int sn = (int)((Math.random()*9+1)*100000);
@Override catalogResponseMessageHandler.setChannelSyncReady(device, sn);
public void setChannelSyncEnd(String deviceId, String errorMsg) { sipCommander.catalogQuery(device, sn, event -> {
catalogResponseMessageHandler.setChannelSyncEnd(deviceId, errorMsg); String errorMsg = String.format("同步通道失败,错误码: %s, %s", event.statusCode, event.msg);
catalogResponseMessageHandler.setChannelSyncEnd(device.getDeviceId(), errorMsg);
});
} }
} }

13
src/main/java/com/genersoft/iot/vmp/storager/impl/VideoManagerStorageImpl.java

@ -238,12 +238,15 @@ public class VideoManagerStorageImpl implements IVideoManagerStorage {
@Override @Override
public boolean resetChannels(String deviceId, List<DeviceChannel> deviceChannelList) { public boolean resetChannels(String deviceId, List<DeviceChannel> deviceChannelList) {
if (deviceChannelList == null) {
return false;
}
TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(transactionDefinition); TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(transactionDefinition);
// 数据去重 // 数据去重
List<DeviceChannel> channels = new ArrayList<>(); List<DeviceChannel> channels = new ArrayList<>();
StringBuilder stringBuilder = new StringBuilder(); StringBuilder stringBuilder = new StringBuilder();
Map<String, Integer> subContMap = new HashMap<>(); Map<String, Integer> subContMap = new HashMap<>();
if (deviceChannelList.size() > 1) { if (deviceChannelList != null && deviceChannelList.size() > 1) {
// 数据去重 // 数据去重
Set<String> gbIdSet = new HashSet<>(); Set<String> gbIdSet = new HashSet<>();
for (DeviceChannel deviceChannel : deviceChannelList) { for (DeviceChannel deviceChannel : deviceChannelList) {
@ -300,6 +303,7 @@ public class VideoManagerStorageImpl implements IVideoManagerStorage {
dataSourceTransactionManager.commit(transactionStatus); //手动提交 dataSourceTransactionManager.commit(transactionStatus); //手动提交
return true; return true;
}catch (Exception e) { }catch (Exception e) {
e.printStackTrace();
dataSourceTransactionManager.rollback(transactionStatus); dataSourceTransactionManager.rollback(transactionStatus);
return false; return false;
} }
@ -415,10 +419,9 @@ public class VideoManagerStorageImpl implements IVideoManagerStorage {
TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(transactionDefinition); TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(transactionDefinition);
boolean result = false; boolean result = false;
try { try {
if (platformChannelMapper.delChannelForDeviceId(deviceId) <0 // 删除与国标平台的关联 platformChannelMapper.delChannelForDeviceId(deviceId);
|| deviceChannelMapper.cleanChannelsByDeviceId(deviceId) < 0 // 删除他的通道 deviceChannelMapper.cleanChannelsByDeviceId(deviceId);
|| deviceMapper.del(deviceId) < 0 // 移除设备信息 if ( deviceMapper.del(deviceId) < 0 ) {
) {
//事务回滚 //事务回滚
dataSourceTransactionManager.rollback(transactionStatus); dataSourceTransactionManager.rollback(transactionStatus);
} }

8
src/main/java/com/genersoft/iot/vmp/vmanager/gb28181/device/DeviceQuery.java

@ -172,12 +172,8 @@ public class DeviceQuery {
wvpResult.setData(syncStatus); wvpResult.setData(syncStatus);
return wvpResult; return wvpResult;
} }
SyncStatus syncStatusReady = new SyncStatus(); deviceService.sync(device);
deviceService.setChannelSyncReady(deviceId);
cmder.catalogQuery(device, event -> {
String errorMsg = String.format("同步通道失败,错误码: %s, %s", event.statusCode, event.msg);
deviceService.setChannelSyncEnd(deviceId, errorMsg);
});
WVPResult<SyncStatus> wvpResult = new WVPResult<>(); WVPResult<SyncStatus> wvpResult = new WVPResult<>();
wvpResult.setCode(0); wvpResult.setCode(0);
wvpResult.setMsg("开始同步"); wvpResult.setMsg("开始同步");

23
web_src/src/components/dialog/SyncChannelProgress.vue

@ -61,14 +61,23 @@ export default {
if (!this.syncFlag) { if (!this.syncFlag) {
this.syncFlag = true; this.syncFlag = true;
} }
if (res.data.data == null) {
if (res.data.data != null) {
if (res.data.data.total == 0) {
if (res.data.data.errorMsg !== null ){
this.msg = res.data.data.errorMsg;
this.syncStatus = "exception"
}else {
this.msg = `等待同步中`;
this.timmer = setTimeout(this.getProgress, 300)
}
}else {
if (res.data.data.total == res.data.data.current) {
this.syncStatus = "success" this.syncStatus = "success"
this.percentage = 100; this.percentage = 100;
this.msg = '同步成功'; this.msg = '同步成功';
}else if (res.data.data.total == 0){ }else {
this.msg = `等待同步中`; if (res.data.data.errorMsg !== null ){
this.timmer = setTimeout(this.getProgress, 300)
}else if (res.data.data.errorMsg !== null ){
this.msg = res.data.data.errorMsg; this.msg = res.data.data.errorMsg;
this.syncStatus = "exception" this.syncStatus = "exception"
}else { }else {
@ -78,6 +87,10 @@ export default {
this.msg = `同步中...[${res.data.data.current}/${res.data.data.total}]`; this.msg = `同步中...[${res.data.data.current}/${res.data.data.total}]`;
this.timmer = setTimeout(this.getProgress, 300) this.timmer = setTimeout(this.getProgress, 300)
} }
}
}
}
}else { }else {
if (this.syncFlag) { if (this.syncFlag) {
this.syncStatus = "success" this.syncStatus = "success"

Loading…
Cancel
Save