ArcGIS 服务对象扩展(SOE)新手自学笔记(5):注册与调试
上一篇到现在过去好几天了,本来打算用ESRI官方自带的例子呢,后来觉得还是应该实践一下。这几天都在忙着写这个例子,其中也出现了好多问题,从一开始思考解决问题的方法,到找代码,再调试成功,费了不少事,好在问题都已经解决了。不喜欢说废话,还是忍不住说了这么多,下面正式进入主题。
首先说说我们要解决的问题。gp服务广泛使用的一个原因是他可以做栅格数据的分析,那好我们就用SOE来解决一个插值问题。在gp服务中,我们可以通过设置输入输出类型来保客户端成功加载分析结果,大部分分析结果是以图片形式传到客户端。在SOE中理论上是可以设置输出图片形式,但帮助中只是简单地说了几句,很不详细,所以我用的方法是将栅格分类后转换成矢量数据,再将矢量数据序列化成json格式,传给客户端。流图如下:
获取要素类---》插值---》重分类---》栅格转面---》序列化成json---》客户端
1.编写代码
新建一个工程,命名为Saturation
首先加入以下引用:
代码:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 using System.Collections.Specialized; 7 8 using System.Runtime.InteropServices; 9 using System.EnterpriseServices; 10 11 using ESRI.ArcGIS.esriSystem; 12 using ESRI.ArcGIS.Server; 13 using ESRI.ArcGIS.Geometry; 14 using ESRI.ArcGIS.Geodatabase; 15 using ESRI.ArcGIS.Carto; 16 using ESRI.ArcGIS.SOESupport; 17 using ESRI.ArcGIS.GeoAnalyst; 18 using ESRI.ArcGIS.DataSourcesRaster; 19 20 21 //TODO: sign the project (project properties > signing tab > sign the assembly) 22 // this is strongly suggested if the dll will be registered using regasm.exe <your>.dll /codebase 23 24 25 namespace Saturation 26 { 27 [ComVisible(true)] 28 [Guid("f8cb9f88-1dc9-4f34-a0a9-fa931afb8730")] 29 [ClassInterface(ClassInterfaceType.None)] 30 public class Saturation : ServicedComponent, IServerObjectExtension, IObjectConstruct, IRESTRequestHandler 31 { 32 private string soe_name; 33 34 private IPropertySet configProps; 35 private IServerObjectHelper serverObjectHelper; 36 private ServerLogger logger; 37 private IRESTRequestHandler reqHandler; 38 39 //Member variables 40 private string m_layerNameToAnalyst = null; 41 private IFeatureClass m_fcToAnalyst = null;//进行插值的要素类 42 43 public Saturation() 44 { 45 soe_name = this.GetType().Name; 46 logger = new ServerLogger(); 47 reqHandler = new SoeRestImpl(soe_name, CreateRestSchema()) as IRESTRequestHandler; 48 } 49 50 #region IServerObjectExtension Members 51 52 public void Init(IServerObjectHelper pSOH) 53 { 54 serverObjectHelper = pSOH; 55 } 56 57 public void Shutdown() 58 { 59 logger.LogMessage(ServerLogger.msgType.infoStandard, "Shutdown", 8000, "Custom error message: Shutting down the SOE"); 60 soe_name = null; 61 m_fcToAnalyst = null; 62 serverObjectHelper = null; 63 m_layerNameToAnalyst = null; 64 logger = null; 65 } 66 67 #endregion 68 69 #region IObjectConstruct Members 70 71 public void Construct(IPropertySet props) 72 { 73 logger.LogMessage(ServerLogger.msgType.error, "Construct", 8000, "SOE Construct:start"); 74 configProps = props; 75 m_layerNameToAnalyst = "testPoint"; 76 //获得所要分析的FeatureClass 77 try 78 { 79 IMapServer3 mapServer = serverObjectHelper.ServerObject as IMapServer3; 80 81 string mapName = mapServer.DefaultMapName; 82 IMapLayerInfos layerInfos = mapServer.GetServerInfo(mapName).MapLayerInfos; 83 IMapLayerInfo layerInfo = null; 84 85 //获得所要分析图层的index 86 int c = layerInfos.Count; 87 int layerIndex = 0; 88 for (int i = 0; i < c; i++) 89 { 90 layerInfo = layerInfos.get_Element(i); 91 if (layerInfo.Name == m_layerNameToAnalyst) 92 { 93 layerIndex = i; 94 break; 95 } 96 } 97 98 //使用IMapServerDataAccess获取数据 99 IMapServerDataAccess dataAccess = mapServer as IMapServerDataAccess; 100 m_fcToAnalyst = dataAccess.GetDataSource(mapName, layerIndex) as IFeatureClass; 101 102 if (m_fcToAnalyst == null) 103 { 104 logger.LogMessage(ServerLogger.msgType.error, "Construct", 8000, "SOE custom error: Layer name not found."); 105 return; 106 } 107 } 108 catch (Exception es) 109 { 110 logger.LogMessage(ServerLogger.msgType.error, "Construct", 8000, "SOE custom error: Could not get the feature layer."); 111 } 112 } 113 114 #endregion 115 116 #region IRESTRequestHandler Members 117 118 public string GetSchema() 119 { 120 return reqHandler.GetSchema(); 121 } 122 123 public byte[] HandleRESTRequest(string Capabilities, string resourceName, string operationName, string operationInput, string outputFormat, string requestProperties, out string responseProperties) 124 { 125 return reqHandler.HandleRESTRequest(Capabilities, resourceName, operationName, operationInput, outputFormat, requestProperties, out responseProperties); 126 } 127 128 #endregion 129 130 private RestResource CreateRestSchema() 131 { 132 RestResource rootRes = new RestResource(soe_name, false, RootResHandler); 133 134 RestOperation sampleOper = new RestOperation("sampleOperation", 135 new string[] { "field" }, 136 new string[] { "json" }, 137 SaturationAnalystOpeartionHandler); 138 139 rootRes.operations.Add(sampleOper); 140 141 return rootRes; 142 } 143 144 private byte[] RootResHandler(NameValueCollection boundVariables, string outputFormat, string requestProperties, out string responseProperties) 145 { 146 responseProperties = null; 147 148 JsonObject result = new JsonObject(); 149 //result.AddString("hello", "world"); 150 151 return Encoding.UTF8.GetBytes(result.ToJson()); 152 } 153 154 private byte[] SaturationAnalystOpeartionHandler(NameValueCollection boundVariables, 155 JsonObject operationInput, 156 string outputFormat, 157 string requestProperties, 158 out string responseProperties) 159 { 160 responseProperties = null; 161 //反序列化参数 162 string strField; 163 bool found = operationInput.TryGetString("field", out strField); 164 if (!found || string.IsNullOrEmpty(strField)) 165 throw new ArgumentNullException("field"); 166 167 byte[] result = SaturationAnalyst(strField); 168 169 return result; 170 } 171 172 private byte[] SaturationAnalyst(string field) 173 { 174 if (field.Length <= 0) 175 { 176 throw new ArgumentOutOfRangeException("field"); 177 } 178 179 //插值 180 IRaster krigeRaster = BuildKrigeRaster(m_fcToAnalyst, field); 181 //重分类 182 IRaster reclassRastr = BuildReclassRaster(krigeRaster, 4); 183 //栅格转面,字段名作为生成的要素名称 184 IFeatureClass ConPolygon = RasterToPolygon(reclassRastr, field); 185 //将要素类序列化成json格式 186 JsonObject resultJsonObject = FclassToJsonObj(ConPolygon); 187 188 byte[] result = Encoding.UTF8.GetBytes(resultJsonObject.ToJson()); 189 return result; 190 191 } 192 193 #region Helper 函数 194 /// <summary> 195 /// 克里金插值 196 /// </summary> 197 /// <param name="inputFclass">插值数据</param> 198 /// <param name="inputField">字段</param> 199 /// <returns></returns> 200 private IRaster BuildKrigeRaster(IFeatureClass inputFclass, string inputField) 201 { 202 if (inputFclass == null) 203 { 204 logger.LogMessage(ServerLogger.msgType.error, "BuildKrigeRaster", 8000, "SOE custom error: inputFclass is null."); 205 return null; 206 } 207 if (inputField.Length == 0) 208 { 209 logger.LogMessage(ServerLogger.msgType.error, "BuildKrigeRaster", 8000, "SOE custom error: inputField is null."); 210 return null; 211 } 212 213 //生成IFeatureClassDescriptor 214 IFeatureClassDescriptor pFcDescriptor = new FeatureClassDescriptorClass(); 215 pFcDescriptor.Create(inputFclass, null, inputField); 216 //设置分析环境 217 IInterpolationOp pInterpolationOp = new RasterInterpolationOpClass(); 218 IRasterAnalysisEnvironment pEnv = pInterpolationOp as IRasterAnalysisEnvironment; 219 object cellSize = 5.80481468000016E-04; 220 pEnv.Reset();//栅格单元大小 221 pEnv.SetCellSize(esriRasterEnvSettingEnum.esriRasterEnvValue, ref cellSize); 222 223 IRasterRadius pRadius = new RasterRadius(); 224 object obj = Type.Missing;//可变搜索半径 225 pRadius.SetVariable(12, ref obj); 226 227 IGeoDataset pGeodataset = inputFclass as IGeoDataset; 228 IEnvelope pRasterExt = new EnvelopeClass(); 229 pRasterExt.XMin = 117.178625; 230 pRasterExt.XMax = 117.376715; 231 pRasterExt.YMax = 31.922649; 232 pRasterExt.YMin = 31.777529; 233 object extentPro = pRasterExt;//分析范围 234 pEnv.SetExtent(esriRasterEnvSettingEnum.esriRasterEnvValue, ref extentPro, ref obj); 235 pEnv.OutSpatialReference = pGeodataset.SpatialReference; 236 //执行差值 237 IGeoDataset pGeoDataset = pInterpolationOp.Krige((IGeoDataset)pFcDescriptor, esriGeoAnalysisSemiVariogramEnum.esriGeoAnalysisLinearSemiVariogram, 238 pRadius, false, ref obj); 239 240 return pGeoDataset as IRaster; 241 } 242 243 /// <summary> 244 /// 对栅格进行等间距重分类 245 /// </summary> 246 /// <param name="inputRaster">待分类栅格</param> 247 /// <param name="pClassNo">分类数</param> 248 /// <returns></returns> 249 private IRaster BuildReclassRaster(IRaster inputRaster, int pClassNo) 250 { 251 if (inputRaster == null) 252 { 253 logger.LogMessage(ServerLogger.msgType.error, "BuildReclassRaster", 8000, "SOE custom error: inputRaster is null."); 254 return null; 255 } 256 if (pClassNo <= 0) 257 { 258 logger.LogMessage(ServerLogger.msgType.error, "BuildReclassRaster", 8000, "SOE custom error: pClassNo is null."); 259 return null; 260 } 261 262 //获取栅格分类数组和频度数组 263 object dataValues = null, dataCounts = null; 264 GetRasterClass(inputRaster, out dataValues, out dataCounts); 265 266 //获取栅格分类间隔数组 267 IClassifyGEN pEqualIntervalClass = new EqualIntervalClass(); 268 pEqualIntervalClass.Classify(dataValues, dataCounts, ref pClassNo); 269 double[] breaks = pEqualIntervalClass.ClassBreaks as double[]; 270 271 //设置新分类值 272 INumberRemap pNemRemap = new NumberRemapClass(); 273 for (int i = 0; i < breaks.Length - 1; i++) 274 { 275 pNemRemap.MapRange(breaks[i], breaks[i + 1], i+1); 276 } 277 IRemap pRemap = pNemRemap as IRemap; 278 279 //设置环境 280 IReclassOp pReclassOp = new RasterReclassOpClass(); 281 IGeoDataset pGeodataset = inputRaster as IGeoDataset; 282 IRasterAnalysisEnvironment pEnv = pReclassOp as IRasterAnalysisEnvironment; 283 object obj = Type.Missing; 284 285 IEnvelope pRasterExt = new EnvelopeClass(); 286 pRasterExt.XMin = 117.178625; 287 pRasterExt.XMax = 117.376715; 288 pRasterExt.YMax = 31.922649; 289 pRasterExt.YMin = 31.777529; 290 object extentPro = pRasterExt;//分析范围 291 pEnv.SetExtent(esriRasterEnvSettingEnum.esriRasterEnvValue, ref extentPro, ref obj); 292 pEnv.OutSpatialReference = pGeodataset.SpatialReference; 293 294 //重分类 295 IRaster pRaster = pReclassOp.ReclassByRemap(pGeodataset, pRemap, false) as IRaster; 296 return pRaster; 297 } 298 299 /// <summary> 300 /// 获取栅格分类数组和频度数组 301 /// </summary> 302 /// <param name="inputRaster">输入栅格</param> 303 /// <param name="dataValues"></param> 304 /// <param name="dataCounts"></param> 305 private void GetRasterClass(IRaster inputRaster, out object dataValues, out object dataCounts) 306 { 307 IRasterBandCollection pRasBandCol = inputRaster as IRasterBandCollection; 308 IRasterBand pRsBand = pRasBandCol.Item(0); 309 pRsBand.ComputeStatsAndHist();//IRasterBand中本无统计直方图,必须先进行ComputeStatsAndHist() 310 IRasterStatistics pRasterStatistic = pRsBand.Statistics; 311 312 double mMean = pRasterStatistic.Mean; 313 double mStandsrdDeviation = pRasterStatistic.StandardDeviation; 314 315 IRasterHistogram pRasterHistogram = pRsBand.Histogram; 316 double[] dblValues; 317 dblValues = pRasterHistogram.Counts as double[]; 318 int intValueCount = dblValues.GetUpperBound(0) + 1; 319 double[] vValues = new double[intValueCount]; 320 321 double dMaxValue = pRasterStatistic.Maximum; 322 double dMinValue = pRasterStatistic.Minimum; 323 double BinInterval = Convert.ToDouble((dMaxValue - dMinValue) / intValueCount); 324 for (int i = 0; i < intValueCount; i++) 325 { 326 vValues[i] = i * BinInterval + pRasterStatistic.Minimum; 327 } 328 329 dataValues = vValues as object; 330 dataCounts = dblValues as object; 331 } 332 333 /// <summary> 334 /// 栅格转面 335 /// </summary> 336 /// <param name="inputRaster">待转换栅格</param> 337 /// <param name="fcName">生成要素类名称</param> 338 /// <returns></returns> 339 private IFeatureClass RasterToPolygon(IRaster inputRaster, string fcName) 340 { 341 //获得testPoint所在工作空间 342 IWorkspace pWorkspace = (m_fcToAnalyst as IDataset).Workspace; 343 IRasterBandCollection pRasBandCol = inputRaster as IRasterBandCollection; 344 IRasterBand pRsBand = pRasBandCol.Item(0); 345 IRasterDataset pRasterDataset = pRsBand as IRasterDataset; 346 IGeoDataset pRasterGeoDataset = pRasterDataset as IGeoDataset; 347 348 //栅格转面 349 IConversionOp pConversionOp = new RasterConversionOpClass(); 350 ISpatialReference pSpatialReference = pRasterGeoDataset.SpatialReference; 351 352 IGeoDataset pGeoDataset = pConversionOp.RasterDataToPolygonFeatureData(pRasterGeoDataset, pWorkspace, fcName, true); 353 return pGeoDataset as IFeatureClass; 354 } 355 356 /// <summary> 357 /// 将要素类序列化成json格式对象 358 /// </summary> 359 /// <param name="inputFeaClass">输入要素类</param> 360 /// <returns></returns> 361 private JsonObject FclassToJsonObj(IFeatureClass inputFeaClass) 362 { 363 //获取要素数目 364 IQueryFilter pQueryFilter = new QueryFilterClass(); 365 pQueryFilter.WhereClause = null; 366 int count = inputFeaClass.FeatureCount(pQueryFilter); 367 368 //将每一个要素序列化成json数据 369 IFeature pFeature = null; 370 List<JsonObject> jsonGeometries = new List<JsonObject>(); 371 for (int i = 1; i < count; i++)//OBJECTID从1开始 372 { 373 pFeature = inputFeaClass.GetFeature(i); 374 IGeometry pGeometry = pFeature.Shape; 375 JsonObject featureJson = new JsonObject(); 376 JsonObject feaGeoJson = null;//几何对象 377 if (pGeometry != null) 378 { 379 feaGeoJson = Conversion.ToJsonObject(pGeometry); 380 featureJson.AddJsonObject("geometry", feaGeoJson);//加入几何对象 381 } 382 383 384 double grid_Code = (double)pFeature.get_Value(pFeature.Fields.FindField("GRIDCODE")); 385 featureJson.AddLong("id", i);//id 386 featureJson.AddDouble("gridCode", grid_Code);//等级 387 388 jsonGeometries.Add(featureJson); 389 } 390 391 JsonObject resultJson = new JsonObject(); 392 resultJson.AddArray("geometries", jsonGeometries.ToArray()); 393 return resultJson; 394 } 395 #endregion 396 } 397 }
增加一个控制台应用程序,命名为RegisterSaturation
加入以下引用
代码:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using ESRI.ArcGIS.esriSystem; 6 using ESRI.ArcGIS.Server; 7 using ESRI.ArcGIS; 8 using ESRI.ArcGIS.ADF.Connection.AGS; 9 10 namespace RegisterSaturation 11 { 12 class Program 13 { 14 static void Main(string[] args) 15 { 16 // Must run as an user in the agsadmin group on the SOM 17 ESRI.ArcGIS.ADF.Connection.AGS.AGSServerConnection agsServerConnection = 18 new ESRI.ArcGIS.ADF.Connection.AGS.AGSServerConnection(); 19 agsServerConnection.Host = "localhost"; 20 agsServerConnection.Connect(); 21 ESRI.ArcGIS.Server.IServerObjectAdmin2 serverObjectAdmin = 22 agsServerConnection.ServerObjectAdmin as ESRI.ArcGIS.Server.IServerObjectAdmin2; 23 24 // This name must match those defined for property pages 25 string extensionName = "Saturation"; 26 27 // Check command line arguments to see if SOE is to be unregistered 28 if (args.Length == 1 && args[0] == "/unregister") 29 { 30 // Check whether the SOE is registered 31 if (ExtensionRegistered(serverObjectAdmin, extensionName)) 32 { 33 // Delete the SOE 34 serverObjectAdmin.DeleteExtensionType("MapServer", extensionName); 35 Console.WriteLine(extensionName + " successfully unregistered"); 36 } 37 else 38 Console.WriteLine(extensionName + " is not registered with ArcGIS Server"); 39 } 40 else 41 { 42 // Check whether the SOE is registered 43 if (!ExtensionRegistered(serverObjectAdmin, extensionName)) 44 { 45 // Use IServerObjectExtensionType3 to get access to info properties 46 ESRI.ArcGIS.Server.IServerObjectExtensionType3 serverObjectExtensionType = 47 serverObjectAdmin.CreateExtensionType() as ESRI.ArcGIS.Server.IServerObjectExtensionType3; 48 49 // Must match the namespace and class name of the class implementing IServerObjectExtension 50 serverObjectExtensionType.CLSID = "Saturation.Saturation"; 51 //serverObjectExtensionType.CLSID = "{C41E8674-F186-4a0c-8FC9-AAB7885EFD00}"; 52 serverObjectExtensionType.Description = "the shops saturation"; 53 serverObjectExtensionType.Name = extensionName; 54 55 // Name that will be shown in the capabilities list on property pages 56 serverObjectExtensionType.DisplayName = "Saturation REST"; 57 58 // Use info properties to define capabilities and msd support 59 serverObjectExtensionType.Info.SetProperty("DefaultWebCapabilities", "GetInfo"); 60 serverObjectExtensionType.Info.SetProperty("AllWebCapabilities", "GetInfo,Saturation"); 61 serverObjectExtensionType.Info.SetProperty("SupportsMSD", "true"); 62 63 // Required to enable exposure of SOE with ArcGIS Server REST endpoint 64 serverObjectExtensionType.Info.SetProperty("SupportsREST", "true"); 65 66 // Register the SOE with the server 67 serverObjectAdmin.AddExtensionType("MapServer", serverObjectExtensionType); 68 69 Console.WriteLine(extensionName + " successfully registered with ArcGIS Server"); 70 } 71 else 72 Console.WriteLine(extensionName + " is already registered with ArcGIS Server"); 73 } 74 75 Console.ReadLine(); 76 } 77 78 // Checks whether an extension with the passed-in name is already registered with the passed-in server 79 static private bool ExtensionRegistered(ESRI.ArcGIS.Server.IServerObjectAdmin2 serverObjectAdmin, string extensionName) 80 { 81 // Get the extensions that extend MapServer server objects 82 ESRI.ArcGIS.Server.IEnumServerObjectExtensionType extensionTypes = serverObjectAdmin.GetExtensionTypes("MapServer"); 83 extensionTypes.Reset(); 84 85 // If an extension with a name matching that passed-in is found, return true 86 ESRI.ArcGIS.Server.IServerObjectExtensionType extensionType = extensionTypes.Next(); 87 while (extensionType != null) 88 { 89 if (extensionType.Name == extensionName) 90 { 91 //serverObjectAdmin.DeleteExtensionType("MapServer", extensionName); 92 return true; 93 } 94 95 96 extensionType = extensionTypes.Next(); 97 } 98 99 // No matching extension was found, so return false 100 return false; 101 } 102 } 103 }
在代码中有个地方需要注意:
serverObjectExtensionType.CLSID = "Saturation.Saturation";
CLSID的值必须与你的命名空间的类名相一致
2.注册
(1)注册COM组件
SOE其实是运行在服务器端的COM组件,并且你需要在每台运行SOC的机器上都为其注册COM组件
* 右击Saturation工程--》属性--》生成,将目标平台选为“x86”,因为ArcServer是运行在32位机器上
*在属性中选择“签名”选项卡,为程序集生成一个签名
*在开始菜单-》visual studio 20110-》visual studio tools-》命令提示,打开命令提示工具
*在命令提示工具中输入命令:regasm <path to DLL> /codebase,在本例中输入如下命令:
regasm “C:\Users\LZZ\Documents\Visual Studio 2010\Projects\Saturation\Saturation\bin\Debug\Saturation.dll” /codebase
(2)ArcServer服务器注册
现在程序集已经注册号,接下来要在ArcServer服务器上注册,这里要用到RegisterSaturation工程,右击工程-》属性-》调试-》启动新实例,如果注册成功,你会看到一个提示成功的信息。
注意:在注册的时候一定要保证让运行在这台机器上的soc用户能够访问到你上面注册的dll,否则会提示你无法注册(给soc用户能够读取你注册的dll的权限)
3.调试
如果没有调试的话,那岂不是太痛苦了,不过还有说回来,SOE这个东西有调试也很痛苦。他需要附加到SOC进程中去,而你的机器上往往会有多个SOC进程,这时候如果你附加错误的话,你是无法进行调试的,我常用的方法是把其他的服务全部删除,是删除不是停止;这里还会碰到一个古怪的问题,就是第一次你可以调试,当你停止调后,再一次附加到进城后却无法调试,我的方法是停止服务,将该工程重新生成,然后启动服务,在附加到进程中去。这是我的这几天捉摸出来的经验。在调试之前我们首先需要使服务支持SOE,貌似在ArcGIS10中只支持MapServer的SOE。
这里我建议你用Manager来发布服务,发布服务的方法跟发布普通mapServer的方法一样,如果你的SOE在服务器上注册成功的话,你会看到以下内容:
在这里你不需要选中,我碰到过注册成功后这在里面却不显示的问题,这时候你只要重启一下计算机即可。发布服务成功后,点击编辑服务:
在这里选中Saturation REST,在下方选中两个check框,点击右边按钮保存并重启,现在我们的mapServer已经有了SOE能力。接下来我们进行调试:
在VS中点击调试-》附加到进程:
如果你看到你附加到进城后,断点依然显示的话,说明可以正常调试,否则你就需要看一下前文提到的解决方法啦。
当点击按钮时,后进入VS中的断点处:
运行成功后,你会看到返回的结果为json类型对象
现在我们已经介绍完SOE的内容了,如果你还有什么不了解的可以直接看帮助:
这里补充几句:官网中有三个例子,其中空间查询的那个我没运行成功,是跟里面设置属性也有关,因为我没用过里面的WebControl对他不太了解,而且我们的东西不需要使用属性页,所以暂时还无法解决他。希望有哪位大神看了这篇文章后能给小人指点一下,感激不尽。
这个教程到这里就暂时告一段落了,如果以后还碰到关于这种技术的问题话,我会继续发博文的。
在这里顺便感谢一下群哥多年来的帮助,这个技术也是从他那听说的。可以说没有群哥,也就没有这系列教程。呵呵,祝大家好运!!!
![](http://images2017.cnblogs.com/blog/412020/201712/412020-20171216224224593-1205430224.png)