標籤: 如何寫文案

  • .NetCore對接各大財務軟件憑證API——用友系列(2)

    .NetCore對接各大財務軟件憑證API——用友系列(2)

    一. 前言

    今天我們繼續來分析用友系列的第二個產品–U8Cloud2.5 ,apilink方式的API.官網的API文檔地址如下:U8API文檔 因為我們主要是憑證對接,所以使用到的模塊有總賬、基礎檔案這兩個模塊。

    Ps:2.5的財務系統如果不是最新補丁的話,要記得打補丁,不然後續的科目接口會有問題。

    二. API參數

    2.1 遠程訪問財務系統

    如果我們對接的財務系統是公有雲的U8C的話,你會得到一個遠程的財務系統的地址,接着使用UClient工具,即 通過集成友戶通,為企業應用提供了統一的單點登陸支持,支持CA登陸、短信登陸、用戶名/密碼登陸,支持企業用戶系統與友戶通進行綁定,實現統一的用戶登陸服務 的這麼一個工具。

    具體的添加應用的步驟為

    2.2 全局請求頭

    首先,我們必須要在網站內註冊賬號,API集市上有各個接口的詳細說明,我們需要獲取一個apicode參數,每個API模塊在點擊購買后系統會自動分配該模塊的apicode,所以這也就是我們需要兩個不同的apicode.

    基本上U8cloud2.5的版本接口,需要涉及到的請求參數就是這個了,接着我們就可以愉快的進行開發工作了。

    如圖,固定的全局請求頭參數有以下幾個:

    1.authoration–驗證方式;默認是apicode

    2.apicode—模塊的apicode.也就是我們上文中購買模塊后得到的參數.

    3.system—系統類型. 1—測試. 2—正式.

    4.trantype–翻譯類型; 默認為code.即採用編碼模式.

    2.3 基礎檔案

    基礎檔案,我們主要使用到的API接口有科目查詢.

    會計主體賬簿編碼–我們可以從財務系統里獲取,具體的獲取方式如下

    打開U8Client,使用正確的用戶名和密碼登錄財務系統.在企業建模平台–》基礎檔案–》組織機構–》會計主體 一欄,可以看到我們使用的會計主體賬簿編碼.如我們要使用的就是40001-9999

    其中40001為公司編碼,9999為會計方案.可以看到是採用分頁形式來訪問的,所以如果我們要一次性獲取到所有的會計科目,可以採用以下方法。

            public AccountQueryResponse QueryAccount(string pk_subjscheme, string pageIndex, string glorgBookCode)
            {
                var request = new AccountQueryRequest();
                var pms = new Dictionary<string, object>();
                pms.Add("pk_subjscheme", pk_subjscheme);
                pms.Add("glorgbookcode", glorgBookCode);
                pms.Add("page_now", pageIndex);
                pms.Add("page_size", "100");
                request.SetPostParameters(pms);
                return _Client.Excute(request);
            }
    
            public List<U8AccountResult> GetAccountQueryResult(string pk_subjscheme, string pageIndex, string glorgBookCode)
            {
                var list = new List<U8AccountResult>();
                var response = QueryAccount(pk_subjscheme, pageIndex, glorgBookCode);
                if (response != null && response.status == "success" && response.data != null)
                {
                    var result = JsonConvert.DeserializeObject<AccountQueryResult>(response.data);
                    list = result.datas == null ? new List<U8AccountResult>() : result.datas.ToList().Select(x => new U8AccountResult
                    {
                        balanorient = x.accsubjParentVO.balanorient,
                        subjcode = x.accsubjParentVO.subjcode,
                        subjname = x.accsubjParentVO.subjname,
                        dispname = x.accsubjParentVO.dispname,
                        remcode = x.accsubjParentVO.remcode,
                        subjId = x.accsubjParentVO.pk_accsubj,
                        endflag = x.accsubjParentVO.endflag,
                        subjectAssInfos = x.subjass == null ? new List<AccSubjectAssInfo>() : x.subjass.ToList().Select(t => new AccSubjectAssInfo
                        {
                            bdcode = t.bdcode,
                            bddispname = t.bddispname,
                            bdname = t.bdname
                        }).ToList()
                    }).ToList();
                }
                return list;
            }
    
    ///獲取所有的會計科目
            public List<U8AccountResult> GetAllAccount(string pk_subjescheme, string glorgBookCode)
            {
                var pageNo = "1";
                var list = new List<U8AccountResult>();
                var response = QueryAccount(pk_subjescheme, pageNo, glorgBookCode);
                if (response != null && response.status == "success" && response.data != null)
                {
                    var result = JsonConvert.DeserializeObject<AccountQueryResult>(response.data);
                    var allCount = Math.Ceiling(Convert.ToDouble(result.allcount) / result.retcount);
                    if (allCount >= 1)
                    {
                        for (int i = 1; i <= allCount; i++)
                        {
                            var resultList = GetAccountQueryResult(pk_subjescheme, i.ToString(), glorgBookCode);
                            list.AddRange(resultList);
                        }
                    }
                }
                return list;
            }
    

    allCount為總條數,retCount為當次請求的分頁條數,默認最大值為100,即接口每次只能返回100條數據,超過100條的數據量,我們就要採用分頁的形式來獲取了。

    這裏,有兩個隱藏的坑需要注意一下

    1.如果沒有打過類似“patch_會計科目查詢api查詢條件增加會計主體賬簿編碼”這樣的補丁,我們無法傳入會計主體賬簿編碼,就默認返回該集團下所有公司的會計科目,這樣顯然達不到我們的目的。

    2.返回的會計科目中沒有輔助核算明細,這對於我們傳輸憑證也是有影響的。所以這兩個補丁,如果我們在對接的過程中發現有接口有問題,那麼就要聯繫總部的老師幫忙打相應的補丁了.

    2.4 總賬

    總賬模塊,主要是我們的憑證傳輸了.

    我們先來看憑證的保存,憑證保存要傳入相應的憑證json串.

            public GL_VoucherInsertResponse InsertVoucher(List<object> models)
            {
                var request = new GL_VoucherInsertRequest();
                var pms = new Dictionary<string, object>();
                pms.Add("voucher", models);
                request.SetPostParameters(pms);
                return _Client.Excute(request);
            }
    ///憑證新增結果
            public List<U8GLVoucherResult> GetVoucherInsertResult(List<object> models)
            {
                var list = new List<U8GLVoucherResult>();
                var response = InsertVoucher(models);
                if (response != null && response.status == "success")
                {
                    if (response.data != null && !response.data.IsNullOrEmpty())
                    {
                        var result = JsonConvert.DeserializeObject<List<VoucherResult>>(response.data);
                        list = result.Select(x => new U8GLVoucherResult
                        {
                            explanation = x.explanation,
                            glorgbook_code = x.glorgbook_code,
                            glorgbook_name = x.glorgbook_name,
                            no = x.no,
                            pk_glorgbook = x.pk_glorgbook,
                            pk_voucher = x.pk_voucher,
                            totalcredit = x.totalcredit,
                            totaldebit = x.totaldebit,
                            pk_vouchertype = x.pk_vouchertype,
                            vouchertype_code = x.vouchertype_code,
                            vouchertype_name = x.vouchertype_name,
                            prepareddate = Convert.ToDateTime(x.prepareddate),
                            errorMsg = ""
                        }).ToList();
                    }
                }
                else
                {
                    list.Add(new U8GLVoucherResult { errorMsg = response.errormsg });
                }
                return list;
            }
    

    借貸方,憑證字主要用於我們新增后回執進行憑證記錄的.

    接着我們來看憑證保存的實體類.

     public class U8VoucherModel
        {
            /// <summary>
            /// 是否差異憑證
            /// </summary>
            public bool ISDIFFLAG { get; set; }
            /// <summary>
            /// 附單據數
            /// </summary>
            public string attachment { get; set; }
            public Detail[] details { get; set; }
            /// <summary>
            /// 憑證摘要
            /// </summary>
            public string explanation { get; set; }
            /// <summary>
            /// 憑證號
            /// </summary>
            public string no { get; set; }
            /// <summary>
            /// 公司
            /// </summary>
            public string pk_corp { get; set; }
            /// <summary>
            /// 賬簿
            /// </summary>
            public string pk_glorgbook { get; set; }
            /// <summary>
            /// 制單人編碼
            /// </summary>
            public string pk_prepared { get; set; }
            /// <summary>
            /// 憑證類別簡稱
            /// </summary>
            public string pk_vouchertype { get; set; }
            /// <summary>
            /// 制單日期
            /// </summary>
            public string prepareddate { get; set; }
            /// <summary>
            /// 憑證類型
            /// </summary>
            public int voucherkind { get; set; }
        }
    
        public class Detail
        {
            /// <summary>
            /// 原幣貸方金額
            /// </summary>
            public string creditamount { get; set; }
            /// <summary>
            /// 貸方數量
            /// </summary>
            public string creditquantity { get; set; }
            /// <summary>
            /// 原幣借方金額
            /// </summary>
            public string debitamount { get; set; }
            /// <summary>
            /// 借方數量
            /// </summary>
            public string debitquantity { get; set; }
            /// <summary>
            /// 分錄號
            /// </summary>
            public string detailindex { get; set; }
            /// <summary>
            /// 匯率
            /// </summary>
            public string excrate1 { get; set; }
            /// <summary>
            /// 摘要
            /// </summary>
            public string explanation { get; set; }
            /// <summary>
            /// 本幣貸方金額
            /// </summary>
            public string localcreditamount { get; set; }
            /// <summary>
            /// 本幣借方金額
            /// </summary>
            public string localdebitamount { get; set; }
            /// <summary>
            /// 科目
            /// </summary>
            public string pk_accsubj { get; set; }
            /// <summary>
            /// 幣別編碼
            /// </summary>
            public string pk_currtype { get; set; }
            /// <summary>
            /// 單價
            /// </summary>
            public string price { get; set; }
            public Ass[] ass { get; set; }
            public Cashflow[] cashflow { get; set; }
        }
    
        public class Ass
        {
            /// <summary>
            /// 輔助核算類型編碼
            /// </summary>
            public string checktypecode { get; set; }
            /// <summary>
            /// 輔助核算值編碼
            /// </summary>
            public string checkvaluecode { get; set; }
        }
    
        public class Cashflow
        {
            public string cashflow_code { get; set; }
            public string currtype_code { get; set; }
            public int money { get; set; }
        }
    

    整個憑證對接下來,其實坑不是很多,主要在於前期接口文檔的研究,參數的獲取以及測試接口連通性上面.

    三.結束語

    希望文章能在你開發API接口對接的路上一些幫助和解疑,也希望同樣做API對接的小夥伴,我們可以多多交流。祝你在開發的道路上勇往直前。

    我是程序猿貝塔,一個分享自己對接過財務系統API經歷和生活感悟的程序員。

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※別再煩惱如何寫文案,掌握八大原則!

    ※教你寫出一流的銷售文案?

    ※超省錢租車方案

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • 被迫重構代碼,這次我幹掉了 if-else

    被迫重構代碼,這次我幹掉了 if-else

    本文收錄在個人博客:www.chengxy-nds.top,技術資源共享,一起進步

    最近公司貌似融到資了!開始發了瘋似的找渠道推廣,現在終於明白為啥前一段大肆的招人了,原來是在下一盤大棋,對員工總的來看是個好事,或許是時候該跟boss提一提漲工資的話題了。

    不過,漲工資還沒下文,隨之而來的卻是一車一車的需求,每天都有新渠道接入,而且每個渠道都要提供個性化支持,開發量陡增。最近都沒什麼時間更文,準點下班都成奢望了!

    由於推廣渠道的激增,而每一個下單來源在下單時都做特殊的邏輯處理,可能每两天就會加一個來源,已經把之前的下單邏輯改的面目全。出於長遠的考慮,我決定對現有的邏輯進行重構,畢竟長痛不如短痛。

    傳統的實現方式

    我們看下邊的偽代碼,大致就是重構前下單邏輯的代碼,由於來源比較少,簡單的做if-else邏輯判斷足以滿足需求。

    現在每種訂單來源的處理邏輯都有幾百行代碼,看着已經比較臃腫,可我愣是遲遲沒動手重構,一方面業務方總像催命鬼一樣的讓你趕工期,想快速實現需求,這樣寫是最快;另一方面是不敢動,面對古董級代碼,還是想求個安穩。

    但這次來源一下子增加幾十個,再用這種方式做已經無法維護了,想象一下那種臃腫的if-else代碼,別說開發想想都頭大!

    public class OrderServiceImpl implements IOrderService {
        @Override
        public String handle(OrderDTO dto) {
            String type = dto.getType();
            if ("1".equals(type)) {
                return "處理普通訂單";
            } else if ("2".equals(type)) {
                return "處理團購訂單";
            } else if ("3".equals(type)) {
                return "處理促銷訂單";
            }
            return null;
        }
    }
    

    策略模式的實現方式

    思來想去基於當前業務場景重構,還是用策略模式比較合適,它是oop中比較著名的設計模式之一,對方法行為的抽象。

    策略模式定義了一個擁有共同行為的算法族,每個算法都被封裝起來,可以互相替換,獨立於客戶端而變化。

    一、策略模式的使用場景:

    • 針對同一問題的多種處理方式,僅僅是具體行為有差別時;
    • 需要安全地封裝多種同一類型的操作時;
    • 同一抽象類有多個子類,而客戶端需要使用if-else 或者 switch-case 來選擇具體子類時。

    這個是用策略模式修改後代碼:

    @Component
    @OrderHandlerType(16)
    public class DispatchModeProcessor extends AbstractHandler{
    
    	@Autowired
    	private OrderStencilledService orderStencilledService;
    	
    	@Override
    	public void handle(OrderBO orderBO) {
    		
    		/**
        	 * 訂單完結廣播通知(1 - 支付完成)
        	 */
        	orderStencilledService.dispatchModeFanout(orderBO);
    		
        	/**
        	 *  SCMS 出庫單
        	 */
        	orderStencilledService.createScmsDeliveryOrder(orderBO.getPayOrderInfoBO().getLocalOrderNo());
    	}
    }
    

    每個訂單來源都有自己單獨的邏輯實現類,而每次需要添加訂單來源,直接新建實現類,修改@OrderHandlerType(16)的數值即可,再也不用去翻又臭又長的if-lese

    不僅如此在分配任務時,每個人負責開發幾種訂單來源邏輯,都可以做到互不干擾,而且很大程度上減少了合併代碼的衝突。

    二、具體的實現過程:

    1、定義註解

    定義一個標識訂單來源的註解@OrderHandlerType

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Inherited
    public @interface OrderHandlerType {
    	int value() default 0;
    }
    

    2、抽象業務處理器

    向上抽象出來一個具體的業務處理器

    public abstract class AbstractHandler {
    	abstract public void handle(OrderBO orderBO);
    }
    

    3、項目啟動掃描 handler 入口

    @Component
    @SuppressWarnings({"unused","rawtypes"})
    public class HandlerProcessor implements BeanFactoryPostProcessor {
    	
    	private String basePackage = "com.ecej.order.pipeline.processor";
    	
        public static final Logger log = LoggerFactory.getLogger(HandlerProcessor.class);
    	
    	@Override
    	public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
    		
    		Map<Integer,Class> map = new HashMap<Integer,Class>();
    		
    		ClassScaner.scan(basePackage, OrderHandlerType.class).forEach(x ->{
    			int type = x.getAnnotation(OrderHandlerType.class).value();
    			map.put(type,x);
    		});
    		
    		beanFactory.registerSingleton(OrderHandlerType.class.getName(), map);
    		
    		log.info("處理器初始化{}", JSONObject.toJSONString(beanFactory.getBean(OrderHandlerType.class.getName())));
    	}
    }
    

    4、掃描需要用到的工具類

    public class ClassScaner {
    	private ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver();
    
    	private final List<TypeFilter> includeFilters = new ArrayList<TypeFilter>();
    
    	private final List<TypeFilter> excludeFilters = new ArrayList<TypeFilter>();
    
    	private MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resourcePatternResolver);
    	
    	/**
    	 * 添加包含的Fiter
    	 * @param includeFilter
    	 */
    	public void addIncludeFilter(TypeFilter includeFilter) {
    		this.includeFilters.add(includeFilter);
    	}
    
    	/**
    	 * 添加排除的Fiter
    	 * @param includeFilter
    	 */
    	public void addExcludeFilter(TypeFilter excludeFilter) {
    		this.excludeFilters.add(excludeFilter);
    	}
    	
    	/**
    	 * 掃描指定的包,獲取包下所有的Class
    	 * @param basePackage 包名
    	 * @param targetTypes 需要指定的目標類型,可以是pojo,可以是註解
    	 * @return Set<Class<?>>
    	 */
    	public static Set<Class<?>> scan(String basePackage,
    			Class<?>... targetTypes) {
    		ClassScaner cs = new ClassScaner();
    		for (Class<?> targetType : targetTypes){
    			if(TypeUtils.isAssignable(Annotation.class, targetType)){
    				cs.addIncludeFilter(new AnnotationTypeFilter((Class<? extends Annotation>) targetType));
    			}else{
    				cs.addIncludeFilter(new AssignableTypeFilter(targetType));
    			}
    		}
    		return cs.doScan(basePackage);
    	}
    	
    	/**
    	 * 掃描指定的包,獲取包下所有的Class
    	 * @param basePackages 包名,多個
    	 * @param targetTypes 需要指定的目標類型,可以是pojo,可以是註解
    	 * @return Set<Class<?>>
    	 */
    	public static Set<Class<?>> scan(String[] basePackages,
    			Class<?>... targetTypes) {
    		ClassScaner cs = new ClassScaner();
    		for (Class<?> targetType : targetTypes){
    			if(TypeUtils.isAssignable(Annotation.class, targetType)){
    				cs.addIncludeFilter(new AnnotationTypeFilter((Class<? extends Annotation>) targetType));
    			}else{
    				cs.addIncludeFilter(new AssignableTypeFilter(targetType));
    			}
    		}
    		Set<Class<?>> classes = new HashSet<Class<?>>();
    		for (String s : basePackages){
    			classes.addAll(cs.doScan(s));
    		}
    		return classes;
    	}
    	
    	/**
    	 * 掃描指定的包,獲取包下所有的Class
    	 * @param basePackages 包名
    	 * @return Set<Class<?>>
    	 */
    	public Set<Class<?>> doScan(String [] basePackages) {
    		Set<Class<?>> classes = new HashSet<Class<?>>();
    		for (String basePackage :basePackages) {
    			classes.addAll(doScan(basePackage));
    		}
    		return classes;
    	}
    	
    	/**
    	 * 掃描指定的包,獲取包下所有的Class
    	 * @param basePackages 包名
    	 * @return Set<Class<?>>
    	 */
    	public Set<Class<?>> doScan(String basePackage) {
    		Set<Class<?>> classes = new HashSet<Class<?>>();
    		try {
    			String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX
    					+ ClassUtils.convertClassNameToResourcePath(
    							SystemPropertyUtils.resolvePlaceholders(basePackage))+"/**/*.class";
    			Resource[] resources = this.resourcePatternResolver.getResources(packageSearchPath);
    			for (int i = 0; i < resources.length; i++) {
    				Resource resource = resources[i];
    				if (resource.isReadable()) {
    					MetadataReader metadataReader = this.metadataReaderFactory.getMetadataReader(resource);
    					if ((includeFilters.size() == 0 && excludeFilters.size() == 0)|| matches(metadataReader)) {
    						try {
    							classes.add(Class.forName(metadataReader.getClassMetadata().getClassName()));
    						} catch (ClassNotFoundException ignore) {}
    					}
    				}
    			}
    		} catch (IOException ex) {
    			throw new RuntimeException("I/O failure during classpath scanning", ex);
    		}
    		return classes;
    	}
    	
    	/**
    	 * 處理 excludeFilters和includeFilters
    	 * @param metadataReader
    	 * @return boolean
    	 * @throws IOException
    	 */
    	private boolean matches(MetadataReader metadataReader) throws IOException {
    		for (TypeFilter tf : this.excludeFilters) {
    			if (tf.match(metadataReader, this.metadataReaderFactory)) {
    				return false;
    			}
    		}
    		for (TypeFilter tf : this.includeFilters) {
    			if (tf.match(metadataReader, this.metadataReaderFactory)) {
    				return true;
    			}
    		}
    		return false;
    	}
    }
    
    

    5、根據類型實例化抽象類

    
    @Component
    public class HandlerContext {
    
    	@Autowired
    	private ApplicationContext beanFactory;
    
    	public  AbstractHandler getInstance(Integer type){
    		
    		Map<Integer,Class> map = (Map<Integer, Class>) beanFactory.getBean(OrderHandlerType.class.getName());
    		
    		return (AbstractHandler)beanFactory.getBean(map.get(type));
    	}
    	
    }
    

    6、調用入口

    我這裡是在接受到MQ消息時,處理多個訂單來源業務,不同訂單來源路由到不同的業務處理類中。

    
    @Component
    @RabbitListener(queues = "OrderPipelineQueue")
    public class PipelineSubscribe{
     
    	private final Logger LOGGER = LoggerFactory.getLogger(PipelineSubscribe.class);
    	
    	@Autowired
    	private HandlerContext HandlerContext;
    	
    	@Autowired
    	private OrderValidateService orderValidateService;
    	
        @RabbitHandler
        public void subscribeMessage(MessageBean bean){
        	
        	OrderBO orderBO = JSONObject.parseObject(bean.getOrderBO(), OrderBO.class);
        	
        	if(null != orderBO &&CollectionUtils.isNotEmpty(bean.getType()))
        	{
        		for(int value:bean.getType())
        		{
           		 AbstractHandler handler = HandlerContext.getInstance(value);
           		 handler.handle(orderBO);
        		}
    		}
    	}
    }
    

    接收實體 MessageBean 類代碼

    public class MessageBean implements Serializable {
        private static final long serialVersionUID = 5454831432308782668L;
        private String cachKey;
        private List<Integer> type;
        private String orderBO;
    
        public MessageBean(List<Integer> type, String orderBO) {
            this.type = type;
            this.orderBO = orderBO;
        }
    }
    

    以上設計模式方式看着略顯複雜,很些小夥伴提出質疑:“你為了個if-else,弄的如此的麻煩,又是自定義註解,又弄這麼多類不麻煩嗎?” 還有一些小夥伴糾結於性能問題,策略模式的性能可能確實不如if-else

    但我覺得吧增加一點複雜度、犧牲一丟丟性能,換代碼的整潔和可維護性還是值得的。不過,一個人一個想法,怎麼選還是看具體業務場景吧!

    策略模式的優缺點

    優點

    • 易於擴展,增加一個新的策略只需要添加一個具體的策略類即可,基本不需要改變原有的代碼,符合開放封閉原則
    • 避免使用多重條件選擇語句,充分體現面向對象設計思想 策略類之間可以自由切換,由於策略類都實現同一個接口,所以使它們之間可以自由切換
    • 每個策略類使用一個策略類,符合單一職責原則 客戶端與策略算法解耦,兩者都依賴於抽象策略接口,符合依賴反轉原則
    • 客戶端不需要知道都有哪些策略類,符合最小知識原則

    缺點

    • 策略模式,當策略算法太多時,會造成很多的策略類
    • 客戶端不知道有哪些策略類,不能決定使用哪個策略類,這點可以通過封裝common公共包解決,也可以考慮使IOC容器依賴注入的方式來解決。

    以下是訂單來源策略類的一部分,不得不說策略類確實比較多。

    總結

    凡事都有他的兩面性,if-else多層嵌套和也都有其各自的優缺點:

    • if-else的優點就是簡單,想快速迭代功能,邏輯嵌套少且不會持續增加,if-else更好些,缺點也是顯而易見,代碼臃腫繁瑣不便於維護。

    • 策略模式 將各個場景的邏輯剝離出來維護,同一抽象類有多個子類,需要使用if-else 或者 switch-case 來選擇具體子類時,建議選策略模式,他的缺點就是會產生比較多的策略類文件。

    兩種實現方式各有利弊,如何選擇還是要依據具體業務場景,還是那句話設計模式不是為了用而用,一定要用在最合適的位置。

    閑聊

    平常和粉絲私下聊天,好多人對於學設計模式的感受:設計模式背了一大堆,可平常開發還不是成天寫if-else業務邏輯,根本就用不到。

    學設計模式也不是用不到,只是有時候沒有合適它的場景而已,像我們今天說的這種業務場景,用設計模式就可以完美的解決嘛。

    學了N多技術可工作用不到是一種很常見的事情,一個穩定的項目使用一種技術會有諸多考量的,新技術會不會提升系統複雜度?它有哪些性能瓶頸?這些都必須考慮到,畢竟項目穩定才是最重要,誰也不敢輕易冒險嘗試。

    而我們學習技術可不僅為了眼下項目中是否會用到,是要做一個技術積累,做長遠打算,人往高處走,沒點能力可不行。

    原創不易,燃燒秀髮輸出內容,希望你能有一丟丟收穫!

    整理了幾百本各類技術电子書,送給小夥伴們。關公眾號回復【666】自行領取。和一些小夥伴們建了一個技術交流群,一起探討技術、分享技術資料,旨在共同學習進步,如果感興趣就掃碼加入我們吧!

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※別再煩惱如何寫文案,掌握八大原則!

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    ※超省錢租車方案

    ※教你寫出一流的銷售文案?

    網頁設計最專業,超強功能平台可客製化

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • 120行代碼打造.netcore生產力工具-小而美的後台異步組件

    120行代碼打造.netcore生產力工具-小而美的後台異步組件

    相信絕大部分開發者都接觸過用戶註冊的流程,通常情況下大概的流程如下所示:

    1. 接收用戶提交註冊信息
    2. 持久化註冊信息(數據庫+redis)
    3. 發送註冊成功短信(郵件)
    4. 寫操作日誌(可選)

    偽代碼如下:

    public async Task<IActionResult> Reg([FromBody] User user)
    {
        _logger.LogInformation("持久化數據開始");
        await Task.Delay(50);
        _logger.LogInformation("持久化結束");
        _logger.LogInformation("發送短信開始");
        await Task.Delay(100);
        _logger.LogInformation("發送短信結束");
        _logger.LogInformation("操作日誌開始");
        await _logRepository.Insert(new Log { Txt = "註冊日誌" });
        _logger.LogInformation("操作日誌結束");
        return Ok("註冊成功");
    }
    
    

    在以上的代碼中,我使用Task.Delay方法阻塞主線程,用以模擬實際場景中的執行耗時。以上流程應該是包含了絕大部分註冊流程所需要的操作。對於任何開發者來講,以上業務流程沒任何難度,無非是順序的執行各個流程的代碼即可。

    稍微有點開發經驗的應該會將以上的流程進行拆分,但有些人可能就要問了,為什麼要拆分呢?拆分之後的代碼應該怎麼寫呢?下面我們就來簡單聊下如此場景的正確打開方式。

    首先,註冊成功的依據應該是是否成功的將用戶信息持久化(至於是先持久化到數據庫,異或是先寫到redis不在本篇文章討論的範疇),至於發送註冊短信(郵件)以及寫日誌的操作應該不能成為影響註冊是否成功的因素,而發送短信/郵件等相關操作通常情況下也是比較耗時的,所以在對此接口做性能優化時,可優先考慮將短信/郵件以及寫日誌等相關操作與主流程(持久化數據)拆分,使其不阻塞主流程的執行,從而達到提高響應速度的目的。

    知道了為什麼要拆,但具體如何拆分呢?怎樣才能用最少的改動,達到所需的目的呢?

    條條大路通羅馬,所以要達成我們的目的也是有很多方案的,具體選擇哪種方案需要根據具體的業務場景,業務體量等多種因素綜合考慮,下面我將一一介紹分析相關方案。

    在正式介紹可用方案前,筆者想先介紹一種很多新手容易錯誤使用的一種方案(因為筆者就曾經天真的使用過這種錯誤的方案)。

    提到異步,絕大部分.net開發者應該第一想到的就是Task,async,await等,的確,async,await的語法糖簡化了.net開發者異步編程的門檻,減少了很多代碼量。通常一個返回Task類型的方法,在被調用時,會在方法的前面加上await,表示需要等待此方法的執行結果,再繼續執行後面的代碼。但如果不加await時,則不會等待方法的執行結果,進而也不會阻塞主線程。所以,有些人可能就會將發送短信/郵件以及寫日誌的操作如下方式進行改造。

    public async Task<IActionResult> Reg1([FromBody] User user)
    {
        _logger.LogInformation("持久化數據開始");
        await Task.Delay(50);
        _logger.LogInformation("持久化結束");
        _ = Task.Run(async () =>
         {
             _logger.LogInformation("發送短信開始");
             await Task.Delay(100);
             _logger.LogInformation("發送短信結束");
             _logger.LogInformation("操作日誌開始");
             await _logRepository.Insert(new Log { Txt = "註冊日誌" });
             _logger.LogInformation("操作日誌結束");
         });
        return Ok("註冊成功");
    }
    

    然後使用jmeter分別壓測改造前和改造后的接口,結果如下:

    有沒有被驚訝到?就這樣一個簡單的改造,吞吐量就提高了三四倍。既然已經提高了三四倍,那為什麼說這是一種錯誤的改造方法嗎?各位看官且往下看。

    熟悉.netcore的大佬,應該都知道.netcore的依賴注入的生命周期吧。通常情況下,注入的生命周期包括:Singleton,Scope,Transient。
    在以上的流程中,假如寫操作日誌的實例的生命周期是Scope,當在Task中調用Controller獲取到的實例的方法時,因為Task.Run並沒有阻塞主線程,當調用Action return后,當前請求的scope注入的對象會被回收,如果對象被回收之後,Task.Run還未執行完,則會報System.ObjectDisposedException: Cannot access a disposed object. 異常。意思是,不能訪問一個已disposed的對象。正確的做法是使用IServiceScopeFactory創建一個新的作用域,在新的作用域中獲取獲取日誌倉儲服務的實例。這樣就可以避免System.ObjectDisposedException異常了。
    改造后的示例代碼如下:

    public async Task<IActionResult> Reg1([FromBody] User user)
    {
        _logger.LogInformation("持久化數據開始");
        await Task.Delay(50);
        _logger.LogInformation("持久化結束");
        _ = Task.Run(async () =>
        {
            using (var scope = _scopeFactory.CreateScope())
            {
                var sp = scope.ServiceProvider;
                var logRepository = sp.GetService<ILogRepository>();
                _logger.LogInformation("發送短信開始");
                await Task.Delay(100);
                _logger.LogInformation("發送短信結束");
    
                _logger.LogInformation("操作日誌開始");
                await logRepository.Insert(new Log { Txt = "註冊日誌" });
                _logger.LogInformation("操作日誌結束");
            }
        });
        return Ok("註冊成功");
    }
    

    雖然得到了正解,但上述的代碼着實有點多,如果一個項目有多個相似的業務場景,就要考慮對CreateScope相關的操作進行封裝。

    下面就來一一介紹下筆者覺得實現此業務場景的幾種方案。
    1.消息隊列
    2.Quartz任務調度組件
    3.Hangfire任務調度組件
    4.Weshare.TransferJob(推薦)
    首先說下消息隊列的方式。準確的說,消息隊列應該是這種場景的最優解決方案,消息隊列的其中一個比較重要的特性就是解耦,從而提高吞吐量。但並不是所有的應用程序都需要上消息隊列。有些業務場景使用消息隊列時,往往會給人一種”殺雞用牛刀”的感覺。

    其次Quartz和Hangfire都是任務調度框架,都提供了可實現以上業務場景的邏輯,但Quartz和Hangfire都需要持久化作業數據。雖然Hangfire提供了內存版本,但經過我的測試,發現Hangfire的內存版本特別消耗內存,所以不太推薦使用任務調度框架來實現類似於這樣的業務邏輯。

    最後,也就是本文的重點,筆者結合了消息隊列和任務調度的思想,實現了一個輕量級的轉移作業到後台執行的組件。此組件完美的解決了Scope生命周期實例獲取的問題,一行代碼將不需要等待的操作轉移到後台線程執行。
    接入步驟如下:
    1.使用nuget安裝Weshare.TransferJob
    2.在Stratup中注入服務。

    services.AddTransferJob();
    

    3.通過構造函數或其他方法獲取到IBackgroundRunService的實例。
    4.調用實例的Transfer方法將作業轉移到後台線程。

    _backgroundRunService.Transfer(log=>log.Insert(new Log(){Txt = "註冊日誌"}));
    

    就是這麼簡單的實現了這樣的業務場景,不僅簡化了代碼,而且大大提高了系統的吞吐量。

    下面再來一起分析下Weshare.TransferJob的核心代碼(畢竟文章要點題)。各位器宇不凡的看官請繼續往下看。
    下面的代碼是AddTransferJob方法的實現:

    public static IServiceCollection AddTransferJob(this IServiceCollection services)
    {
        services.AddSingleton<IBackgroundRunService, BackgroundRunService>();
        services.AddHostedService<TransferJobHostedService>();
        return services;
    }
    

    聰明”絕頂”的各位看官應該已經發現上述代碼的關鍵所在。是的, 你沒有看錯,此組件的就是利用.net core提供的HostedService在後台執行被轉移的作業的。
    我們再來一起看看TransferJobHostedService的代碼:

    public class TransferJobHostedService:BackgroundService
    {
        private IBackgroundRunService _runService;
        public TransferJobHostedService(IBackgroundRunService runService)
        {
            _runService = runService;
        }
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                await _runService.Execute(stoppingToken);
            }
        }
    }
    

    這個類的代碼也很簡單,重寫了BackgroundService類的ExecuteAsync,循環調用IBackgroundRunService實例的Execute方法。所以,最最關鍵的代碼是IBackgroundRunService的實現類中。
    詳細代碼如下:

    public class BackgroundRunService : IBackgroundRunService
    {
        private readonly SemaphoreSlim _slim;
        private readonly ConcurrentQueue<LambdaExpression> queue;
        private ILogger<BackgroundRunService> _logger;
        private readonly IServiceProvider _serviceProvider;
        public BackgroundRunService(ILogger<BackgroundRunService> logger, IServiceProvider serviceProvider)
        {
            _slim = new SemaphoreSlim(1);
            _logger = logger;
            _serviceProvider = serviceProvider;
            queue = new ConcurrentQueue<LambdaExpression>();
        }
        public async Task Execute(CancellationToken cancellationToken)
        {
            try
            {
                await _slim.WaitAsync(cancellationToken);
                if (queue.TryDequeue(out var job))
                {
                    using (var scope = _serviceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope())
                    {
                        var action = job.Compile();
                        var isTask = action.Method.ReturnType == typeof(Task);
                        var parameters = job.Parameters;
                        var pars = new List<object>();
                        if (parameters.Any())
                        {
                            var type = parameters[0].Type;
                            var param = scope.ServiceProvider.GetRequiredService(type);
                            pars.Add(param);
                        }
                        if (isTask)
                        {
                            await (Task)action.DynamicInvoke(pars.ToArray());
                        }
                        else
                        {
                            action.DynamicInvoke(pars.ToArray());
                        }
                    }
                }
            }
            catch (Exception e)
            {
                _logger.LogError(e.ToString());
            }
        }
        public void Transfer<T>(Expression<Func<T, Task>> expression)
        {
            queue.Enqueue(expression);
            _slim.Release();
        }
        public void Transfer(Expression<Action> expression)
        {
            queue.Enqueue(expression);
            _slim.Release();
        }
    }
    

    納尼?嫌代碼多看不懂?那咱們一起來剖析下吧。
    首先,此類有三個較重要的私有變量,對應的類型分別是SemaphoreSlim, ConcurrentQueue ,IServiceProvider。
    其中SemaphoreSlim是為了控制後台作業執行的順序的,在構造函數中初始化了此對象的信號量為1,表示在後台服務的ExecuteAsync方法的循環中每次只能有一個作業執行。
    ConcurrentQueue 的對象是用來存儲被轉移到後台服務執行的作業的邏輯,所以使用LambdaExpression作為隊列的類型。
    IServiceProvider是為了解決依賴注入的生命周期的。

    然後在Execute方法中,第一行代碼如下:

    await _slim.WaitAsync(cancellationToken);
    

    作用是等待一個信號量,當沒有可用的信號量時,會阻塞線程的執行,這樣在後台服務的ExecuteAsync方法的死循環就不會一直執行下去,只有獲取到信號量才會繼續執行。
    當獲取到信號量后,則說明有新的作業等待執行,所以此時則需要從隊列中讀出要執行的LambdaExpression表達式,創建一個新的Scope后,編譯此表達式樹,判斷返回類型,獲取泛型的具體類型,最後獲取到泛型對應的實例,執行對應的方法。

    另外,Transfer方法就是暴露給調用者的方法,用於將表達式樹寫到隊列中,同時釋放信號量。

    到此為止,Weshare.TransferJob的實現原理已分析完畢,由於此組件的原理只是將任務轉移到後台進行執行,所以並不是適合對事務有要求的場景。正如本文開頭所假設的場景,TransferJob最適合的場景還是那些和主操作關聯性較低的、失敗或成功並不會影響業務的正常運行。
    同時,此組件的定位就是小而美,像延遲執行、定時執行的功能在最初的規劃中其實是有的,後來發現這些功能quartz已經有了,所以沒必要重複造這樣的輪子。
    後期會根據使用場景,嘗試加入異常重試機制,以及異常通知回調機制。

    最後,不知道有沒有較真的看官想計算下代碼量是否超過120行。
    為了證明我不是標題黨,現將此組件進行開源,地址是:
    https://github.com/fuluteam/WeShare.TransferJob

    橋豆麻袋,筆者辛苦敲的代碼,難道各位看官想白嫖嗎? 點個贊再走唄。點完贊還有力氣的話,如果git上能點個star的話,那也是最好不過的。小生這廂先行謝過。

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※教你寫出一流的銷售文案?

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※回頭車貨運收費標準

    ※別再煩惱如何寫文案,掌握八大原則!

    ※超省錢租車方案

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • 分佈式鎖的一些理解

     在多線程併發的情況下,單個節點內的線程安全可以通過synchronized關鍵字和Lock接口來保證。

    synchronized和lock的區別

    1. Lock是一個接口,是基於在語言層面實現的鎖,而synchronized是Java中的關鍵字,是基於JVM實現的內置鎖,Java中的每一個對象都可以使用synchronized添加鎖。

    2. synchronized在發生異常時,會自動釋放線程佔有的鎖,因此不會導致死鎖現象發生;而Lock在發生異常時,如果沒有主動通過unLock()去釋放鎖,則很可能造成死鎖現象,因此使用Lock時需要在finally塊中釋放鎖;

    3. Lock可以讓等待鎖的線程響應中斷,而synchronized卻不行,使用synchronized時,等待的線程會一直等待下去,不能夠響應中斷;

    4. Lock可以提高多個線程進行讀操作的效率。(可以通過readwritelock實現讀寫分離,一個用來獲取讀鎖,一個用來獲取寫鎖。)

      當開發的應用程序處於一個分佈式的集群環境中,涉及到多節點,多進程共同完成時,如何保證線程的執行順序是正確的。比如在高併發的情況下,很多企業都會使用Nginx反向代理服務器實現負載均衡的目的,這個時候很多請求會被分配到不同的Server上,一旦這些請求涉及到對統一資源進行修改操作時,就會出現問題,這個時候在分佈式系統中就需要一個全局鎖實現多個線程(不同進程中的線程)之間的同步。

      常見的處理辦法有三種:數據庫、緩存、分佈式協調系統。數據庫和緩存是比較常用的,但是分佈式協調系統是不常用的。

      常用的分佈式鎖的實現包含:

          Redis分佈式鎖Zookeeper分佈式鎖Memcached

    基於 Redis 做分佈式鎖

     Redis提供的三種方法:

    (1)鎖 SETNX:只在鍵 key 不存在的情況下, 將鍵 key 的值設置為 value 。若鍵 key 已經存在, 則 SETNX 命令不做任何動作。SETNX 是『SET if Not eXists』(如果不存在,則 SET)的簡寫。命令在設置成功時返回 1 , 設置失敗時返回 0

    redis> SETNX job "programmer"    # job 設置成功
    (integer) 1
    
    redis> SETNX job "code-farmer"   # 嘗試覆蓋 job ,失敗
    

    (2)解鎖 DEL:刪除給定的一個或多個 key

    (3)鎖超時 EXPIRE: 為給定 key 設置生存時間,當 key 過期時(生存時間為 0 ),它會被自動刪除。

      每次當一個節點想要去操作臨界資源的時候,我們可以通過redis來的鍵值對來標記一把鎖,每一進程首先通過Redis訪問同一個key,對於每一個進程來說,如果該key不存在,則該線程可以獲取鎖,將該鍵值對寫入redis,如果存在,則說明鎖已經被其他進程所佔用。具體邏輯的偽代碼如下:

    try{
    	if(SETNX(key, 1) == 1){
    		//do something ......
    	}finally{
    	DEL(key);
    }

      但是此時,又會出現問題,因為SETNX和DEL操作並不是原子操作,如果程序在執行完SETNX后,而並沒有執行EXPIRE就已經宕機了,這樣一來,原先的問題依然存在,整個系統都將被阻塞。

      幸虧Redis又提供了SET key value timeout NX方法,可以以原子操作的方式完成SETNX和EXPIRE的操作。此時只需如下操作即可。

    try{
    	if(SET(key, 1, 30, timeout, NX) == 1){
    		//do something ......
    	}
    }finally{
    	DEL(key);
    }
    

      解決了原子操作,仍然還有一點需要注意,例如,A節點的進程獲取到鎖的時候,A進程可能執行的很慢,在do something未完成的情況下,30秒的時間片已經使用完,此時會將該key給深處掉,此時B進程發現這個key不存在,則去訪問,並成功的獲取到鎖,開始執行do something,此時A線程恰好執行到DEL(key),會將B的key刪除掉,此時相當於B線程在訪問沒有加鎖的臨界資源,而其餘進程都有機會同時去操作這個臨界資源,會造成一些錯誤的結果。對於該問題的解決辦法是進程在刪除key之前可以做一個判斷,驗證當前的鎖是不是本進程加的鎖。

    String threadId = Thread.currentThread().getId()
    try{
    	if(SET(key, threadId, 30, timeout, NX) == 1){
    		//do something ......
    	}
    }finally{
        if(threadId.equals(redisClient.get(key))){
            DEL(key);
        }
    }
    

       上面的改進雖然解決鎖被不同的進程釋放的危險,但並沒有解決獲取到鎖的進程在指定的時間內未完成do something操作(上面的代碼還有一點小問題,就是判斷操作和釋放鎖是兩個獨立的操作,不具備原子性。假設線程A判斷完確實是自己加的鎖 , 這時還沒del ,這時有效的時間用完了 , 緊接着線程B又馬上搶到了鎖 , 然後線程A才執行del命令 , 就會把B搶到的鎖給誤刪了),使得卡住的進程有可能與後來的進程同時同問臨界資源,而出現問題,因此一旦某個進程無法在超時時間內完成對臨界資源的操作,就需要延長超時的時間。此時可以啟動一個守護進程,監視指定時間內獲取鎖的進程是否完成操作,如果沒有,則添加超時時間,讓程序繼續執行。

    String threadId = Thread.currentThread().getId()
    try{
    	if(SET(key, threadId, 30, timeout, NX) == 1){
    		new Thread(){
                @Override
                public void run() {
                	//start Daemon
                }
             }
    		//do something ......
    	}
    }finally{
        if(threadId.equals(redisClient.get(key))){
            DEL(key);
        }
    }
    

      基於以上的分析,基本上可以通過Redis實現一個分佈式鎖,如果我們想提升該分佈式的性能,我們可以對連接資源進行分段處理,將請求均勻的分佈到這些臨界資源段中,比如一個買票系統,我們可以將100張票分為10 部分,每部分包含10張票放在其他的服務節點上,這些請求可以通過Nginx被均勻的分散到這些處理節點上,可以加快對臨界資源的處理。

    參考資料

    1. 併發編程的鎖機制:synchronized和lock

    2. B站視頻上一部分講解

    3. 什麼是分佈式鎖?

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※超省錢租車方案

    ※別再煩惱如何寫文案,掌握八大原則!

    ※回頭車貨運收費標準

    ※教你寫出一流的銷售文案?

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

  • 世界經濟論壇:綠色振興將可創造一年300兆元收益

    環境資訊中心綜合外電;姜唯 編譯;林大利 審校

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

    ※Google地圖已可更新顯示潭子電動車充電站設置地點!!

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※別再煩惱如何寫文案,掌握八大原則!

    網頁設計最專業,超強功能平台可客製化

  • 漢娜颶風肆虐德州 吹倒美墨邊境圍牆

    摘錄自2020年7月27日東森國際新聞美國報導

    美國德州(Texas)新冠肺炎疫情持續肆虐,周末又遭颶風漢娜(Hanna)侵襲。漢娜是今年第一個颶風,它已經摧毀許多船隻、淹沒街道、造成電力中斷、甚至吹倒一部分美墨邊境圍牆,災情相當嚴重。

    據外媒《WDSU News》報導,國家颶風中心(National Hurricane Center)表示,目前颶風漢娜已降為熱帶低氣壓,以每小時超過50英里的風速橫越美墨邊境,並在德州南部和墨西哥東北部的部分地區降下了超過300毫米的豪雨。

    州長格雷格·阿博特 (Greg Abbott)於週六(25日)在一場記者會中表示,「任何颶風都是一場巨大的挑戰,但這次的挑戰非常複雜,必且比以往更加嚴峻,因為這場颶風正在席捲著新冠肺炎的其中一個震央。」

    土地利用
    國際新聞
    美國
    颶風
    災害

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    ※想知道最厲害的網頁設計公司"嚨底家"!

    ※別再煩惱如何寫文案,掌握八大原則!

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • 東北印有權有勢「黑手黨」 藉非法伐木撕裂犀鳥棲地

    環境資訊中心綜合外電;黃鈺婷 翻譯;林大利 審校;稿源:Mongabay

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※別再煩惱如何寫文案,掌握八大原則!

    ※教你寫出一流的銷售文案?

    ※超省錢租車方案

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • 掌握SpringBoot-2.3的容器探針:實戰篇

    掌握SpringBoot-2.3的容器探針:實戰篇

    歡迎訪問我的GitHub

    https://github.com/zq2599/blog_demos

    • 內容:原創文章分類匯總,及配套源碼,涉及Java、Docker、K8S、DevOPS等
      經過多篇知識積累終於來到實戰章節,親愛的讀者們,請將裝備就位,一起動手體驗SpringBoot官方帶給我們的最新技術;

    關於《SpringBoot-2.3容器化技術》系列

    • 《SpringBoot-2.3容器化技術》系列,旨在和大家一起學習實踐2.3版本帶來的最新容器化技術,讓咱們的Java應用更加適應容器化環境,在雲計算時代依舊緊跟主流,保持競爭力;
    • 全系列文章分為主題和輔助兩部分,主題部分如下:
    1. 《體驗SpringBoot(2.3)應用製作Docker鏡像(官方方案)》;
    2. 《詳解SpringBoot(2.3)應用製作Docker鏡像(官方方案)》;
    3. 《掌握SpringBoot-2.3的容器探針:基礎篇》;
    4. 《掌握SpringBoot-2.3的容器探針:深入篇》;
    5. 《掌握SpringBoot-2.3的容器探針:實戰篇》;
    • 輔助部分是一些參考資料和備忘總結,如下:
    1. 《SpringBoot-2.3鏡像方案為什麼要做多個layer》;
    2. 《設置非root賬號不用sudo直接執行docker命令》;
    3. 《開發階段,將SpringBoot應用快速部署到K8S》;

    SpringBoot-2.3容器探針知識點小結

    經過前面的知識積累,我們知道了SpringBoot-2.3新增的探針規範以及適用場景,這裏做個簡短的回顧:

    1. kubernetes要求業務容器提供一個名為livenessProbe的地址,kubernetes會定時訪問該地址,如果該地址的返回碼不在200到400之間,kubernetes認為該容器不健康,會殺死該容器重建新的容器,這個地址就是存活探針
    2. kubernetes要求業務容器提供一個名為readinessProbe的地址,kubernetes會定時訪問該地址,如果該地址的返回碼不在200到400之間,kubernetes認為該容器無法對外提供服務,不會把請求調度到該容器,這個地址就是就緒探針
    3. SpringBoot的2.3.0.RELEASE發布了兩個新的actuator地址,/actuator/health/liveness/actuator/health/readiness,前者用作存活探針,後者用作就緒探針,這兩個地址的返回值來自兩個新增的actuator:Liveness StateReadiness State
    4. SpringBoot應用根據特殊環境變量是否存在來判定自己是否運行在容器環境,如果是,/actuator/health/liveness/actuator/health/readiness這兩個地址就有返回碼,具體的值是和應用的狀態有對應關係的,例如應用啟動過程中,/actuator/health/readiness返回503,啟動成功后返回200
    5. 業務應用可以通過Spring系統事件機制來讀取Liveness StateReadiness State,也可以訂閱這兩個actuator的變更事件;
    6. 業務應用可以通過Spring系統事件機制來修改Liveness StateReadiness State,此時/actuator/health/liveness和/actuator/health/readiness的返回值都會發生變更,從而影響kubernetes對此容器的行為(參照第一點和第二點),例如livenessProbe返回碼變成503,導致kubernetes認為容器不健康,從而殺死容器;

    小結完畢,接下來開始實打實的編碼和操作實戰,驗證上述理論;

    實戰環境信息

    本次實戰有兩個環境:開發和運行環境,其中開發環境信息如下:

    1. 操作系統:Ubuntu 20.04 LTS 桌面版
    2. CPU :2.30GHz × 4,內存:32G,硬盤:1T NVMe
    3. JDK:1.8.0_231
    4. MAVEN:3.6.3
    5. SpringBoot:2.3.0.RELEASE
    6. Docker:19.03.10
    7. 開發工具:IDEA 2020.1.1 (Ultimate Edition)

    運行環境信息如下:

    1. 操作系統:CentOS Linux release 7.8.2003
    2. Kubernetes:1.15

    事實證明,用Ubuntu桌面版作為開發環境是可行的,體驗十分順暢,IDEA、SubLime、SSH、Chrome、微信都能正常使用,下圖是我的Ubuntu開發環境:

    實戰內容簡介

    本次實戰包括以下內容:

    1. 開發SpringBoot應用,部署在kubernetes;
    2. 檢查應用狀態和kubernetes的pod狀態的關聯變化;
    3. 修改Readiness State,看kubernetes是否還會把請求調度到pod;
    4. 修改Liveness State,看kubernetes會不是殺死pod;

    源碼下載

    1. 本次實戰用到了一個普通的SpringBoot工程,源碼可在GitHub下載到,地址和鏈接信息如下錶所示(https://github.com/zq2599/blog_demos):
    名稱 鏈接 備註
    項目主頁 https://github.com/zq2599/blog_demos 該項目在GitHub上的主頁
    git倉庫地址(https) https://github.com/zq2599/blog_demos.git 該項目源碼的倉庫地址,https協議
    git倉庫地址(ssh) git@github.com:zq2599/blog_demos.git 該項目源碼的倉庫地址,ssh協議
    1. 這個git項目中有多個文件夾,本章的應用在probedemo文件夾下,如下圖紅框所示:

    開發SpringBoot應用

    1. 請在IDEA上安裝lombok插件:
    1. 在IDEA上新建名為probedemo的SpringBoot工程,版本選擇2.3.0
    1. 該工程的pom.xml內容如下,注意要有spring-boot-starter-actuatorlombok依賴,另外插件spring-boot-maven-plugin也要增加layers節點:
    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.3.0.RELEASE</version>
            <relativePath/> <!-- lookup parent from repository -->
        </parent>
        <groupId>com.bolingcavalry</groupId>
        <artifactId>probedemo</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>probedemo</name>
        <description>Demo project for Spring Boot</description>
    
        <properties>
            <java.version>1.8</java.version>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
                <exclusions>
                    <exclusion>
                        <groupId>org.junit.vintage</groupId>
                        <artifactId>junit-vintage-engine</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-actuator</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <version>2.3.0.RELEASE</version>
                    <!--該配置會在jar中增加layer描述文件,以及提取layer的工具-->
                    <configuration>
                        <layers>
                            <enabled>true</enabled>
                        </layers>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </project>
    
    1. 應用啟動類ProbedemoApplication是個最普通的啟動類:
    package com.bolingcavalry.probedemo;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    
    @SpringBootApplication
    public class ProbedemoApplication {
        public static void main(String[] args) {
            SpringApplication.run(ProbedemoApplication.class, args);
        }
    }
    
    1. 增加一個監聽類,可以監聽存活和就緒狀態的變化:
    package com.bolingcavalry.probedemo.listener;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.boot.availability.AvailabilityChangeEvent;
    import org.springframework.boot.availability.AvailabilityState;
    import org.springframework.context.event.EventListener;
    import org.springframework.stereotype.Component;
    
    /**
     * description: 監聽系統事件的類 <br>
     * date: 2020/6/4 下午12:57 <br>
     * author: willzhao <br>
     * email: zq2599@gmail.com <br>
     * version: 1.0 <br>
     */
    @Component
    @Slf4j
    public class AvailabilityListener {
    
        /**
         * 監聽系統消息,
         * AvailabilityChangeEvent類型的消息都從會觸發此方法被回調
         * @param event
         */
        @EventListener
        public void onStateChange(AvailabilityChangeEvent<? extends AvailabilityState> event) {
            log.info(event.getState().getClass().getSimpleName() + " : " + event.getState());
        }
    }
    
    1. 增加名為StateReader的Controller的Controller,用於獲取存活和就緒狀態:
    package com.bolingcavalry.probedemo.controller;
    
    import org.springframework.boot.availability.ApplicationAvailability;
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    import javax.annotation.Resource;
    import java.util.Date;
    
    @RestController
    @RequestMapping("/statereader")
    public class StateReader {
    
        @Resource
        ApplicationAvailability applicationAvailability;
    
        @RequestMapping(value="/get")
        public String state() {
            return "livenessState : " + applicationAvailability.getLivenessState()
                   + "<br>readinessState : " + applicationAvailability.getReadinessState()
                   + "<br>" + new Date();
        }
    }
    
    1. 增加名為StateWritter的Controller,用於設置存活和就緒狀態:
    package com.bolingcavalry.probedemo.controller;
    
    import org.springframework.boot.availability.AvailabilityChangeEvent;
    import org.springframework.boot.availability.LivenessState;
    import org.springframework.boot.availability.ReadinessState;
    import org.springframework.context.ApplicationEventPublisher;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    import java.util.Date;
    
    /**
     * description: 修改狀態的controller <br>
     * date: 2020/6/4 下午1:21 <br>
     * author: willzhao <br>
     * email: zq2599@gmail.com <br>
     * version: 1.0 <br>
     */
    @RestController
    @RequestMapping("/staterwriter")
    public class StateWritter {
    
        @Resource
        ApplicationEventPublisher applicationEventPublisher;
    
        /**
         * 將存活狀態改為BROKEN(會導致kubernetes殺死pod)
         * @return
         */
        @RequestMapping(value="/broken")
        public String broken(){
            AvailabilityChangeEvent.publish(applicationEventPublisher, StateWritter.this, LivenessState.BROKEN);
            return "success broken, " + new Date();
        }
    
        /**
         * 將存活狀態改為CORRECT
         * @return
         */
        @RequestMapping(value="/correct")
        public String correct(){
            AvailabilityChangeEvent.publish(applicationEventPublisher, StateWritter.this, LivenessState.CORRECT);
            return "success correct, " + new Date();
        }
    
        /**
         * 將就緒狀態改為REFUSING_TRAFFIC(導致kubernetes不再把外部請求轉發到此pod)
         * @return
         */
        @RequestMapping(value="/refuse")
        public String refuse(){
            AvailabilityChangeEvent.publish(applicationEventPublisher, StateWritter.this, ReadinessState.REFUSING_TRAFFIC);
            return "success refuse, " + new Date();
        }
    
        /**
         * 將就緒狀態改為ACCEPTING_TRAFFIC(導致kubernetes會把外部請求轉發到此pod)
         * @return
         */
        @RequestMapping(value="/accept")
        public String accept(){
            AvailabilityChangeEvent.publish(applicationEventPublisher, StateWritter.this, ReadinessState.ACCEPTING_TRAFFIC);
            return "success accept, " + new Date();
        }
    
    }
    
    1. 增加名為Hello的controller,此接口能返回當前pod的IP地址,在後面測試時會用到:
    package com.bolingcavalry.probedemo.controller;
    
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.net.Inet4Address;
    import java.net.InetAddress;
    import java.net.NetworkInterface;
    import java.net.SocketException;
    import java.util.ArrayList;
    import java.util.Date;
    import java.util.Enumeration;
    import java.util.List;
    
    /**
     * description: hello demo <br>
     * date: 2020/6/4 下午4:38 <br>
     * author: willzhao <br>
     * email: zq2599@gmail.com <br>
     * version: 1.0 <br>
     */
    @RestController
    public class Hello {
    
        /**
         * 返回的是當前服務器IP地址,在k8s環境就是pod地址
         * @return
         * @throws SocketException
         */
        @RequestMapping(value="/hello")
        public String hello() throws SocketException {
            List<Inet4Address> addresses = getLocalIp4AddressFromNetworkInterface();
            if(null==addresses || addresses.isEmpty()) {
                return  "empty ip address, " + new Date();
            }
    
            return addresses.get(0).toString() + ", " + new Date();
        }
    
        public static List<Inet4Address> getLocalIp4AddressFromNetworkInterface() throws SocketException {
            List<Inet4Address> addresses = new ArrayList<>(1);
            Enumeration e = NetworkInterface.getNetworkInterfaces();
            if (e == null) {
                return addresses;
            }
            while (e.hasMoreElements()) {
                NetworkInterface n = (NetworkInterface) e.nextElement();
                if (!isValidInterface(n)) {
                    continue;
                }
                Enumeration ee = n.getInetAddresses();
                while (ee.hasMoreElements()) {
                    InetAddress i = (InetAddress) ee.nextElement();
                    if (isValidAddress(i)) {
                        addresses.add((Inet4Address) i);
                    }
                }
            }
            return addresses;
        }
    
        /**
         * 過濾迴環網卡、點對點網卡、非活動網卡、虛擬網卡並要求網卡名字是eth或ens開頭
         * @param ni 網卡
         * @return 如果滿足要求則true,否則false
         */
        private static boolean isValidInterface(NetworkInterface ni) throws SocketException {
            return !ni.isLoopback() && !ni.isPointToPoint() && ni.isUp() && !ni.isVirtual()
                    && (ni.getName().startsWith("eth") || ni.getName().startsWith("ens"));
        }
    
        /**
         * 判斷是否是IPv4,並且內網地址並過濾迴環地址.
         */
        private static boolean isValidAddress(InetAddress address) {
            return address instanceof Inet4Address && address.isSiteLocalAddress() && !address.isLoopbackAddress();
        }
    }
    

    以上就是該SpringBoot工程的所有代碼了,請確保可以編譯運行;

    製作Docker鏡像

    1. 在pom.xml所在目錄創建文件Dockerfile,內容如下:
    # 指定基礎鏡像,這是分階段構建的前期階段
    FROM openjdk:8u212-jdk-stretch as builder
    # 執行工作目錄
    WORKDIR application
    # 配置參數
    ARG JAR_FILE=target/*.jar
    # 將編譯構建得到的jar文件複製到鏡像空間中
    COPY ${JAR_FILE} application.jar
    # 通過工具spring-boot-jarmode-layertools從application.jar中提取拆分后的構建結果
    RUN java -Djarmode=layertools -jar application.jar extract
    
    # 正式構建鏡像
    FROM openjdk:8u212-jdk-stretch
    WORKDIR application
    # 前一階段從jar中提取除了多個文件,這裏分別執行COPY命令複製到鏡像空間中,每次COPY都是一個layer
    COPY --from=builder application/dependencies/ ./
    COPY --from=builder application/spring-boot-loader/ ./
    COPY --from=builder application/snapshot-dependencies/ ./
    COPY --from=builder application/application/ ./
    ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
    
    1. 先編譯構建工程,執行以下命令:
    mvn clean package -U -DskipTests 
    
    1. 編譯成功后,通過Dockerfile文件創建鏡像:
    sudo docker build -t bolingcavalry/probedemo:0.0.1 .
    
    1. 鏡像創建成功:

    SpringBoot的鏡像準備完畢,接下來要讓kubernetes環境用上這個鏡像;

    將鏡像加載到kubernetes環境

    此時的鏡像保存在開發環境的電腦上,可以有以下三種方式加載到kubernetes環境:

    1. push到私有倉庫,kubernetes上使用時也從私有倉庫獲取;
    2. push到hub.docker.com,kubernetes上使用時也從hub.docker.com獲取,目前我已經將此鏡像push到hub.docker.com,您在kubernetes直接使用即可,就像nginx、tomcat這些官方鏡像一樣下載;
    3. 在開發環境執行docker save bolingcavalry/probedemo:0.0.1 > probedemo.tar,可將此鏡像另存為本地文件,再scp到kubernetes服務器,再在kubernetes服務器執行docker load < /root/temp/202006/04/probedemo.tar就能加載到kubernetes服務器的本地docker緩存中;

    以上三種方法的優缺點整理如下:

    1. 首推第一種,但是需要您搭建私有倉庫;
    2. 由於springboot-2.3官方對鏡像構建作了優化,第二種方法也就執行第一次的時候上傳和下載很耗時,之後修改java代碼重新構建時,不論上傳還是下載都很快(只上傳下載某個layer);
    3. 在開發階段,使用第三種方法最為便捷,但如果kubernetes環境有多台機器,就不合適了,因為鏡像是存在指定機器的本地緩存的;

    我的kubernetes環境只有一台電腦,因此用的是方法三,參考命令如下(建議安裝sshpass,就不用每次輸入帳號密碼了):

    # 將鏡像保存為tar文件
    sudo docker save bolingcavalry/probedemo:0.0.1 > probedemo.tar
    
    # scp到kubernetes服務器
    sshpass -p 888888 scp ./probedemo.tar root@192.168.50.135:/root/temp/202006/04/ 
      
    # 遠程執行ssh命令,加載docker鏡像
    sshpass -p 888888 ssh root@192.168.50.135 "docker load < /root/temp/202006/04/probedemo.tar"
    

    kubernetes部署deployment和service

    1. 在kubernetes創建名為probedemo.yaml的文件,內容如下,注意pod副本數是2,另外請關注livenessProbe和readinessProbe的參數配置:
    apiVersion: v1
    kind: Service
    metadata:
      name: probedemo
    spec:
      type: NodePort
      ports:
        - port: 8080
          nodePort: 30080
      selector:
        name: probedemo
    ---
    apiVersion: extensions/v1beta1
    kind: Deployment
    metadata:
      name: probedemo
    spec:
      replicas: 2
      template:
        metadata:
          labels:
            name: probedemo
        spec:
          containers:
            - name: probedemo
              image: bolingcavalry/probedemo:0.0.1
              tty: true
              livenessProbe:
                httpGet:
                  path: /actuator/health/liveness
                  port: 8080
                initialDelaySeconds: 5
                failureThreshold: 10
                timeoutSeconds: 10
                periodSeconds: 5
              readinessProbe:
                httpGet:
                  path: /actuator/health/readiness
                  port: 8080
                initialDelaySeconds: 5
                timeoutSeconds: 10
                periodSeconds: 5
              ports:
                - containerPort: 8080
              resources:
                requests:
                  memory: "512Mi"
                  cpu: "100m"
                limits:
                  memory: "1Gi"
                  cpu: "500m"
    
    1. 執行命令kubectl apply -f probedemo..yaml,即可創建deployment和service:
    1. 這裏要重點關注的是livenessProbeinitialDelaySecondsfailureThreshold參數,initialDelaySeconds等於5,表示pod創建5秒后檢查存活探針,如果10秒內應用沒有完成啟動,存活探針不返回200,就會重試10次(failureThreshold等於10),如果重試10次后存活探針依舊無法返回200,該pod就會被kubernetes殺死重建,要是每次啟動都耗時這麼長,pod就會不停的被殺死重建;
    2. 執行命令kubectl apply -f probedemo.yaml,創建deployment和service,如下圖,可見在第十秒的時候pod創建成功,但是此時還未就緒:
    1. 繼續查看狀態,創建一分鐘后兩個pod終於就緒:
    1. kubectl describe命令查看pod狀態,事件通知显示存活和就緒探針都有失敗情況,不過因為有重試,因此後來狀態會變為成功:

    至此,從編碼到部署都完成了,接下來驗證SpringBoot-2.3.0.RELEASE的探針技術;

    驗證SpringBoot-2.3.0.RELEASE的探針技術

    1. 監聽類AvailabilityListener的作用是監聽狀態變化,看看pod日誌,看AvailabilityListener的代碼是否有效,如下圖紅框,在應用啟動階段AvailabilityListener被成功回調,打印了存活和就緒狀態:
    1. kubernetes所在機器的IP地址是192.168.50.135,因此SpringBoot服務的訪問地址是http://192.168.50.135:30080/xxx

    2. 訪問地址http://192.168.50.135:30080/actuator/health/liveness,返回碼如下圖紅框,可見存活探針已開啟:

    1. 就緒探針也正常:
    1. 打開兩個瀏覽器,都訪問:http://192.168.50.135:30080/hello,多次Ctrl+F5強刷,如下圖,很快就能得到不同結果,證明響應來自不同的Pod:
    1. 訪問:http://192.168.50.135:30080/statereader/get,可以得到存活和就緒的狀態,可見StateReader的代碼已經生效,可以通過ApplicationAvailability接口取得狀態:
    1. 修改就緒狀態,訪問:http://192.168.50.135:30080/statewriter/refuse,如下圖紅框,可見收到請求的pod,其就緒狀態已經出現了異常,證明StateWritter.java中修改就緒狀態后,可以讓kubernetes感知到這個pod的異常
    1. 用瀏覽器反覆強刷hello接口,返回的Pod地址也只有一個,證明只有一個Pod在響應請求:
    1. 嘗試恢復服務,注意請求要在服務器後台發送,而且IP地址要用剛才被設置為refuse的pod地址
    curl http://10.233.90.195:8080/statewriter/accept
    
    1. 如下圖,狀態已經恢復:
    1. 最後再來試試將存活狀態從CORRECT改成BROKEN,瀏覽器訪問:http://192.168.50.135:30080/statewriter/broken
    2. 如下圖紅框,重啟次數變成1,表示pod被殺死了一次,並且由於重啟導致當前還未就緒,證明在SpringBoot中修改了存活探針的狀態,是會觸發kubernetes殺死pod的
    1. 等待pod重啟、就緒探針正常后,一切恢復如初:
    1. 強刷瀏覽器,如下圖紅框,兩個Pod都能正常響應:

    官方忠告

    • 至此,《掌握SpringBoot-2.3的容器探針》系列就全部完成了,從理論到實踐,咱們一起學習了SpringBoot官方帶給我們的容器化技術,最後以一段官方忠告來結尾,大家一起將此忠告牢記在心:
    • 我對以上內容的理解:選擇外部系統的服務作為探針的時候要謹慎(外部系統可能是數據庫,也可能是其他web服務),如果外部系統出現問題,會導致kubernetes殺死pod(存活探針問題),或者導致kubernetes不再調度請求到pod(就緒探針問題);(再請感謝大家容忍我的英語水平)

    歡迎關注我的公眾號:程序員欣宸

    https://github.com/zq2599/blog_demos

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

    ※Google地圖已可更新顯示潭子電動車充電站設置地點!!

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※別再煩惱如何寫文案,掌握八大原則!

    網頁設計最專業,超強功能平台可客製化

  • 自動擋!大空間!ESP!這款不到10萬的SUV值得考慮

    自動擋!大空間!ESP!這款不到10萬的SUV值得考慮

    所以在10萬元以內的購車預算,選擇一台國產車很划算,不僅配置高、而且車身尺寸更大,看起來更大氣。作為一款擁有超高性價比的家用SUV車型,森雅R7自動擋順應了那些注重家庭,銳意進取,年輕時尚且懂得享受高品質生活的人群需求,剛上市首月就有1萬多的訂單也足以說明它具備成為熱門車型的潛力,而這一次上市的自動擋車型,更能進一步豐富了車型的產品線,而對小型SUV市場來說,又是一次強有力的衝擊。

    隨着我們生活質量的不停提高,人們對於購車的需求越來越強烈,而今已經有越來越多的消費者將購車的計劃擺在了首位。來總結一下我國消費者的購車需求。目前汽車還算得上是一件奢飾品,很多人將買車當成一件漲面子的事情,所以人們買車都喜歡選擇一些尺寸大、顏值高、配置高的車子。

    SUV之所以流行,除了它本身擁有高底盤高通過性的優勢以外,假如SUV和轎車的尺寸相差不大,兩者中SUV看起來更高檔次!消費者在選車時,除了看臉,內在也很重要,一個好的內飾、一堆逆天的配置更能吸引到消費者的關注。說實話,為什麼這麼多人在10萬以內都傾向於選擇國產車?因為合資車配置車型都太落後了呀!所以在10萬元以內的購車預算,選擇一台國產車很划算,不僅配置高、而且車身尺寸更大,看起來更大氣!

    作為一款擁有超高性價比的家用SUV車型,森雅R7自動擋順應了那些注重家庭,銳意進取,年輕時尚且懂得享受高品質生活的人群需求,剛上市首月就有1萬多的訂單也足以說明它具備成為熱門車型的潛力,而這一次上市的自動擋車型,更能進一步豐富了車型的產品線,而對小型SUV市場來說,又是一次強有力的衝擊。而森雅R7自動擋車型目前有舒適型、豪華型、智能型、尊貴型,售價7.89~9.29萬之間。為了滿足用戶的更高需求,還將推出“森雅R7 AT版 旗艦型”,售價 9.99萬元。本站聲明:網站內容來源於http://www.auto6s.com/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

    ※Google地圖已可更新顯示潭子電動車充電站設置地點!!

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※別再煩惱如何寫文案,掌握八大原則!

    網頁設計最專業,超強功能平台可客製化

  • 比老司機更會玩の日常

    比老司機更會玩の日常

    com/x/page/u0353zzxlol。html)據傳,這就是新TIIDA車主的日常。果然“城會玩”,待俺去充個“會員”,咱一起飛。溫馨提示:點擊閱讀原文,預約試駕,馬上成為“會員”。(閱讀原文鏈接如下:http://www。dongfeng-nissan。com。cn/Nissan/car/tiida)。

    話說,一群老司機聚在一起能幹什麼?

    吃吃?

    喝喝?

    騷年,敢不敢幹一票“大”的?

    ↓↓↓↓↓

    (視頻鏈接如下:https://v.qq.com/x/page/u0353zzxlol.html)

    據傳,

    這就是新TIIDA車主的日常。

    果然“城會玩”,

    待俺去充個“會員”,

    咱一起飛。

    溫馨提示:點擊閱讀原文,預約試駕,馬上成為“會員”。

    (閱讀原文鏈接如下:http://www.dongfeng-nissan.com.cn/Nissan/car/tiida)本站聲明:網站內容來源於http://www.auto6s.com/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    ※想知道最厲害的網頁設計公司"嚨底家"!

    ※別再煩惱如何寫文案,掌握八大原則!

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!