【MyBatis源碼全面解析】MyBatis一二級(jí)緩存介紹
MyBatis緩存
我們知道,頻繁的數(shù)據(jù)庫操作是非常耗費(fèi)性能的(主要是因?yàn)閷?duì)于DB而言,數(shù)據(jù)是持久化在磁盤中的,因此查詢操作需要通過IO,IO操作速度相比內(nèi)存操作速度慢了好幾個(gè)量級(jí)),尤其是對(duì)于一些相同的查詢語句,完全可以把查詢結(jié)果存儲(chǔ)起來,下次查詢同樣的內(nèi)容的時(shí)候直接從內(nèi)存中獲取數(shù)據(jù)即可,這樣在某些場(chǎng)景下可以大大提升查詢效率。
MyBatis的緩存分為兩種:
一級(jí)緩存,一級(jí)緩存是SqlSession級(jí)別的緩存,對(duì)于相同的查詢,會(huì)從緩存中返回結(jié)果而不是查詢數(shù)據(jù)庫
二級(jí)緩存,二級(jí)緩存是Mapper級(jí)別的緩存,定義在Mapper文件的<cache>標(biāo)簽中并需要開啟此緩存,多個(gè)Mapper文件可以共用一個(gè)緩存,依賴<cache-ref>標(biāo)簽配置
下面來詳細(xì)看一下MyBatis的一二級(jí)緩存。
MyBatis一級(jí)緩存工作流程
接著看一下MyBatis一級(jí)緩存工作流程。前面說了,MyBatis的一級(jí)緩存是SqlSession級(jí)別的緩存,當(dāng)openSession()的方法運(yùn)行完畢或者主動(dòng)調(diào)用了SqlSession的close方法,SqlSession就被回收了,一級(jí)緩存與此同時(shí)也一起被回收掉了。前面的文章有說過,在MyBatis中,無論selectOne還是selectList方法,最終都被轉(zhuǎn)換為了selectList方法來執(zhí)行,那么看一下SqlSession的selectList方法的實(shí)現(xiàn):
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
try {
MappedStatement ms = configuration.getMappedStatement(statement);
return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
繼續(xù)跟蹤第4行的代碼,到BaseExeccutor的query方法:
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
第3行構(gòu)建緩存條件CacheKey,這里涉及到怎么樣條件算是和上一次查詢是同一個(gè)條件的一個(gè)問題,因?yàn)橥粋€(gè)條件就可以返回上一次的結(jié)果回去,這部分代碼留在下一部分分析。
接著看第4行的query方法的實(shí)現(xiàn),代碼位于CachingExecutor中:
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
Cache cache = ms.getCache();
if (cache != null) {
flushCacheIfRequired(ms);
if (ms.isUseCache() && resultHandler == null) {
ensureNoOutParams(ms, parameterObject, boundSql);
@SuppressWarnings("unchecked")
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
}
}
return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
第3行~第16行的代碼先不管,繼續(xù)跟第17行的query方法,代碼位于BaseExecutor中:
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
if (closed) {
throw new ExecutorException("Executor was closed.");
}
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
List<E> list;
try {
queryStack++;
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
...
}
看12行,query的時(shí)候會(huì)嘗試從localCache中去獲取查詢結(jié)果,如果獲取到的查詢結(jié)果為null,那么執(zhí)行16行的代碼從DB中撈數(shù)據(jù),撈完之后會(huì)把CacheKey作為key,把查詢結(jié)果作為value放到localCache中。
MyBatis一級(jí)緩存存儲(chǔ)流程看完了,接著我們從這段代碼中可以得到三個(gè)結(jié)論:
1、MyBatis的一級(jí)緩存是SqlSession級(jí)別的,但是它并不定義在SqlSessio接口的實(shí)現(xiàn)類DefaultSqlSession中,而是定義在DefaultSqlSession的成員變量Executor中,Executor是在openSession的時(shí)候被實(shí)例化出來的,它的默認(rèn)實(shí)現(xiàn)為SimpleExecutor
2、MyBatis中的一級(jí)緩存,與有沒有配置無關(guān),只要SqlSession存在,MyBastis一級(jí)緩存就存在,localCache的類型是PerpetualCache,它其實(shí)很簡(jiǎn)單,一個(gè)id屬性+一個(gè)HashMap屬性而已,id是一個(gè)名為"localCache"的字符串,HashMap用于存儲(chǔ)數(shù)據(jù),Key為CacheKey,Value為查詢結(jié)果
3、MyBatis的一級(jí)緩存查詢的時(shí)候默認(rèn)都是會(huì)先嘗試從一級(jí)緩存中獲取數(shù)據(jù)的,但是我們看第6行的代碼做了一個(gè)判斷,ms.isFlushCacheRequired(),即想每次查詢都走DB也行,將<select>標(biāo)簽中的flushCache屬性設(shè)置為true即可,這意味著每次查詢的時(shí)候都會(huì)清理一遍PerpetualCache,PerpetualCache中沒數(shù)據(jù),自然只能走DB
從MyBatis一級(jí)緩存來看,它以單純的HashMap做緩存,沒有容量控制,而一次SqlSession中通常來說并不會(huì)有大量的查詢操作,因此只適用于一次SqlSession,如果用到二級(jí)緩存的Mapper級(jí)別的場(chǎng)景,有可能緩存數(shù)據(jù)不斷碰到而導(dǎo)致內(nèi)存溢出。
還有一點(diǎn),差點(diǎn)忘了寫了,<insert>、<delete>、<update>最終都會(huì)轉(zhuǎn)換為update方法,看一下BaseExecutor的update方法:
public int update(MappedStatement ms, Object parameter) throws SQLException {
ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
if (closed) {
throw new ExecutorException("Executor was closed.");
}
clearLocalCache();
return doUpdate(ms, parameter);
}
第6行clearLocalCache()方法,這意味著所有的增、刪、改都會(huì)清空本地緩存,這和是否配置了flushCache=true是無關(guān)的。
這很好理解,因?yàn)樵?、刪、改這三種操作都可能會(huì)導(dǎo)致查詢出來的結(jié)果并不是原來的結(jié)果,如果增、刪、改不清理緩存,那么可能導(dǎo)致讀取出來的數(shù)據(jù)是臟數(shù)據(jù)。
一級(jí)緩存的CacheKey
接著我們看下一個(gè)問題:怎么樣的查詢條件算和上一次查詢是一樣的查詢,從而返回同樣的結(jié)果回去?這個(gè)問題,得從CacheKey說起。
我們先看一下CacheKey的數(shù)據(jù)結(jié)構(gòu):
public class CacheKey implements Cloneable, Serializable {
private static final long serialVersionUID = 1146682552656046210L;
public static final CacheKey NULL_CACHE_KEY = new NullCacheKey();
private static final int DEFAULT_MULTIPLYER = 37;
private static final int DEFAULT_HASHCODE = 17;
private int multiplier;
private int hashcode;
private long checksum;
private int count;
private List<Object> updateList;
...
}
其中最重要的是第14行的updateList這個(gè)兩個(gè)屬性,為什么這么說,因?yàn)镠ashMap的Key是CacheKey,而HashMap的get方法是先判斷hashCode,在hashCode沖突的情況下再進(jìn)行equals判斷,因此最終無論如何都會(huì)進(jìn)行一次equals的判斷,看下equals方法的實(shí)現(xiàn):
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof CacheKey)) {
return false;
}
final CacheKey cacheKey = (CacheKey) object;
if (hashcode != cacheKey.hashcode) {
return false;
}
if (checksum != cacheKey.checksum) {
return false;
}
if (count != cacheKey.count) {
return false;
}
for (int i = 0; i < updateList.size(); i++) {
Object thisObject = updateList.get(i);
Object thatObject = cacheKey.updateList.get(i);
if (thisObject == null) {
if (thatObject != null) {
return false;
}
} else {
if (!thisObject.equals(thatObject)) {
return false;
}
}
}
return true;
}
看到整個(gè)方法的流程都是圍繞著updateList中的每個(gè)屬性進(jìn)行逐一比較,因此再進(jìn)一步的,我們要看一下updateList中到底存儲(chǔ)了什么。
關(guān)于updateList里面存儲(chǔ)的數(shù)據(jù)我們可以看下哪里使用了updateList的add方法,然后一步一步反推回去即可。updateList中數(shù)據(jù)的添加是在doUpdate方法中:
private void doUpdate(Object object) {
int baseHashCode = object == null ? 1 : object.hashCode();
count++;
checksum += baseHashCode;
baseHashCode *= count;
hashcode = multiplier * hashcode + baseHashCode;
updateList.add(object);
}
它的調(diào)用方為update方法:
public void update(Object object) {
if (object != null && object.getClass().isArray()) {
int length = Array.getLength(object);
for (int i = 0; i < length; i++) {
Object element = Array.get(object, i);
doUpdate(element);
}
} else {
doUpdate(object);
}
}
這里主要是對(duì)輸入?yún)?shù)是數(shù)組類型進(jìn)行了一次判斷,是數(shù)組就遍歷逐一做doUpdate,否則就直接做doUpdate。再看update方法的調(diào)用方,其實(shí)update方法的調(diào)用方有挺多處,但是這里我們要看的是Executor中的,看一下BaseExecutor中的createCacheKey方法實(shí)現(xiàn):
... CacheKey cacheKey = new CacheKey(); cacheKey.update(ms.getId()); cacheKey.update(rowBounds.getOffset()); cacheKey.update(rowBounds.getLimit()); cacheKey.update(boundSql.getSql()); ...
到了這里應(yīng)當(dāng)一目了然了,MyBastis從三個(gè)維度判斷兩次查詢是相同的:
1、<select>標(biāo)簽所在的Mapper的Namespace+<select>標(biāo)簽的id屬性
2、RowBounds的offset和limit屬性,RowBounds是MyBatis用于處理分頁的一個(gè)類,offset默認(rèn)為0,limit默認(rèn)為Integer.MAX_VALUE
3、<select>標(biāo)簽中定義的sql語句
即只要兩次查詢滿足以上三個(gè)條件且沒有定義flushCache="true",那么第二次查詢會(huì)直接從MyBatis一級(jí)緩存PerpetualCache中返回?cái)?shù)據(jù),而不會(huì)走DB。
MyBatis二級(jí)緩存
上面說完了MyBatis,接著看一下MyBatis二級(jí)緩存,還是從二級(jí)緩存工作流程開始。還是從DefaultSqlSession的selectList方法進(jìn)去:
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
try {
MappedStatement ms = configuration.getMappedStatement(statement);
return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
執(zhí)行query方法,方法位于CachingExecutor中:
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameterObject);
CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
繼續(xù)跟第4行的query方法,同樣位于CachingExecutor中:
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
Cache cache = ms.getCache();
if (cache != null) {
flushCacheIfRequired(ms);
if (ms.isUseCache() && resultHandler == null) {
ensureNoOutParams(ms, parameterObject, boundSql);
@SuppressWarnings("unchecked")
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
}
}
return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
從這里看到,執(zhí)行第17行的BaseExecutor的query方法之前,會(huì)先拿Mybatis二級(jí)緩存,而BaseExecutor的query方法會(huì)優(yōu)先讀取MyBatis一級(jí)緩存,由此可以得出一個(gè)重要結(jié)論:假如定義了MyBatis二級(jí)緩存,那么MyBatis二級(jí)緩存讀取優(yōu)先級(jí)高于MyBatis一級(jí)緩存。
而第3行~第16行的邏輯:
1、第5行的方法很好理解,根據(jù)flushCache=true或者flushCache=false判斷是否要清理二級(jí)緩存
2、第7行的方法是保證MyBatis二級(jí)緩存不會(huì)存儲(chǔ)存儲(chǔ)過程的結(jié)果
3、第9行的方法先嘗試從tcm中獲取查詢結(jié)果,這個(gè)tcm解釋一下,這又是一個(gè)裝飾器模式(數(shù)數(shù)MyBatis用到了多少裝飾器模式了),創(chuàng)建一個(gè)事物緩存TranactionalCache,持有Cache接口,Cache接口的實(shí)現(xiàn)類就是根據(jù)我們?cè)贛apper文件中配置的<cache>創(chuàng)建的Cache實(shí)例
4、第10行~第12行,如果沒有從MyBatis二級(jí)緩存中拿到數(shù)據(jù),那么就會(huì)查一次數(shù)據(jù)庫,然后放到MyBatis二級(jí)緩存中去
至于如何判定上次查詢和這次查詢是一次查詢?由于這里的CacheKey和MyBatis一級(jí)緩存使用的是同一個(gè)CacheKey,因此它的判定條件和前文寫過的MyBatis一級(jí)緩存三個(gè)維度的判定條件是一致的。
最后再來談一點(diǎn),"Cache cache = ms.getCache()"這句代碼十分重要,這意味著Cache是從MappedStatement中獲取到的,而MappedStatement又和每一個(gè)<insert>、<delete>、<update>、<select>綁定并在MyBatis啟動(dòng)的時(shí)候存入Configuration中:
protected final Map<String, MappedStatement> mappedStatements = new StrictMap<MappedStatement>("Mapped Statements collection");
因此MyBatis二級(jí)緩存的生命周期即整個(gè)應(yīng)用的生命周期,應(yīng)用不結(jié)束,定義的二級(jí)緩存都會(huì)存在在內(nèi)存中。
從這個(gè)角度考慮,為了避免MyBatis二級(jí)緩存中數(shù)據(jù)量過大導(dǎo)致內(nèi)存溢出,MyBatis在配置文件中給我們?cè)黾恿撕芏嗯渲美鐂ize(緩存大小)、flushInterval(緩存清理時(shí)間間隔)、eviction(數(shù)據(jù)淘汰算法)來保證緩存中存儲(chǔ)的數(shù)據(jù)不至于太過龐大。
MyBatis二級(jí)緩存實(shí)例化過程
接著看一下MyBatis二級(jí)緩存<cache>實(shí)例化的過程,代碼位于XmlMapperBuilder的cacheElement方法中:
private void cacheElement(XNode context) throws Exception {
if (context != null) {
String type = context.getStringAttribute("type", "PERPETUAL");
Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
String eviction = context.getStringAttribute("eviction", "LRU");
Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
Long flushInterval = context.getLongAttribute("flushInterval");
Integer size = context.getIntAttribute("size");
boolean readWrite = !context.getBooleanAttribute("readOnly", false);
boolean blocking = context.getBooleanAttribute("blocking", false);
Properties props = context.getChildrenAsProperties();
builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
}
}
這里分別取<cache>中配置的各個(gè)屬性,關(guān)注一下兩個(gè)默認(rèn)值:
1、type表示緩存實(shí)現(xiàn),默認(rèn)是PERPETUAL,根據(jù)typeAliasRegistry中注冊(cè)的,PERPETUAL實(shí)際對(duì)應(yīng)PerpetualCache,這和MyBatis一級(jí)緩存是一致的
2、eviction表示淘汰算法,默認(rèn)是LRU算法
第3行~第11行拿到了所有屬性,那么調(diào)用12行的useNewCache方法創(chuàng)建緩存:
public Cache useNewCache(Class<? extends Cache> typeClass,
Class<? extends Cache> evictionClass,
Long flushInterval,
Integer size,
boolean readWrite,
boolean blocking,
Properties props) {
Cache cache = new CacheBuilder(currentNamespace)
.implementation(valueOrDefault(typeClass, PerpetualCache.class))
.addDecorator(valueOrDefault(evictionClass, LruCache.class))
.clearInterval(flushInterval)
.size(size)
.readWrite(readWrite)
.blocking(blocking)
.properties(props)
.build();
configuration.addCache(cache);
currentCache = cache;
return cache;
}
這里又使用了建造者模式,跟一下第16行的build()方法,在此之前該傳入的參數(shù)都已經(jīng)傳入了CacheBuilder:
public Cache build() {
setDefaultImplementations();
Cache cache = newBaseCacheInstance(implementation, id);
setCacheProperties(cache);
// issue #352, do not apply decorators to custom caches
if (PerpetualCache.class.equals(cache.getClass())) {
for (Class<? extends Cache> decorator : decorators) {
cache = newCacheDecoratorInstance(decorator, cache);
setCacheProperties(cache);
}
cache = setStandardDecorators(cache);
} else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
cache = new LoggingCache(cache);
}
return cache;
}
第3行的代碼,構(gòu)建基礎(chǔ)的緩存,implementation指的是type配置的值,這里是默認(rèn)的PerpetualCache。
第6行的代碼,如果是PerpetualCache,那么繼續(xù)裝飾(又是裝飾器模式,可以數(shù)數(shù)這幾篇MyBatis源碼解析的文章里面出現(xiàn)了多少次裝飾器模式了),這里的裝飾是根據(jù)eviction進(jìn)行裝飾,到這一步,給PerpetualCache加上了LRU的功能。
第11行的代碼,繼續(xù)裝飾,這次MyBatis將它命名為標(biāo)準(zhǔn)裝飾,setStandardDecorators方法實(shí)現(xiàn)為:
private Cache setStandardDecorators(Cache cache) {
try {
MetaObject metaCache = SystemMetaObject.forObject(cache);
if (size != null && metaCache.hasSetter("size")) {
metaCache.setValue("size", size);
}
if (clearInterval != null) {
cache = new ScheduledCache(cache);
((ScheduledCache) cache).setClearInterval(clearInterval);
}
if (readWrite) {
cache = new SerializedCache(cache);
}
cache = new LoggingCache(cache);
cache = new SynchronizedCache(cache);
if (blocking) {
cache = new BlockingCache(cache);
}
return cache;
} catch (Exception e) {
throw new CacheException("Error building standard cache decorators. Cause: " + e, e);
}
}
這次是根據(jù)其它的配置參數(shù)來:
如果配置了flushInterval,那么繼續(xù)裝飾為ScheduledCache,這意味著在調(diào)用Cache的getSize、putObject、getObject、removeObject四個(gè)方法的時(shí)候都會(huì)進(jìn)行一次時(shí)間判斷,如果到了指定的清理緩存時(shí)間間隔,那么就會(huì)將當(dāng)前緩存清空
如果readWrite=true,那么繼續(xù)裝飾為SerializedCache,這意味著緩存中所有存儲(chǔ)的內(nèi)存都必須實(shí)現(xiàn)Serializable接口
跟配置無關(guān),將之前裝飾好的Cache繼續(xù)裝飾為LoggingCache與SynchronizedCache,前者在getObject的時(shí)候會(huì)打印緩存命中率,后者將Cache接口中所有的方法都加了Synchronized關(guān)鍵字進(jìn)行了同步處理
如果blocking=true,那么繼續(xù)裝飾為BlockingCache,這意味著針對(duì)同一個(gè)CacheKey,拿數(shù)據(jù)與放數(shù)據(jù)、刪數(shù)據(jù)是互斥的,即拿數(shù)據(jù)的時(shí)候必須沒有在放數(shù)據(jù)、刪數(shù)據(jù)
Cache全部裝飾完畢,返回,至此MyBatis二級(jí)緩存生成完畢。
最后說一下,MyBatis支持三種類型的二級(jí)緩存:
MyBatis默認(rèn)的緩存,type為空,Cache為PerpetualCache
自定義緩存
第三方緩存
從build()方法來看,后兩種場(chǎng)景的Cache,MyBatis只會(huì)將其裝飾為LoggingCache,理由很簡(jiǎn)單,這些緩存的定期清除功能、淘汰過期數(shù)據(jù)功能開發(fā)者自己或者第三方緩存都已經(jīng)實(shí)現(xiàn)好了,根本不需要依賴MyBatis本身的裝飾。
以上這篇【MyBatis源碼全面解析】MyBatis一二級(jí)緩存介紹就是小編分享給大家的全部內(nèi)容了,希望能給大家一個(gè)參考,也希望大家多多支持腳本之家。
相關(guān)文章
Java基于socket服務(wù)實(shí)現(xiàn)UDP協(xié)議的方法
這篇文章主要介紹了Java基于socket服務(wù)實(shí)現(xiàn)UDP協(xié)議的方法,通過兩個(gè)簡(jiǎn)單實(shí)例分析了java通過socket實(shí)現(xiàn)UDP發(fā)送與接收的技巧,需要的朋友可以參考下2015-05-05
springboot整合kaptcha驗(yàn)證碼的示例代碼
kaptcha是一個(gè)很有用的驗(yàn)證碼生成工具,本篇文章主要介紹了springboot整合kaptcha驗(yàn)證碼的示例代碼,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-06-06
Java并發(fā)編程示例(六):等待線程執(zhí)行終止
這篇文章主要介紹了Java并發(fā)編程示例(六):等待線程執(zhí)行終止,在本節(jié),示例程序演示等待初始化方法完成后,再去執(zhí)行其他任務(wù),需要的朋友可以參考下2014-12-12
try-with-resource優(yōu)雅關(guān)閉io流的方法
這篇文章主要給大家介紹了關(guān)于try-with-resource優(yōu)雅關(guān)閉io流的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-01-01
在eclipse中使用SVN的實(shí)現(xiàn)方法(圖文教程)
這篇文章主要介紹了在eclipse中使用SVN的實(shí)現(xiàn)方法(圖文教程),文中通過圖文介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-07-07
java地理坐標(biāo)系及投影間轉(zhuǎn)換代碼示例
在地圖投影中,經(jīng)常需要將坐標(biāo)從不同的坐標(biāo)系之間進(jìn)行轉(zhuǎn)換,下面這篇文章主要給大家介紹了關(guān)于java地理坐標(biāo)系及投影間轉(zhuǎn)換的相關(guān)資料,需要的朋友可以參考下2024-08-08

